generated from ttipo/project-base
chore: 14단계(메일 검색 색인 · 검색 · 대화)를 앱 검색 브랜치에 합친다
앱 검색 화면(App1Search)은 계약 2절 "검색 — 14단계", 웹 글 원본과
백엔드 표본 표(backend/test/search-text-samples.ts)를 짝 시험으로 읽는다.
feat/mail-search-index 는 아직 main 에 없어서, 갈래점이 ba80956 인 앱 브랜치에
먼저 합친다. 14단계가 main 에 들어가면 이 브랜치를 main 위로 옮긴다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionModule } from './auth-session.module';
|
||||
import { AuthSessionsController } from './auth-sessions.controller';
|
||||
@@ -38,6 +39,8 @@ import { LoginAttemptModule } from './login-attempt.module';
|
||||
MailDirectoryModule,
|
||||
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
|
||||
AccountSecurityModule,
|
||||
// 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다)
|
||||
MailSearchModule,
|
||||
],
|
||||
controllers: [AuthController, AuthSessionsController],
|
||||
providers: [
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { CredentialCheck } from '@/modules/mail/mail.types';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { hashRefreshToken } from './auth-session.policy';
|
||||
import type {
|
||||
@@ -259,6 +260,8 @@ describe('AuthService', () => {
|
||||
sessionState: jest.Mock;
|
||||
isEnabledInEngine: jest.Mock;
|
||||
};
|
||||
/** 검색 색인 대역 (14단계) — 로그인한 직원의 상태 줄 */
|
||||
let searchIndex: { enrollAfterLogin: jest.Mock };
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -277,6 +280,7 @@ describe('AuthService', () => {
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
twoFactorPolicy as unknown as TwoFactorPolicyService,
|
||||
searchIndex as unknown as MailSearchIndexService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -329,6 +333,7 @@ describe('AuthService', () => {
|
||||
.mockResolvedValue({ restriction: null, deadline: null }),
|
||||
isEnabledInEngine: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
searchIndex = { enrollAfterLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -395,6 +400,22 @@ describe('AuthService', () => {
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('로그인에 성공한 직원의 검색 색인 상태 줄을 만든다 (14단계) — 실패한 로그인 · 코드를 기다리는 로그인은 만들지 않는다', async () => {
|
||||
await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
|
||||
|
||||
searchIndex.enrollAfterLogin.mockClear();
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
expect((await service.login(ARA, PASSWORD, CLIENT_IP)).status).toBe(
|
||||
'twoFactorRequired',
|
||||
);
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
|
||||
directory.recordLogin.mockRejectedValue(new Error('db down'));
|
||||
|
||||
@@ -914,6 +935,20 @@ describe('AuthService', () => {
|
||||
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('비밀번호가 바뀌면 검색 색인의 멈춤을 풀고 줄을 만든다 (14단계 — 만료로 멈춘 색인) · 바뀌지 않았으면 하지 않는다', async () => {
|
||||
const current = await loginToken();
|
||||
searchIndex.enrollAfterLogin.mockClear();
|
||||
|
||||
mail.changePassword.mockResolvedValueOnce('new-password-rejected');
|
||||
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
|
||||
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
|
||||
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
|
||||
});
|
||||
|
||||
it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => {
|
||||
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
|
||||
.tokens!.refreshToken;
|
||||
@@ -1804,6 +1839,8 @@ describe('AuthService', () => {
|
||||
expect(decode(result.tokens.accessToken).rst).toBe('passwordChange');
|
||||
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
// 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계)
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: ARA, isSuccess: true }),
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
PasswordChangeOutcome,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
@@ -183,6 +184,8 @@ export class AuthService {
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
/** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */
|
||||
private readonly twoFactorPolicy: TwoFactorPolicyService,
|
||||
/** 14단계 — 로그인한 직원의 검색 색인 상태 줄 (처음이면 만들고, 멈춰 있으면 푼다 — 던지지 않는다) */
|
||||
private readonly searchIndex: MailSearchIndexService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -300,6 +303,9 @@ export class AuthService {
|
||||
const tokens = await this.startSession(profile, client, state);
|
||||
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
|
||||
if (!passwordExpired) await this.recordInDirectory(profile);
|
||||
// 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다)
|
||||
if (!passwordExpired)
|
||||
await this.searchIndex.enrollAfterLogin(profile.email);
|
||||
await this.loginEvents.recordWebmail({
|
||||
email: profile.email,
|
||||
isSuccess: true,
|
||||
@@ -543,6 +549,8 @@ export class AuthService {
|
||||
refreshToken,
|
||||
twoFactorEnabled,
|
||||
);
|
||||
// 검색 색인(14단계) — 비밀번호가 바뀌었으니(만료도 풀렸다) 대리 접근이 다시 된다: 멈춘 줄을 풀고, 없던 줄은 만든다 (던지지 않는다)
|
||||
await this.searchIndex.enrollAfterLogin(address);
|
||||
} finally {
|
||||
releaseLock?.();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
|
||||
import {
|
||||
conversationCursorMethod,
|
||||
decodeConversationCursor,
|
||||
encodeConversationCursor,
|
||||
} from './conversation-cursor';
|
||||
import { encodeIndexCursor } from './search-cursor';
|
||||
|
||||
function codeOf(run: () => unknown): string | null {
|
||||
try {
|
||||
run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('대화 커서 (conversation-cursor — 14단계 C3 · C4)', () => {
|
||||
const position = { at: new Date('2026-11-02T01:23:45.678Z'), key: 'm9x' };
|
||||
|
||||
it('자리를 그대로 되돌린다 — 같은 대화는 그 메일, 묶은 목록은 그 메일함 범위에서만', () => {
|
||||
const thread = encodeConversationCursor('thread', position, 'e1');
|
||||
const list = encodeConversationCursor('list', position, 'inbox');
|
||||
expect(thread).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(decodeConversationCursor('thread', thread, 'e1')).toEqual(position);
|
||||
expect(decodeConversationCursor('list', list, 'inbox')).toEqual(position);
|
||||
|
||||
// 다른 메일 · 다른 메일함 · 다른 종류의 커서는 VAL_001
|
||||
expect(codeOf(() => decodeConversationCursor('thread', thread, 'e2'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(codeOf(() => decodeConversationCursor('list', list, 'sent'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(codeOf(() => decodeConversationCursor('list', thread, 'e1'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('방식을 커서로 안다 — 그 종류의 색인 커서만 index, 엔진 · 검색 커서 · 알 수 없는 값은 engine', () => {
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeConversationCursor('thread', position, 'e1'),
|
||||
'thread',
|
||||
),
|
||||
).toBe('index');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeConversationCursor('list', position, 'inbox'),
|
||||
'thread',
|
||||
),
|
||||
).toBe('engine');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeCursor(
|
||||
{ anchor: 'm1', position: 3 },
|
||||
{ conditions: ['conversation', 'e1'] },
|
||||
),
|
||||
'thread',
|
||||
),
|
||||
).toBe('engine');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeIndexCursor({ receivedAt: position.at, emailId: 'm1' }, 'scope'),
|
||||
'list',
|
||||
),
|
||||
).toBe('engine');
|
||||
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600)]) {
|
||||
expect(conversationCursorMethod(cursor, 'list')).toBe('engine');
|
||||
}
|
||||
|
||||
// `m` 만 맞은 꾸민 커서는 색인 커서로 본다 — 나머지 모양은 decode 가 VAL_001 로 (서비스 입구 — 리뷰 L1)
|
||||
const onlyMethod = Buffer.from(JSON.stringify({ m: 'ti' })).toString(
|
||||
'base64url',
|
||||
);
|
||||
expect(conversationCursorMethod(onlyMethod, 'thread')).toBe('index');
|
||||
expect(
|
||||
codeOf(() => decodeConversationCursor('thread', onlyMethod, 'e1')),
|
||||
).toBe('VAL_001');
|
||||
expect(conversationCursorMethod(onlyMethod, 'list')).toBe('engine');
|
||||
});
|
||||
|
||||
it('모양이 틀린 커서는 VAL_001 — 시각이 날짜가 아님 · 이상한 키 · 너무 긴 값', () => {
|
||||
const real = JSON.parse(
|
||||
Buffer.from(
|
||||
encodeConversationCursor('list', position, 'inbox'),
|
||||
'base64url',
|
||||
).toString('utf8'),
|
||||
) as Record<string, unknown>;
|
||||
const forge = (patch: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify({ ...real, ...patch })).toString('base64url');
|
||||
for (const cursor of [
|
||||
forge({ t: 9e15 }),
|
||||
forge({ t: -1 }),
|
||||
forge({ k: '../x' }),
|
||||
forge({ k: 'k'.repeat(65) }),
|
||||
forge({ v: 2 }),
|
||||
'not-json',
|
||||
'a'.repeat(600),
|
||||
]) {
|
||||
expect(
|
||||
codeOf(() => decodeConversationCursor('list', cursor, 'inbox')),
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
import { SEARCH_ID_COLUMN_LENGTH } from './mail-search.constants';
|
||||
|
||||
/**
|
||||
* 색인으로 본 대화의 커서 (14단계 설계 C3 · C4 — 클라이언트에게는 불투명 문자열).
|
||||
* - 같은 대화(`m: 'ti'`): `{ v: 1, m, t: 받은 시각(ms), k: 메일 id, s }` — 그 줄 다음(오래된 차례 — 받은 시각 · id 가 더 큰 쪽)부터
|
||||
* - 묶은 목록(`m: 'li'`): `{ v: 1, m, t: 그 메일함 안 마지막 받은 시각(ms), k: 대화 키, s }` — 그 대화 다음(최신 차례 — 더 작은 쪽)부터
|
||||
* 방식(m)이 커서에 실려 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로 이어 간다 — 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로.
|
||||
* `m` 이 그 종류의 방식이면 색인 커서로 보고 나머지 모양은 서비스 입구에서 decode 로 본다(엔진 · DB 에 묻기 전에 VAL_001 — 3차 리뷰 L1).
|
||||
* 범위 지문(s)에는 그 메일 id(같은 대화) · 메일함 id(묶은 목록)가 든다 — 다른 대화 · 메일함의 커서를 섞어 쓰면 VAL_001
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 512;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** 대화 키 · 메일 id 의 최대 길이 — 표의 열(thread_key · email_id) */
|
||||
const MAX_KEY_LENGTH = SEARCH_ID_COLUMN_LENGTH;
|
||||
|
||||
/** 커서의 종류 */
|
||||
export type ConversationCursorKind = 'thread' | 'list';
|
||||
|
||||
const METHOD: Readonly<Record<ConversationCursorKind, string>> = {
|
||||
thread: 'ti',
|
||||
list: 'li',
|
||||
};
|
||||
|
||||
/** 이어 갈 자리 — 받은 시각과 그 줄(같은 대화는 메일 id, 묶은 목록은 대화 키) */
|
||||
export interface ConversationPosition {
|
||||
readonly at: Date;
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
m: string;
|
||||
t: number;
|
||||
k: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
function scopeHash(kind: ConversationCursorKind, scope: string): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([kind, scope]))
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 방식 — `m` 이 그 종류의 색인 방식이면 'index'(판 · 시각 · 키 · 지문은 decodeConversationCursor 가 본다 — 틀리면 VAL_001),
|
||||
* 아니면 'engine'(엔진 쪽 커서 — 모양이 틀리면 엔진 쪽 decode 가 VAL_001)
|
||||
*/
|
||||
export function conversationCursorMethod(
|
||||
cursor: string,
|
||||
kind: ConversationCursorKind,
|
||||
): 'index' | 'engine' {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
) as unknown;
|
||||
return typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(parsed as Partial<EncodedCursor>).m === METHOD[kind]
|
||||
? 'index'
|
||||
: 'engine';
|
||||
} catch {
|
||||
return 'engine';
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeConversationCursor(
|
||||
kind: ConversationCursorKind,
|
||||
position: ConversationPosition,
|
||||
scope: string,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
m: METHOD[kind],
|
||||
t: position.at.getTime(),
|
||||
k: position.key,
|
||||
s: scopeHash(kind, scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 VAL_001 */
|
||||
export function decodeConversationCursor(
|
||||
kind: ConversationCursorKind,
|
||||
cursor: string,
|
||||
scope: string,
|
||||
): ConversationPosition {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor('not-json');
|
||||
}
|
||||
const { v, m, t, k, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
m === METHOD[kind] &&
|
||||
typeof t === 'number' &&
|
||||
Number.isSafeInteger(t) &&
|
||||
t >= 0 &&
|
||||
Number.isFinite(new Date(t).getTime()) &&
|
||||
typeof k === 'string' &&
|
||||
k.length <= MAX_KEY_LENGTH &&
|
||||
MAIL_ID_PATTERN.test(k) &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('shape');
|
||||
if (s !== scopeHash(kind, scope)) throw invalidCursor('other-list');
|
||||
return { at: new Date(t), key: k };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
conversationDetailsSql,
|
||||
conversationPageSql,
|
||||
conversationParticipantsSql,
|
||||
mailboxConversationsSql,
|
||||
} from './conversation-sql';
|
||||
|
||||
const USER = 'ara.kim@arasystem.test';
|
||||
const EXCLUSIONS = {
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
|
||||
|
||||
describe('대화의 SQL (conversation-sql — 14단계 C3 · C4)', () => {
|
||||
it('같은 대화 한 쪽 — 그 메일의 대화 키를 같은 문장에서 읽고(리뷰 L2), 오래된 차례 · 빼는 메일함에만 든 메일 · 내부 발송 초안은 빼되 지금 메일은 넣고, 이어 갈 자리 다음부터', () => {
|
||||
const after = { at: new Date('2026-11-01T00:00:00Z'), key: 'm3' };
|
||||
const query = conversationPageSql(USER, 'm5', EXCLUSIONS, after, 101);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "page"."email_id", "page"."received_at", "page"."total" ' +
|
||||
'FROM (SELECT "thread_key" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = $2) AS "current" ' +
|
||||
'LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ' +
|
||||
'AND ((NOT ("mailbox_ids" <@ $3::text[]) AND NOT ("keywords" && $4::text[])) OR "email_id" = $2) ' +
|
||||
'AND ("received_at", "email_id") > ($5::timestamptz, $6) ' +
|
||||
'ORDER BY "received_at" ASC, "email_id" ASC LIMIT $7) AS "page" ON TRUE ' +
|
||||
'ORDER BY "page"."received_at" ASC, "page"."email_id" ASC',
|
||||
);
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
'm5',
|
||||
['b', 'c'],
|
||||
['$ara-sending'],
|
||||
after.at,
|
||||
'm3',
|
||||
101,
|
||||
]);
|
||||
|
||||
// 빼는 키워드만 있어도 지금 메일은 넣는다
|
||||
const keywordOnly = conversationPageSql(
|
||||
USER,
|
||||
'm5',
|
||||
{ excludedMailboxIds: [], excludedKeywords: ['$ara-sending'] },
|
||||
null,
|
||||
10,
|
||||
);
|
||||
expect(flat(keywordOnly.sql)).toContain(
|
||||
'AND ((NOT ("keywords" && $3::text[])) OR "email_id" = $2) ORDER BY',
|
||||
);
|
||||
});
|
||||
|
||||
it('묶은 목록 한 쪽 — 그 직원의 줄(주소 btree)에서 메일함은 GIN 을 못 쓰는 꼴(= ANY)로 거른 뒤 대화 키로 묶는다', () => {
|
||||
const query = mailboxConversationsSql(USER, 'a', EXCLUSIONS, null, 21);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND $2 = ANY("mailbox_ids") AND NOT ("keywords" && $3::text[]) ' +
|
||||
'GROUP BY "thread_key" ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT $4',
|
||||
);
|
||||
expect(query.params).toEqual([USER, 'a', ['$ara-sending'], 21]);
|
||||
expect(query.sql).not.toContain('"mailbox_ids" @>');
|
||||
|
||||
const next = mailboxConversationsSql(
|
||||
USER,
|
||||
'a',
|
||||
EXCLUSIONS,
|
||||
{ at: new Date('2026-11-01T00:00:00Z'), key: 'k9' },
|
||||
21,
|
||||
);
|
||||
expect(flat(next.sql)).toContain(
|
||||
'GROUP BY "thread_key" HAVING (max("received_at"), "thread_key") < ($4::timestamptz, $5) ORDER BY',
|
||||
);
|
||||
});
|
||||
|
||||
it('묶은 목록의 자세히 — 그 쪽의 대화 키만, 통 수는 대화 전체(빼는 메일함에만 든 메일 빼고 — 그 메일함 것은 넣고), 그 메일함 안 id 는 최신부터 상한까지(리뷰 L4) · 보낸 사람 글은 끌어오지 않는다', () => {
|
||||
const query = conversationDetailsSql(
|
||||
USER,
|
||||
'a',
|
||||
['k1', 'k2'],
|
||||
EXCLUSIONS,
|
||||
500,
|
||||
);
|
||||
const sql = flat(query.sql);
|
||||
|
||||
expect(sql).toContain(
|
||||
'WHERE "user_email" = $1 AND "thread_key" = ANY($2::text[]) AND NOT ("keywords" && $8::text[]) GROUP BY "thread_key"',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'count(*) FILTER (WHERE (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")))::int AS "message_count"',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'(array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE $3 = ANY("mailbox_ids")))[1:$7::int] AS "mailbox_message_ids"',
|
||||
);
|
||||
expect(sql).not.toContain('from_text');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['k1', 'k2'],
|
||||
'a',
|
||||
['b', 'c'],
|
||||
'$seen',
|
||||
'$flagged',
|
||||
500,
|
||||
['$ara-sending'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('묶은 목록의 참여자 — 첫 보낸 사람 칸에서 주소(소문자)마다 처음 보낸 메일 하나, 그 차례로 상한까지 · 대화마다 (리뷰 L3 · L4)', () => {
|
||||
const query = conversationParticipantsSql(
|
||||
USER,
|
||||
'a',
|
||||
['k1', 'k2'],
|
||||
EXCLUSIONS,
|
||||
10,
|
||||
);
|
||||
const sql = flat(query.sql);
|
||||
|
||||
expect(sql).toContain('FROM unnest($2::text[]) AS "keys"("thread_key")');
|
||||
expect(sql).toContain(
|
||||
'SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL ' +
|
||||
'AND (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")) AND NOT ("keywords" && $5::text[]) ' +
|
||||
'ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT $6) AS "first"',
|
||||
);
|
||||
expect(sql).not.toContain('from_text');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['k1', 'k2'],
|
||||
'a',
|
||||
['b', 'c'],
|
||||
['$ara-sending'],
|
||||
10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('빼는 것이 없으면 조건도 없다 (지금 메일의 예외도 없다)', () => {
|
||||
const none = { excludedMailboxIds: [], excludedKeywords: [] };
|
||||
const page = conversationPageSql(USER, 'm5', none, null, 10);
|
||||
expect(flat(page.sql)).toContain(
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ORDER BY',
|
||||
);
|
||||
expect(page.params).toEqual([USER, 'm5', 10]);
|
||||
|
||||
const details = conversationDetailsSql(USER, 'a', ['k1'], none, 500);
|
||||
expect(flat(details.sql)).toContain(
|
||||
'count(*) FILTER (WHERE TRUE)::int AS "message_count"',
|
||||
);
|
||||
expect(flat(details.sql)).toContain('AND TRUE GROUP BY "thread_key"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
FLAGGED_KEYWORD,
|
||||
SEARCH_MESSAGES_TABLE,
|
||||
SEEN_KEYWORD,
|
||||
} from './mail-search.constants';
|
||||
import type { ConversationPosition } from './conversation-cursor';
|
||||
import type { SqlQuery } from './search-sql';
|
||||
|
||||
/**
|
||||
* 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다.
|
||||
* 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 —
|
||||
* 메일함의 posting 은 회사 전체라(메일함 id 가 계정마다 같은 꼴) GIN 으로 거르지 않고 그 직원의 줄에서만 거른다(리뷰).
|
||||
* 메일함 칸을 담은 GIN 은 찾기 GIN(lower 식 · 묶음 · 메일함) 하나뿐이고 이 쿼리들은 그것을 쓰지 않는다
|
||||
*/
|
||||
|
||||
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
|
||||
|
||||
/** 대화에서 빼는 것 — 빼는 메일함에만 든 메일 · 빼는 키워드(내부 발송 초안) */
|
||||
export interface ConversationExclusions {
|
||||
readonly excludedMailboxIds: readonly string[];
|
||||
readonly excludedKeywords: readonly string[];
|
||||
}
|
||||
|
||||
/** 매개변수를 차례로 쌓는다 */
|
||||
class Params {
|
||||
readonly values: unknown[] = [];
|
||||
|
||||
add(value: unknown): string {
|
||||
this.values.push(value);
|
||||
return `$${this.values.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 빼는 키워드 — 없으면 늘 참 */
|
||||
function keywordCondition(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
): string {
|
||||
return exclusions.excludedKeywords.length > 0
|
||||
? `NOT ("keywords" && ${params.add([...exclusions.excludedKeywords])}::text[])`
|
||||
: 'TRUE';
|
||||
}
|
||||
|
||||
/** 빼는 메일함에만 든 메일이 아니다 — 없으면 늘 참 */
|
||||
function mailboxCondition(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
): string {
|
||||
return exclusions.excludedMailboxIds.length > 0
|
||||
? `NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[])`
|
||||
: 'TRUE';
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 대화 한 쪽 — 지금 보는 메일(`emailId`)의 대화 키를 같은 문장에서 읽어(3차 리뷰 L2 — 키를 읽은 뒤 그 키가 합쳐져 빈 쪽이 나오지 않게 · 왕복 하나)
|
||||
* 그 키의 줄을 오래된 차례(받은 시각 · id)로, `after` 다음부터 `limit` 줄. 빼는 메일함에만 든 메일 · 빼는 키워드의 메일(내부 발송 초안)은 빼되
|
||||
* 지금 보는 메일은 넣는다(엔진 방식 `jmap-conversations.ts` 의 inConversation 과 같다).
|
||||
* - 그 메일이 색인에 없으면 줄이 없다. 있는데 쪽이 비면 `email_id` 가 null 인 줄 하나다(LEFT JOIN LATERAL) — 둘을 가른다
|
||||
* - `total` 은 이 쪽의 조건(이어 갈 자리 빼고)에 맞는 줄 수 — 첫 쪽에서만 쓴다
|
||||
*/
|
||||
export function conversationPageSql(
|
||||
user: string,
|
||||
emailId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
after: ConversationPosition | null,
|
||||
limit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const email$ = params.add(emailId);
|
||||
const where = [
|
||||
`"user_email" = ${user$}`,
|
||||
`"thread_key" = "current"."thread_key"`,
|
||||
];
|
||||
const kept = [
|
||||
mailboxCondition(params, exclusions),
|
||||
keywordCondition(params, exclusions),
|
||||
].filter((condition) => condition !== 'TRUE');
|
||||
if (kept.length > 0) {
|
||||
where.push(`((${kept.join(' AND ')}) OR "email_id" = ${email$})`);
|
||||
}
|
||||
if (after !== null) {
|
||||
where.push(
|
||||
`("received_at", "email_id") > (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sql:
|
||||
`SELECT "page"."email_id", "page"."received_at", "page"."total"` +
|
||||
` FROM (SELECT "thread_key" FROM ${TABLE} WHERE "user_email" = ${user$} AND "email_id" = ${email$}) AS "current"` +
|
||||
` LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM ${TABLE}` +
|
||||
` WHERE ${where.join(' AND ')} ORDER BY "received_at" ASC, "email_id" ASC LIMIT ${params.add(limit)}) AS "page" ON TRUE` +
|
||||
` ORDER BY "page"."received_at" ASC, "page"."email_id" ASC`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록 한 쪽 — 그 메일함 안의 대화(대화 키)마다 그 메일함 안 마지막 받은 시각, 최신부터 `limit` 개.
|
||||
* 그 직원의 줄(주소 btree)에서만 메일함 · 키워드로 거른 뒤 대화 키로 묶는다. `after` 면 그 대화 다음부터(받은 시각 · 키가 더 작은 쪽).
|
||||
* `total` 은 묶은 대화 수(이어 갈 자리 다음의 것) — 첫 쪽에서만 쓴다
|
||||
*/
|
||||
export function mailboxConversationsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
after: ConversationPosition | null,
|
||||
limit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = [
|
||||
`"user_email" = ${params.add(user)}`,
|
||||
`${params.add(mailboxId)} = ANY("mailbox_ids")`,
|
||||
keywordCondition(params, exclusions),
|
||||
];
|
||||
const having =
|
||||
after === null
|
||||
? ''
|
||||
: ` HAVING (max("received_at"), "thread_key") < (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`;
|
||||
return {
|
||||
sql: `SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM ${TABLE} WHERE ${where.join(' AND ')} GROUP BY "thread_key"${having} ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT ${params.add(limit)}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화마다 자세히 — 그 쪽의 대화 키만(주소 + 대화 키 btree).
|
||||
* - 대화 전체(빼는 메일함에만 든 메일은 빼되 그 메일함의 메일은 넣는다): 통 수
|
||||
* - 그 메일함 안: 메일 id(최신부터 `maxIds` 통까지 — 첫째가 마지막 메일, 3차 리뷰 L4) · 모두 읽음 · 하나라도 중요 · 하나라도 첨부
|
||||
* 참여자는 conversationParticipantsSql 이 따로 읽는다(주소마다 한 번 · 상한까지 — 대화 전체의 보낸 사람 글을 끌어오지 않는다)
|
||||
*/
|
||||
export function conversationDetailsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
threadKeys: readonly string[],
|
||||
exclusions: ConversationExclusions,
|
||||
maxIds: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const keys$ = params.add([...threadKeys]);
|
||||
const mailbox$ = params.add(mailboxId);
|
||||
const inMailbox = `${mailbox$} = ANY("mailbox_ids")`;
|
||||
const kept = keptInConversation(params, exclusions, inMailbox);
|
||||
const seen$ = params.add(SEEN_KEYWORD);
|
||||
const flagged$ = params.add(FLAGGED_KEYWORD);
|
||||
const maxIds$ = params.add(maxIds);
|
||||
const keywords = keywordCondition(params, exclusions);
|
||||
return {
|
||||
sql:
|
||||
`SELECT "thread_key",` +
|
||||
` count(*) FILTER (WHERE ${kept})::int AS "message_count",` +
|
||||
` (array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE ${inMailbox}))[1:${maxIds$}::int] AS "mailbox_message_ids",` +
|
||||
` coalesce(bool_and(${seen$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), true) AS "is_read",` +
|
||||
` coalesce(bool_or(${flagged$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), false) AS "is_flagged",` +
|
||||
` coalesce(bool_or("has_attachment") FILTER (WHERE ${inMailbox}), false) AS "has_attachment"` +
|
||||
` FROM ${TABLE} WHERE "user_email" = ${user$} AND "thread_key" = ANY(${keys$}::text[]) AND ${keywords} GROUP BY "thread_key"`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화마다 참여자 — 첫 보낸 사람 칸(from_email · from_name — 3차 리뷰 L3)에서 주소(소문자)마다 처음 보낸 메일 하나,
|
||||
* 그 차례(받은 시각 · id)로 `max` 명까지(대화마다 LATERAL — 주소 + 대화 키 btree). 대화의 범위는 자세히(통 수)와 같다
|
||||
*/
|
||||
export function conversationParticipantsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
threadKeys: readonly string[],
|
||||
exclusions: ConversationExclusions,
|
||||
max: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const keys$ = params.add([...threadKeys]);
|
||||
const mailbox$ = params.add(mailboxId);
|
||||
const kept = keptInConversation(
|
||||
params,
|
||||
exclusions,
|
||||
`${mailbox$} = ANY("mailbox_ids")`,
|
||||
);
|
||||
const keywords = keywordCondition(params, exclusions);
|
||||
const max$ = params.add(max);
|
||||
return {
|
||||
sql:
|
||||
`SELECT "keys"."thread_key", "first"."from_email", "first"."from_name"` +
|
||||
` FROM unnest(${keys$}::text[]) AS "keys"("thread_key")` +
|
||||
` CROSS JOIN LATERAL (SELECT "senders"."from_email", "senders"."from_name", "senders"."received_at", "senders"."email_id" FROM (` +
|
||||
`SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM ${TABLE}` +
|
||||
` WHERE "user_email" = ${user$} AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL AND ${kept} AND ${keywords}` +
|
||||
` ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC) AS "senders"` +
|
||||
` ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT ${max$}) AS "first"` +
|
||||
` ORDER BY "keys"."thread_key", "first"."received_at" ASC, "first"."email_id" ASC`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 대화에 드는 줄 — 빼는 메일함에만 든 메일이 아니거나 `orCondition`(그 메일함의 메일) */
|
||||
function keptInConversation(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
orCondition: string,
|
||||
): string {
|
||||
return exclusions.excludedMailboxIds.length > 0
|
||||
? `(NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[]) OR ${orCondition})`
|
||||
: 'TRUE';
|
||||
}
|
||||
@@ -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,232 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
firstSender,
|
||||
isIndexableId,
|
||||
referencesOf,
|
||||
storableText,
|
||||
takeCodePoints,
|
||||
toIndexRow,
|
||||
} from './index-document';
|
||||
import type { IndexDocument } from './mail-index-source';
|
||||
import {
|
||||
INDEX_ADDRESSES_MAX,
|
||||
INDEX_BODY_MAX_CHARS,
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
INDEX_REFS_MAX,
|
||||
INDEX_SUBJECT_MAX_CHARS,
|
||||
SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
} 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>',
|
||||
// 공백류(줄바꿈 · 탭 등)는 띄어쓰기 하나로 접는다 — 1:1 이라 위치는 그대로 (색인 판 2)
|
||||
foldTo: 'ara.kim@arasystem.test partner <partner@example.com>',
|
||||
foldSubject: 're: [공지] 11월 계약서',
|
||||
foldAttachment: '계약서.pdf',
|
||||
foldBody: '안녕하세요. 계약서를 보내 드립니다.',
|
||||
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('첫 보낸 사람 칸(참여자 — 리뷰 L3) — 주소 · 이름을 따로, 이름 안의 `<주소>` 꼴은 주소가 되지 않는다', () => {
|
||||
// 주소 없이 이름만 `대표 <ceo@…>` — 보낸 사람 글(조건 검색)에는 남지만 참여자 칸은 비운다
|
||||
const nameOnly = toIndexRow(
|
||||
documentOf({ from: [{ name: '대표 <ceo@example.com>', email: null }] }),
|
||||
);
|
||||
expect(nameOnly.fromText).toBe('대표 <ceo@example.com>');
|
||||
expect(nameOnly.fromEmail).toBeNull();
|
||||
expect(nameOnly.fromName).toBeNull();
|
||||
|
||||
// 이름이 `<다른 주소>` 를 품어도 칸의 주소는 엔진이 준 주소다
|
||||
const disguised = toIndexRow(
|
||||
documentOf({
|
||||
from: [
|
||||
{ name: ' 대표 <ceo@example.com> ', email: ' real@example.net ' },
|
||||
{ name: '둘째', email: 'second@example.com' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(disguised.fromEmail).toBe('real@example.net');
|
||||
expect(disguised.fromName).toBe('대표 <ceo@example.com>');
|
||||
|
||||
expect(toIndexRow(documentOf({ from: [] })).fromEmail).toBeNull();
|
||||
});
|
||||
|
||||
it('첫 보낸 사람 — 이름은 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다) · 주소가 열보다 길면 담지 않는다', () => {
|
||||
const long = firstSender([
|
||||
{
|
||||
name: `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀`,
|
||||
email: 'a@example.com',
|
||||
},
|
||||
]);
|
||||
expect([...(long.name ?? '')]).toHaveLength(INDEX_FROM_NAME_MAX_CHARS);
|
||||
expect(long.name?.endsWith('😀')).toBe(true);
|
||||
|
||||
const tooLong = `${'a'.repeat(SEARCH_EMAIL_COLUMN_LENGTH)}@example.com`;
|
||||
expect(firstSender([{ name: '긴 주소', email: tooLong }])).toEqual({
|
||||
email: null,
|
||||
name: null,
|
||||
});
|
||||
expect(firstSender([{ name: ' ', email: 'a@example.com' }])).toEqual({
|
||||
email: 'a@example.com',
|
||||
name: null,
|
||||
});
|
||||
});
|
||||
|
||||
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,247 @@
|
||||
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_FROM_NAME_MAX_CHARS,
|
||||
INDEX_MESSAGE_ID_MAX_LENGTH,
|
||||
INDEX_PREVIEW_MAX_CHARS,
|
||||
INDEX_REFS_MAX,
|
||||
INDEX_SUBJECT_MAX_CHARS,
|
||||
SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
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 는 뺀다
|
||||
* - 첫 보낸 사람은 주소 · 이름을 따로도 둔다(from_email · from_name — 묶은 목록의 참여자, 3차 리뷰 L3). 주소가 없으면 null — 이름 안의 `<주소>` 꼴은 주소로 읽지 않는다
|
||||
* - 대화 키는 넣을 때 그 메일 id 다 — 색인에 쓸 때 이어진 메일의 키와 합친다(3차 — 설계 B4, mail-search-index.repository.ts 의 linkThreads)
|
||||
*/
|
||||
|
||||
/** 색인 한 줄의 값 — 표의 칸과 같다 (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 fromEmail: string | null;
|
||||
readonly fromName: string | null;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 이름 — 앞뒤 공백을 지운 NFC, 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다), 비면 null.
|
||||
* 색인의 첫 보낸 사람 칸(firstSender)과 엔진 방식 대화의 참여자(modules/mail/jmap/jmap-conversations.ts)가 같은 자르기를 쓴다
|
||||
*/
|
||||
export function participantName(
|
||||
name: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = storableText(
|
||||
(name ?? '').trim(),
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
).trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 보낸 사람 — 주소(앞뒤 공백을 지운 NFC, SEARCH_EMAIL_COLUMN_LENGTH 를 넘거나 없으면 null)와 이름(participantName).
|
||||
* 목록 요약(jmap.mapper.ts 의 toAddress — `from[0]`)과 같은 사람이다. 주소가 없으면 이름도 두지 않는다(참여자는 주소로 센다)
|
||||
*/
|
||||
export function firstSender(addresses: readonly IndexAddress[]): {
|
||||
readonly email: string | null;
|
||||
readonly name: string | null;
|
||||
} {
|
||||
const first = addresses[0];
|
||||
const email = storableText(
|
||||
(first?.email ?? '').trim(),
|
||||
SEARCH_EMAIL_COLUMN_LENGTH + 1,
|
||||
);
|
||||
if (email === '' || email.length > SEARCH_EMAIL_COLUMN_LENGTH) {
|
||||
return { email: null, name: null };
|
||||
}
|
||||
return { email, name: participantName(first?.name) };
|
||||
}
|
||||
|
||||
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 sender = firstSender(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,
|
||||
fromEmail: sender.email,
|
||||
fromName: sender.name,
|
||||
toText,
|
||||
ccText,
|
||||
attachmentNames,
|
||||
hasAttachment: document.hasAttachment,
|
||||
preview: storableText(document.preview, INDEX_PREVIEW_MAX_CHARS),
|
||||
bodyText,
|
||||
foldFrom,
|
||||
foldTo,
|
||||
foldSubject,
|
||||
foldAttachment,
|
||||
foldBody,
|
||||
grams: [...grams],
|
||||
// 처음 키는 그 메일 id — 색인에 쓸 때 이어진 메일의 키와 합친다(mail-search-index.repository.ts 의 linkThreads)
|
||||
threadKey: document.id,
|
||||
engineThreadId: isIndexableId(threadId) ? threadId : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
encodeConversationCursor,
|
||||
type ConversationPosition,
|
||||
} from './conversation-cursor';
|
||||
import {
|
||||
conversationDetailsSql,
|
||||
conversationPageSql,
|
||||
conversationParticipantsSql,
|
||||
mailboxConversationsSql,
|
||||
type ConversationExclusions,
|
||||
} from './conversation-sql';
|
||||
import {
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from './mail-search.constants';
|
||||
import type { ConversationParticipant } from './mail-search.types';
|
||||
|
||||
/** 같은 대화 한 쪽 — 메일 id 만(요약은 부르는 쪽이 엔진에서 읽는다) */
|
||||
export interface IndexConversationPage {
|
||||
readonly ids: readonly string[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
/** 첫 쪽에서만 — 다음 쪽은 null */
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 묶은 목록의 대화 하나 (색인) — 마지막 메일의 요약은 부르는 쪽이 엔진에서 읽는다 */
|
||||
export interface IndexConversation {
|
||||
readonly key: string;
|
||||
/** 그 메일함 안 마지막 메일 */
|
||||
readonly latestId: string;
|
||||
/** 그 메일함 안 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 */
|
||||
readonly mailboxMessageIds: readonly string[];
|
||||
readonly messageCount: number;
|
||||
readonly participants: readonly ConversationParticipant[];
|
||||
readonly isRead: boolean;
|
||||
readonly isFlagged: boolean;
|
||||
readonly hasAttachment: boolean;
|
||||
}
|
||||
|
||||
export interface IndexConversationList {
|
||||
readonly items: readonly IndexConversation[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 쪽을 읽는 자리 — 커서는 서비스 입구에서 읽어(VAL_001 — 엔진 · DB 에 묻기 전) 자리로 넘긴다 (3차 리뷰 L1) */
|
||||
export interface ConversationPageRequest {
|
||||
readonly limit: number;
|
||||
readonly after: ConversationPosition | null;
|
||||
}
|
||||
|
||||
interface PageRow {
|
||||
/** 그 메일은 색인에 있는데 쪽이 비었으면 null (LEFT JOIN LATERAL) */
|
||||
readonly email_id: string | null;
|
||||
readonly received_at: Date | string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
interface ListRow {
|
||||
readonly thread_key: string;
|
||||
readonly latest_at: Date | string;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
readonly thread_key: string;
|
||||
readonly message_count: number;
|
||||
readonly mailbox_message_ids: string[] | null;
|
||||
readonly is_read: boolean;
|
||||
readonly is_flagged: boolean;
|
||||
readonly has_attachment: boolean;
|
||||
}
|
||||
|
||||
interface ParticipantRow {
|
||||
readonly thread_key: string;
|
||||
readonly from_email: string;
|
||||
readonly from_name: string | null;
|
||||
}
|
||||
|
||||
function dateOf(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
|
||||
function normalize(user: string): string {
|
||||
return user.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인으로 본 대화 (14단계 설계 C3 · C4) — 대화 키(B4)로 묶는다. 엔진을 모른다(빼는 메일함 · 키워드는 부르는 쪽이 엔진에서 받아 준다).
|
||||
* - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread)
|
||||
* - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터
|
||||
* CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지)
|
||||
* 모든 쿼리는 그 직원 주소(btree)로 먼저 좁히고 메일함은 그 줄에서 거른다 — 메일함의 GIN posting(회사 전체)을 쓰지 않는다(conversation-sql.ts)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailConversationQueryService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* 같은 대화 한 쪽 — 오래된 차례. 지금 보는 메일(`emailId`)은 빼는 메일함에 있어도 넣는다.
|
||||
* 그 메일이 색인에 없으면 null (부르는 쪽이 첫 쪽이면 엔진으로, 이어 가는 쪽이면 RES_001)
|
||||
*/
|
||||
async conversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
page: ConversationPageRequest,
|
||||
): Promise<IndexConversationPage | null> {
|
||||
const query = conversationPageSql(
|
||||
normalize(user),
|
||||
emailId,
|
||||
exclusions,
|
||||
page.after,
|
||||
page.limit + 1,
|
||||
);
|
||||
const rows = await this.dataSource.query<PageRow[]>(
|
||||
query.sql,
|
||||
query.params,
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const found = rows.flatMap((row) =>
|
||||
row.email_id !== null && row.received_at !== null
|
||||
? [
|
||||
{
|
||||
id: row.email_id,
|
||||
at: dateOf(row.received_at),
|
||||
total: row.total,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const shown = found.slice(0, page.limit);
|
||||
const hasNext = found.length > page.limit;
|
||||
const last = shown[shown.length - 1];
|
||||
return {
|
||||
ids: shown.map((row) => row.id),
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeConversationCursor(
|
||||
'thread',
|
||||
{ at: last.at, key: last.id },
|
||||
emailId,
|
||||
)
|
||||
: null,
|
||||
total: page.after === null ? Number(found[0]?.total ?? 0) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶은 목록 한 쪽 — 그 메일함 안 마지막 받은 시각 최신부터 */
|
||||
async mailboxConversations(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
page: ConversationPageRequest,
|
||||
): Promise<IndexConversationList> {
|
||||
const key = normalize(user);
|
||||
const { after } = page;
|
||||
// 쪽 · 자세히 · 참여자는 같은 스냅숏에서 — 그사이 온 메일로 대화 키가 합쳐져도 한 쪽 안에서는 어긋나지 않게
|
||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||
const listQuery = mailboxConversationsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
exclusions,
|
||||
after,
|
||||
page.limit + 1,
|
||||
);
|
||||
const rows = await manager.query<ListRow[]>(
|
||||
listQuery.sql,
|
||||
listQuery.params,
|
||||
);
|
||||
const shown = rows.slice(0, page.limit);
|
||||
const hasNext = rows.length > page.limit;
|
||||
const keys = shown.map((row) => row.thread_key);
|
||||
let detailRows: DetailRow[] = [];
|
||||
let participantRows: ParticipantRow[] = [];
|
||||
if (keys.length > 0) {
|
||||
const detailQuery = conversationDetailsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
keys,
|
||||
exclusions,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
);
|
||||
detailRows = await manager.query<DetailRow[]>(
|
||||
detailQuery.sql,
|
||||
detailQuery.params,
|
||||
);
|
||||
const participantQuery = conversationParticipantsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
keys,
|
||||
exclusions,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
);
|
||||
participantRows = await manager.query<ParticipantRow[]>(
|
||||
participantQuery.sql,
|
||||
participantQuery.params,
|
||||
);
|
||||
}
|
||||
const details = new Map(detailRows.map((row) => [row.thread_key, row]));
|
||||
const participants = new Map<string, ConversationParticipant[]>();
|
||||
for (const row of participantRows) {
|
||||
const list = participants.get(row.thread_key) ?? [];
|
||||
list.push({ name: row.from_name, email: row.from_email });
|
||||
participants.set(row.thread_key, list);
|
||||
}
|
||||
const items = shown.flatMap((row): IndexConversation[] => {
|
||||
const detail = details.get(row.thread_key);
|
||||
const ids = detail?.mailbox_message_ids ?? [];
|
||||
if (!detail || ids.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: row.thread_key,
|
||||
latestId: ids[0],
|
||||
mailboxMessageIds: ids,
|
||||
messageCount: Math.max(Number(detail.message_count), 1),
|
||||
participants: participants.get(row.thread_key) ?? [],
|
||||
isRead: detail.is_read,
|
||||
isFlagged: detail.is_flagged,
|
||||
hasAttachment: detail.has_attachment,
|
||||
},
|
||||
];
|
||||
});
|
||||
const last = shown[shown.length - 1];
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeConversationCursor(
|
||||
'list',
|
||||
{ at: dateOf(last.latest_at), key: last.thread_key },
|
||||
mailboxId,
|
||||
)
|
||||
: null,
|
||||
total: after === null ? Number(rows[0]?.total ?? 0) : 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,280 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationBootstrap,
|
||||
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,
|
||||
INDEX_FORMAT,
|
||||
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 implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(MailSearchBuildService.name);
|
||||
private running = false;
|
||||
/** 판이 다른 줄을 되돌렸나 — 서버가 뜰 때 못 했으면 1분 차례마다 다시 해 본다 (그동안 상태 API · 검색은 판을 보고 building 으로 다룬다) */
|
||||
private formatsReset = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 뜰 때 — 판(INDEX_FORMAT)이 다른 사내 직원의 상태 줄을 building 으로 되돌린다(밤에 다시 채운다 — 그동안 검색은 엔진 방식).
|
||||
* 되돌리지 못해도 서버는 뜬다 — 1분 차례가 다시 해 본다. 그동안 그 직원은 ready 여도 판이 달라 검색은 엔진 방식이고(isSearchReady)
|
||||
* 상태 API 도 building 으로 보인다(toStatusView)
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.resetOutdatedFormats();
|
||||
}
|
||||
|
||||
/** 판이 다른 줄 되돌리기 — 한 번 되면 다시 하지 않는다. 던지지 않는다 */
|
||||
private async resetOutdatedFormats(): Promise<void> {
|
||||
const suffix = this.sync.indexedAddressSuffix;
|
||||
if (suffix === null || this.formatsReset) return;
|
||||
try {
|
||||
const reset = await this.repository.resetOutdatedFormats(
|
||||
suffix,
|
||||
INDEX_FORMAT,
|
||||
new Date(this.timing.now()),
|
||||
);
|
||||
this.formatsReset = true;
|
||||
if (reset > 0) {
|
||||
// 배포마다 한 번뿐인 상태 변화 — 수만 (주소 없음)
|
||||
this.logger.log(
|
||||
`검색 색인의 판이 바뀌어 ${reset}명의 색인을 밤에 다시 채웁니다 (판 ${INDEX_FORMAT}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001] 판이 바뀐 검색 색인을 되돌리지 못했습니다(그 직원의 검색은 엔진 방식 — 1분 뒤 다시): ${error instanceof Error ? error.name : typeof error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 1분마다 — 밤 창 안이면 채울 사내 직원 한 사람 (앞 차례가 아직 돌면 건너뛴다) */
|
||||
@Cron('0 * * * * *', {
|
||||
name: 'mail-search-build',
|
||||
timeZone: SEARCH_CRON_TIME_ZONE,
|
||||
})
|
||||
async tick(): Promise<BuildOutcome | null> {
|
||||
const startedAt = this.timing.now();
|
||||
if (this.running || this.sync.isStopping) return null;
|
||||
// 서버가 뜰 때 판을 되돌리지 못했으면 낮에도 다시 해 본다 (한 번 되면 하지 않는다)
|
||||
await this.resetOutdatedFormats();
|
||||
if (!isBuildWindow(new Date(startedAt))) return null;
|
||||
this.running = true;
|
||||
try {
|
||||
const candidates = await this.repository.buildCandidates(
|
||||
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,
|
||||
{
|
||||
clearIndex: state.buildStartedAt === null,
|
||||
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,410 @@
|
||||
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 {
|
||||
INDEX_FORMAT,
|
||||
SEARCH_MESSAGES_TABLE,
|
||||
SEARCH_STATES_TABLE,
|
||||
} from './mail-search.constants';
|
||||
import { mergeThreadKeys } from './thread-keys';
|
||||
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 {
|
||||
/**
|
||||
* 그 직원의 색인 줄을 모두 지운다 — 채우기를 처음부터 시작할 때(판이 바뀌어 되돌린 직원 포함). 채우기는 위치로 건너뛸 수 있고 마지막 대조는
|
||||
* 없는 줄만 넣으므로, 남겨 두면 옛 판(접기 규칙)의 줄이 그대로 남았다. 그동안(building) 검색은 엔진 방식이다
|
||||
*/
|
||||
readonly clearIndex?: boolean;
|
||||
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)와 대화 키(thread_key) 밖의 모두.
|
||||
* 대화 키는 이미 있는 줄이면 그대로 둔다 — 합친 대화를 다시 넣기(겹친 채우기 등)가 풀지 않게 (새 줄은 그 메일의 id 로 넣고 linkThreads 가 잇는다)
|
||||
*/
|
||||
const UPSERT_COLUMNS = [
|
||||
'mailbox_ids',
|
||||
'keywords',
|
||||
'received_at',
|
||||
'size',
|
||||
'message_id',
|
||||
'refs',
|
||||
'subject',
|
||||
'subject_base',
|
||||
'from_text',
|
||||
'from_email',
|
||||
'from_name',
|
||||
'to_text',
|
||||
'cc_text',
|
||||
'attachment_names',
|
||||
'has_attachment',
|
||||
'preview',
|
||||
'body_text',
|
||||
'fold_from',
|
||||
'fold_to',
|
||||
'fold_subject',
|
||||
'fold_attachment',
|
||||
'fold_body',
|
||||
'grams',
|
||||
'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,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
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,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return fresh;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 판이 다른 줄을 building 으로 되돌린다 (색인 판 — mail-search.constants.ts 의 INDEX_FORMAT) → 되돌린 줄 수.
|
||||
* 맞출 기준 · 채우기 자리를 지워 밤에 처음부터 다시 채운다 — 옛 색인 줄은 채우기가 시작하는 첫 묶음에서 지운다(SearchIndexWrite 의 clearIndex).
|
||||
* 그동안 그 직원의 검색은 엔진 방식이다. `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 (같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다)
|
||||
*/
|
||||
async resetOutdatedFormats(
|
||||
addressSuffix: string,
|
||||
format: number,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
const [rows] = await this.dataSource.query<[unknown[], number]>(
|
||||
`UPDATE "${SEARCH_STATES_TABLE}"
|
||||
SET "status" = 'building', "email_state" = NULL, "build_anchor" = NULL, "build_done" = 0, "build_total" = NULL,
|
||||
"build_started_at" = NULL, "ready_at" = NULL, "failure_count" = 0, "index_format" = $2, "updated_at" = $3
|
||||
WHERE "index_format" <> $2 AND right("user_email", length($1)) = $1
|
||||
RETURNING "user_email"`,
|
||||
[addressSuffix, format, now],
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/** 5분 맞추기의 차례 — 맞출 기준(email_state)이 있고 멈추지 않은 직원, 오래 맞추지 않은 차례로 */
|
||||
syncCandidates(): Promise<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>() };
|
||||
}
|
||||
if (change.clearIndex) {
|
||||
await manager.query(
|
||||
`DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1`,
|
||||
[userEmail],
|
||||
);
|
||||
}
|
||||
const deletes = [...new Set(change.deletes ?? [])];
|
||||
if (deletes.length > 0) {
|
||||
await manager.query(
|
||||
`DELETE FROM "${SEARCH_MESSAGES_TABLE}" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
|
||||
[userEmail, deletes],
|
||||
);
|
||||
}
|
||||
const upserted = await this.upsertRows(
|
||||
manager,
|
||||
userEmail,
|
||||
change.upserts ?? [],
|
||||
now,
|
||||
);
|
||||
await this.linkThreads(manager, userEmail, upserted);
|
||||
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 — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
|
||||
private async upsertRows(
|
||||
manager: EntityManager,
|
||||
userEmail: string,
|
||||
rows: readonly IndexRowValues[],
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
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();
|
||||
return [...latest.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 키 잇기 (14단계 설계 B4) — 넣은 메일 M 마다, M 의 답장 관계(refs)가 가리키는 메일과 M 을 가리키는 메일 가운데 말머리를 뗀 제목이 같은 것의
|
||||
* 키를 M 의 키와 하나로(가장 작은 키 — thread-keys.ts). Message-ID 가 없는 메일은 잇지 않는다(혼자). 그 직원의 줄만 본다.
|
||||
* 쿼리 둘: 이어진 키 쌍 읽기(주소 + Message-ID 색인 · refs GIN) → 바꿀 키를 한 문장으로 UPDATE(주소 + 대화 키 색인). 쓰기 트랜잭션 안이다(상태 줄 잠금 — 같은 직원은 차례로)
|
||||
*/
|
||||
private async linkThreads(
|
||||
manager: EntityManager,
|
||||
userEmail: string,
|
||||
ids: readonly string[],
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const links = await manager.query<{ a: string; b: string }[]>(
|
||||
`SELECT "n"."thread_key" AS "a", "o"."thread_key" AS "b"
|
||||
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
|
||||
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
|
||||
ON "o"."user_email" = "n"."user_email" AND "o"."message_id" = ANY("n"."refs") AND "o"."subject_base" = "n"."subject_base"
|
||||
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
|
||||
AND "o"."thread_key" <> "n"."thread_key"
|
||||
UNION
|
||||
SELECT "n"."thread_key", "o"."thread_key"
|
||||
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
|
||||
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
|
||||
ON "o"."user_email" = "n"."user_email" AND "o"."refs" @> ARRAY["n"."message_id"]::text[] AND "o"."subject_base" = "n"."subject_base"
|
||||
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
|
||||
AND "o"."message_id" IS NOT NULL AND "o"."thread_key" <> "n"."thread_key"`,
|
||||
[userEmail, [...ids]],
|
||||
);
|
||||
const renames = mergeThreadKeys(links.map((link) => [link.a, link.b]));
|
||||
if (renames.size === 0) return;
|
||||
await manager.query(
|
||||
`UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message" SET "thread_key" = "rename"."to"
|
||||
FROM unnest($2::text[], $3::text[]) AS "rename"("from", "to")
|
||||
WHERE "message"."user_email" = $1 AND "message"."thread_key" = "rename"."from"`,
|
||||
[userEmail, [...renames.keys()], [...renames.values()]],
|
||||
);
|
||||
}
|
||||
|
||||
/** 메일함 · 키워드만 바꾼다 (한 문장) → 바꾼 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,135 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { MailSearchIndexRepository } from './mail-search-index.repository';
|
||||
import {
|
||||
MailSearchIndexService,
|
||||
toStatusView,
|
||||
} from './mail-search-index.service';
|
||||
import { INDEX_FORMAT } from './mail-search.constants';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
|
||||
describe('MailSearchIndexService (검색 색인의 직원 상태)', () => {
|
||||
let ensureState: jest.Mock;
|
||||
let findState: jest.Mock;
|
||||
let service: MailSearchIndexService;
|
||||
let warnings: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
ensureState = jest.fn().mockResolvedValue({
|
||||
status: 'building',
|
||||
buildDone: 0,
|
||||
buildTotal: null,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
});
|
||||
findState = jest.fn().mockResolvedValue(null);
|
||||
service = new MailSearchIndexService({
|
||||
ensureState,
|
||||
findState,
|
||||
} as unknown as MailSearchIndexRepository);
|
||||
warnings = [];
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation((message) => {
|
||||
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,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
}),
|
||||
).toEqual({ status: 'ready', done: 120, total: 120 });
|
||||
});
|
||||
|
||||
it('toStatusView — 채운 수는 채울 수를 넘지 않는다 (채우는 동안 온 메일 · 위치로 이어 간 겹침) · 시작 전에는 채울 수 null', () => {
|
||||
expect(
|
||||
toStatusView({
|
||||
status: 'building',
|
||||
buildDone: 130,
|
||||
buildTotal: 120,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
}),
|
||||
).toEqual({ status: 'building', done: 120, total: 120 });
|
||||
expect(
|
||||
toStatusView({
|
||||
status: 'building',
|
||||
buildDone: 0,
|
||||
buildTotal: null,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
}),
|
||||
).toEqual({ status: 'building', done: 0, total: null });
|
||||
});
|
||||
|
||||
it('toStatusView — 판이 다른 줄은 되돌리기 전이어도 building · 채운 0 (검색 방식과 같은 판정)', () => {
|
||||
expect(
|
||||
toStatusView({
|
||||
status: 'ready',
|
||||
buildDone: 120,
|
||||
buildTotal: 120,
|
||||
indexFormat: INDEX_FORMAT - 1,
|
||||
}),
|
||||
).toEqual({ status: 'building', done: 0, total: null });
|
||||
});
|
||||
|
||||
describe('색인으로 찾을 수 있나 (isSearchReady)', () => {
|
||||
const ready = {
|
||||
status: 'ready',
|
||||
indexFormat: INDEX_FORMAT,
|
||||
pausedReason: null,
|
||||
};
|
||||
|
||||
it('다 만들었고 · 지금 판이고 · 멈추지 않았을 때만 (소문자 주소로 묻는다)', async () => {
|
||||
findState.mockResolvedValue(ready);
|
||||
await expect(
|
||||
service.isSearchReady(' Ara.Kim@ARASYSTEM.test '),
|
||||
).resolves.toBe(true);
|
||||
expect(findState).toHaveBeenCalledWith(ARA);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['줄 없음', null],
|
||||
['채우는 중', { ...ready, status: 'building' }],
|
||||
['판이 다름(되돌리기 전)', { ...ready, indexFormat: INDEX_FORMAT - 1 }],
|
||||
['대리 접근이 막혀 멈춤', { ...ready, pausedReason: 'locked' }],
|
||||
])('%s 이면 엔진 방식', async (_label, state) => {
|
||||
findState.mockResolvedValue(state);
|
||||
await expect(service.isSearchReady(ARA)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('상태를 읽지 못하면(DB 장애) 엔진 방식 — 경고만, 주소 · DB 문장은 가린다', async () => {
|
||||
findState.mockRejectedValue(
|
||||
Object.assign(new Error(`connection to ${ARA} lost`), {
|
||||
code: '57P01',
|
||||
}),
|
||||
);
|
||||
await expect(service.isSearchReady(ARA)).resolves.toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('Error/57P01');
|
||||
expect(warnings[0]).not.toContain(ARA);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { MailSearchIndexRepository } from './mail-search-index.repository';
|
||||
import { INDEX_FORMAT } from './mail-search.constants';
|
||||
import type { MailSearchStateEntity } from './mail-search-state.entity';
|
||||
import type { SearchIndexStatusView } from './mail-search.types';
|
||||
|
||||
/**
|
||||
* 상태 줄 → 화면의 준비 상태 (GET /api/search-index). 채운 수는 채울 수를 넘지 않게 묶는다 —
|
||||
* 채울 수는 시작할 때의 메일 수라, 채우는 동안 온 메일 · 위치로 이어 간 겹침으로 넣은 수가 더 커질 수 있다.
|
||||
* 판(INDEX_FORMAT)이 다른 줄은 아직 되돌리지 못했어도 building · 채운 0 으로 보인다 — 검색 방식(isSearchReady)과 같은 판정이다
|
||||
*/
|
||||
export function toStatusView(
|
||||
state: Pick<
|
||||
MailSearchStateEntity,
|
||||
'status' | 'buildDone' | 'buildTotal' | 'indexFormat'
|
||||
>,
|
||||
): SearchIndexStatusView {
|
||||
if (state.indexFormat !== INDEX_FORMAT) {
|
||||
return { status: 'building', done: 0, total: null };
|
||||
}
|
||||
const done = Math.max(0, state.buildDone);
|
||||
const total =
|
||||
state.buildTotal === null ? null : Math.max(0, state.buildTotal);
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인으로 찾을 수 있나 (14단계 — 검색 방식 고르기) — 다 만들었고(ready) · 지금 판(INDEX_FORMAT)이고 · 멈추지 않았을 때.
|
||||
* 상태를 읽지 못하면(DB 장애) false — 엔진 방식으로 찾는다(검색을 실패로 만들지 않는다)
|
||||
*/
|
||||
async isSearchReady(user: string): Promise<boolean> {
|
||||
try {
|
||||
const state = await this.repository.findState(normalize(user));
|
||||
return (
|
||||
state !== null &&
|
||||
state.status === 'ready' &&
|
||||
state.indexFormat === INDEX_FORMAT &&
|
||||
state.pausedReason === null
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 검색 색인 상태를 읽지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user)}, reason=${errorKind(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/search-index — 줄이 없던 직원은 이때 만든다(building · 채운 0) */
|
||||
async status(user: string): Promise<SearchIndexStatusView> {
|
||||
return toStatusView(
|
||||
await this.repository.ensureState(normalize(user), new Date()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(user: string): string {
|
||||
return user.trim().toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import {
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
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 색인 둘((lower(user_email), grams, mailbox_ids) · refs)은 마이그레이션이 직접 만든다 — TypeORM 이 GIN 을 적지 못해 `synchronize: false` 로 이름만 알린다
|
||||
* (migration:generate 가 지우지 않게). 찾기 GIN 은 직원 주소 · 묶음 · 메일함을 한 색인에 담는다(btree_gin — 14단계 2차 `SearchIndexUserGrams` →
|
||||
* 실사용 시험 `SearchIndexUserGramsMailboxes`). GIN 의 posting 은 칸마다 따로라 흔한 묶음 · 메일함 id 는 **회사 전체** 줄의 posting 이다
|
||||
* (메일함 id 는 계정마다 같은 꼴(a · b …)이다) — 색인 하나가 그 직원 주소의 posting 과 맞추고 맞은 줄만 읽는다(10명 흉내에서 '계약' 7만여 줄 · 수 ms).
|
||||
* 주소 칸은 lower 식이라 후보 쿼리(search-sql.ts 의 candidatesSql)만 이 색인을 쓴다. **메일함만 담은 GIN 을 다시 만들지 않는다** — 계획기가 그것만으로
|
||||
* 메일함의 줄을 모두 읽고 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 고를 수 있게 된다(2026-11-06 — 1만 통 메일함 안 1.1초 · 남의 큰 메일함과 id 가 같은 빈 메일함 1.6초)
|
||||
*/
|
||||
@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_user_grams_mailboxes', { 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) — 보낸 사람 조건 검색(fold_from)의 바탕 */
|
||||
@Column({ name: 'from_text', type: 'text' })
|
||||
fromText!: string;
|
||||
|
||||
/**
|
||||
* 첫 보낸 사람의 주소 (14단계 3차 리뷰 L3 — 묶은 목록의 참여자) — 엔진이 준 주소 그대로(앞뒤 공백만 지운 NFC). 주소가 없거나
|
||||
* SEARCH_EMAIL_COLUMN_LENGTH 를 넘으면 null — 이름에 든 `<주소>` 꼴을 주소로 읽지 않는다 (참여자는 이 칸만 본다)
|
||||
*/
|
||||
@Column({
|
||||
name: 'from_email',
|
||||
type: 'varchar',
|
||||
length: SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
fromEmail!: string | null;
|
||||
|
||||
/** 첫 보낸 사람의 이름 — 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트, 없으면 null */
|
||||
@Column({
|
||||
name: 'from_name',
|
||||
type: 'varchar',
|
||||
length: INDEX_FROM_NAME_MAX_CHARS,
|
||||
nullable: true,
|
||||
})
|
||||
fromName!: string | null;
|
||||
|
||||
@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단계 3차 — 설계 B4) — 넣을 때는 그 메일 id, 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 대화의 가장 작은 키로 합친다
|
||||
* (mail-search-index.repository.ts 의 linkThreads). 다시 넣어도 이미 있는 줄의 키는 그대로다
|
||||
*/
|
||||
@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,573 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
SEARCH_CANDIDATE_COUNT_LIMIT,
|
||||
SEARCH_CHECK_ROWS_GROWTH,
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
SEARCH_FIRST_CHECK_ROWS,
|
||||
SEARCH_TOTAL_LIMIT,
|
||||
} from './mail-search.constants';
|
||||
import { MailSearchQueryService } from './mail-search-query.service';
|
||||
import type { SearchCriteria, SearchScope } from './mail-search.types';
|
||||
import {
|
||||
decodeIndexCursor,
|
||||
encodeIndexCursor,
|
||||
searchScopeKey,
|
||||
} from './search-cursor';
|
||||
import { planSearch } from './search-plan';
|
||||
|
||||
const USER = 'ara.kim@arasystem.test';
|
||||
const SCOPE: SearchScope = {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
/** 쿼리의 종류 — 쿼리마다 다른 모양으로 가른다 (search-sql.ts) */
|
||||
type QueryKind =
|
||||
| 'candidates'
|
||||
| 'check'
|
||||
| 'keyset'
|
||||
| 'frontier'
|
||||
| 'keysetCount'
|
||||
| 'plainPage'
|
||||
| 'plainCount'
|
||||
| 'highlight';
|
||||
|
||||
function kindOf(sql: string): QueryKind {
|
||||
if (sql.includes('lower("user_email")')) return 'candidates';
|
||||
if (sql.includes('"body_text"')) return 'highlight';
|
||||
if (sql.includes('AS "hits"')) return 'keysetCount';
|
||||
if (sql.includes('AS "scanned"')) return 'frontier';
|
||||
if (sql.includes('AS "matches"')) return 'plainCount';
|
||||
if (sql.includes('AS "scan"')) return 'keyset';
|
||||
if (sql.includes('= ANY(')) return 'check';
|
||||
return 'plainPage';
|
||||
}
|
||||
|
||||
interface Recorded {
|
||||
readonly kind: QueryKind;
|
||||
readonly sql: string;
|
||||
readonly params: unknown[];
|
||||
/** 그 쿼리 앞에 건 계획기 설정 (그 트랜잭션의 것) */
|
||||
readonly settings: string[];
|
||||
/** 몇 번째 트랜잭션의 쿼리인가 (트랜잭션 밖이면 null) · 그 격리 수준 */
|
||||
readonly transaction: number | null;
|
||||
readonly isolation: string | null;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
readonly email_id: string;
|
||||
readonly received_at: Date;
|
||||
}
|
||||
|
||||
/** 받은 시각이 최신부터인 줄들 — id 는 m1, m2 … */
|
||||
function rows(count: number, from = 0): Row[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
email_id: `m${from + index + 1}`,
|
||||
received_at: new Date(
|
||||
Date.UTC(2026, 10, 2, 0, 0, 0) - (from + index) * 60_000,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 확인 쿼리의 id 매개변수 */
|
||||
const idsOf = (query: Recorded) => query.params[1] as string[];
|
||||
|
||||
function fakeDataSource(
|
||||
answers: Partial<Record<QueryKind, (params: unknown[]) => unknown[]>>,
|
||||
) {
|
||||
const recorded: Recorded[] = [];
|
||||
let transactions = 0;
|
||||
const answer = (
|
||||
sql: string,
|
||||
params: unknown[] | undefined,
|
||||
context: {
|
||||
settings: string[];
|
||||
transaction: number | null;
|
||||
isolation: string | null;
|
||||
},
|
||||
) => {
|
||||
const kind = kindOf(sql);
|
||||
recorded.push({ kind, sql, params: params ?? [], ...context });
|
||||
const respond = answers[kind];
|
||||
if (!respond) throw new Error(`준비하지 않은 쿼리: ${kind}`);
|
||||
return Promise.resolve(respond(params ?? []));
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(
|
||||
(
|
||||
isolation: string,
|
||||
run: (manager: {
|
||||
query: (sql: string, params?: unknown[]) => Promise<unknown>;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
transactions += 1;
|
||||
const context = {
|
||||
settings: [] as string[],
|
||||
transaction: transactions,
|
||||
isolation,
|
||||
};
|
||||
return run({
|
||||
query: (sql: string, params?: unknown[]) => {
|
||||
if (sql.startsWith('SET LOCAL')) {
|
||||
context.settings.push(sql);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return answer(sql, params, context);
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
query: jest.fn((sql: string, params?: unknown[]) =>
|
||||
answer(sql, params, { settings: [], transaction: null, isolation: null }),
|
||||
),
|
||||
};
|
||||
return {
|
||||
recorded,
|
||||
service: new MailSearchQueryService(dataSource as unknown as DataSource),
|
||||
};
|
||||
}
|
||||
|
||||
/** 확인 — 준 id 가운데 `matches` 를 만족하는 것만 맞았다고 답한다 */
|
||||
const answerChecks =
|
||||
(matches: (id: string) => boolean) =>
|
||||
(params: unknown[]): unknown[] =>
|
||||
(params[1] as string[]).filter(matches).map((email_id) => ({ email_id }));
|
||||
|
||||
const numberOf = (id: string) => Number(id.slice(1));
|
||||
|
||||
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function search(
|
||||
service: MailSearchQueryService,
|
||||
given: SearchCriteria,
|
||||
page: { limit: number; cursor?: string } = { limit: 20 },
|
||||
user = USER,
|
||||
) {
|
||||
return service.search(user, SCOPE, given, planSearch(given), page);
|
||||
}
|
||||
|
||||
describe('MailSearchQueryService (색인으로 찾기 — 14단계 설계 G)', () => {
|
||||
const bitmapHint = [
|
||||
'SET LOCAL enable_seqscan = off',
|
||||
'SET LOCAL enable_indexscan = off',
|
||||
];
|
||||
const keysetHint = [
|
||||
'SET LOCAL enable_bitmapscan = off',
|
||||
'SET LOCAL enable_seqscan = off',
|
||||
];
|
||||
|
||||
describe('묶음이 있는 찾기 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인', () => {
|
||||
it(`후보는 GIN 의 bitmap 으로만 · 받은 시각 최신부터 확인 상한 + 1(${SEARCH_CHECK_ROWS_LIMIT + 1})줄까지 — 두 글자 낱말뿐이면 후보가 곧 결과(확인하지 않는다)`, async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(25),
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '계약' }));
|
||||
|
||||
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
|
||||
expect(recorded[0].settings).toEqual(bitmapHint);
|
||||
expect(recorded[0].params.at(-1)).toBe(SEARCH_CHECK_ROWS_LIMIT + 1);
|
||||
expect(page.hits.map((hit) => hit.id)).toEqual(
|
||||
rows(20).map((row) => row.email_id),
|
||||
);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBe(25);
|
||||
// 다음 쪽 커서 — 이 쪽의 마지막 줄 다음부터, 같은 검색에서만
|
||||
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
|
||||
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
|
||||
receivedAt: rows(20)[19].received_at,
|
||||
emailId: 'm20',
|
||||
});
|
||||
});
|
||||
|
||||
it(`두 글자 낱말뿐인 찾기의 total — ${SEARCH_TOTAL_LIMIT}을 넘거나 후보가 상한 밖까지 있으면 null`, async () => {
|
||||
for (const count of [
|
||||
SEARCH_TOTAL_LIMIT + 1,
|
||||
SEARCH_CHECK_ROWS_LIMIT + 1,
|
||||
]) {
|
||||
const { service } = fakeDataSource({ candidates: () => rows(count) });
|
||||
const page = await search(service, criteria({ text: '계약 확인' }));
|
||||
expect(page.total).toBeNull();
|
||||
expect(page.hits).toHaveLength(20);
|
||||
expect(page.hasNext).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it(`후보가 ${SEARCH_CANDIDATE_COUNT_LIMIT}개 이하면 한 번에 모두 확인한다 — 첫 쪽의 total 은 맞은 수`, async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(40),
|
||||
check: answerChecks((id) => numberOf(id) % 4 !== 0),
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '계약서' }));
|
||||
|
||||
expect(recorded.map((query) => query.kind)).toEqual([
|
||||
'candidates',
|
||||
'check',
|
||||
]);
|
||||
expect(idsOf(recorded[1])).toHaveLength(40);
|
||||
// 확인은 기본 키로 그 줄만 — 계획기 설정 없이
|
||||
expect(recorded[1].settings).toEqual([]);
|
||||
expect(page.hits).toHaveLength(20);
|
||||
expect(page.hits.map((hit) => hit.id)).not.toContain('m4');
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBe(30);
|
||||
});
|
||||
|
||||
it(`후보가 많으면 받은 차례로 ${SEARCH_FIRST_CHECK_ROWS}줄부터 ${SEARCH_CHECK_ROWS_GROWTH}배씩 확인하다 쪽이 차면 멈춘다 — total 은 모른다(null)`, async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(2_500),
|
||||
check: answerChecks((id) => numberOf(id) % 100 === 0),
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '"계약 갱신"' }));
|
||||
|
||||
const checks = recorded.filter((query) => query.kind === 'check');
|
||||
expect(checks.map((query) => idsOf(query).length)).toEqual([
|
||||
SEARCH_FIRST_CHECK_ROWS,
|
||||
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH,
|
||||
SEARCH_FIRST_CHECK_ROWS * SEARCH_CHECK_ROWS_GROWTH ** 2,
|
||||
]);
|
||||
// 확인은 받은 차례 그대로 이어진다
|
||||
expect(idsOf(checks[1])[0]).toBe('m101');
|
||||
expect(page.hits).toHaveLength(20);
|
||||
expect(page.hits[0].id).toBe('m100');
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
});
|
||||
|
||||
it('후보가 많아도 상한 안이면(상한 밖 후보 없음) 다 확인하고 끝 — 쪽을 못 채워도 hasNext 없음, 맞은 수를 안다', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(2_500),
|
||||
check: answerChecks((id) => ['m7', 'm2400'].includes(id)),
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '"계약 갱신"' }));
|
||||
|
||||
const checked = recorded
|
||||
.filter((query) => query.kind === 'check')
|
||||
.reduce((sum, query) => sum + idsOf(query).length, 0);
|
||||
expect(checked).toBe(2_500);
|
||||
expect(page).toMatchObject({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
expect(page.hits.map((hit) => hit.id)).toEqual(['m7', 'm2400']);
|
||||
});
|
||||
|
||||
it('상한 밖에도 후보가 있고 상한까지 확인해도 쪽을 못 채우면 찾은 것까지와 이어 찾기 커서 — 쪽이 비어 있어도 hasNext', async () => {
|
||||
const lastChecked = rows(1, SEARCH_CHECK_ROWS_LIMIT - 1)[0];
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
|
||||
check: answerChecks((id) => id === 'm42'),
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '확인계약' }));
|
||||
|
||||
const checked = recorded
|
||||
.filter((query) => query.kind === 'check')
|
||||
.flatMap(idsOf);
|
||||
expect(checked).toHaveLength(SEARCH_CHECK_ROWS_LIMIT);
|
||||
expect(checked).not.toContain(`m${SEARCH_CHECK_ROWS_LIMIT + 1}`);
|
||||
expect(page.hits.map((hit) => hit.id)).toEqual(['m42']);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
const key = searchScopeKey(undefined, criteria({ text: '확인계약' }));
|
||||
// 이어 찾을 자리는 마지막으로 **확인한** 후보 — 맞은 줄이 아니다
|
||||
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
|
||||
receivedAt: lastChecked.received_at,
|
||||
emailId: lastChecked.email_id,
|
||||
});
|
||||
|
||||
const empty = fakeDataSource({
|
||||
candidates: () => rows(SEARCH_CHECK_ROWS_LIMIT + 1),
|
||||
check: () => [],
|
||||
});
|
||||
const emptyPage = await search(
|
||||
empty.service,
|
||||
criteria({ text: '확인계약' }),
|
||||
);
|
||||
expect(emptyPage.hits).toEqual([]);
|
||||
expect(emptyPage.hasNext).toBe(true);
|
||||
expect(emptyPage.nextCursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it('후보가 없으면 확인하지 않는다 — 빈 쪽 · total 0', async () => {
|
||||
const { service, recorded } = fakeDataSource({ candidates: () => [] });
|
||||
await expect(
|
||||
search(service, criteria({ text: '뷁뷁뷁' })),
|
||||
).resolves.toEqual({
|
||||
hits: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
expect(recorded.map((query) => query.kind)).toEqual(['candidates']);
|
||||
});
|
||||
|
||||
it('직원 주소는 소문자로 맞춰 묻는다 (표의 주소는 늘 소문자)', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(1),
|
||||
});
|
||||
await search(
|
||||
service,
|
||||
criteria({ text: '계약' }),
|
||||
{ limit: 20 },
|
||||
' Ara.Kim@ArASystem.test ',
|
||||
);
|
||||
expect(recorded[0].params[0]).toBe(USER);
|
||||
});
|
||||
});
|
||||
|
||||
it('묶음이 없으면(한 글자 낱말뿐) 받은 차례로 확인 상한까지 — 쪽이 차면 total 은 창 안에서 센다', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
keyset: (params) => rows(Number(params.at(-1))),
|
||||
keysetCount: () => [{ count: 120 }],
|
||||
frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ text: '묵' }));
|
||||
|
||||
expect(recorded.map((query) => query.kind)).toEqual([
|
||||
'keyset',
|
||||
'keysetCount',
|
||||
'frontier',
|
||||
]);
|
||||
expect(recorded[0].settings).toEqual(keysetHint);
|
||||
expect(recorded[0].sql).not.toContain('"grams"');
|
||||
expect(recorded[0].sql).not.toContain('strpos("fold_body"');
|
||||
expect(recorded[0].params.slice(-3)).toEqual([
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
'묵',
|
||||
21,
|
||||
]);
|
||||
expect(page.hits).toHaveLength(20);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBe(120);
|
||||
});
|
||||
|
||||
it('한 글자 낱말 — 창을 다 훑지 못하고 쪽을 못 채우면 이어 찾기, 다 훑었으면 끝', async () => {
|
||||
const lastScanned = rows(1, 2_999)[0];
|
||||
const unfinished = fakeDataSource({
|
||||
keyset: () => rows(3),
|
||||
frontier: () => [{ ...lastScanned, scanned: SEARCH_CHECK_ROWS_LIMIT }],
|
||||
});
|
||||
const page = await search(unfinished.service, criteria({ text: '뷁' }));
|
||||
expect(page.hits).toHaveLength(3);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
const key = searchScopeKey(undefined, criteria({ text: '뷁' }));
|
||||
expect(decodeIndexCursor(page.nextCursor!, key)).toEqual({
|
||||
receivedAt: lastScanned.received_at,
|
||||
emailId: lastScanned.email_id,
|
||||
});
|
||||
|
||||
const done = fakeDataSource({
|
||||
keyset: () => rows(7),
|
||||
frontier: () => [{ ...rows(1, 1_499)[0], scanned: 1_500 }],
|
||||
});
|
||||
expect(await search(done.service, criteria({ text: '뷁' }))).toMatchObject({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 7,
|
||||
});
|
||||
|
||||
const over = fakeDataSource({
|
||||
keyset: () => rows(21),
|
||||
keysetCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
|
||||
});
|
||||
expect(
|
||||
(await search(over.service, criteria({ text: '팀' }))).total,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('낱말 확인이 없으면(기간 · 첨부만) 받은 차례로 바로 — total 은 상한 + 1 까지 세어 넘으면 null', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
plainPage: (params) => rows(Number(params.at(-1))),
|
||||
plainCount: () => [{ count: SEARCH_TOTAL_LIMIT + 1 }],
|
||||
});
|
||||
|
||||
const page = await search(service, criteria({ hasAttachment: true }), {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(recorded.map((query) => query.kind)).toEqual([
|
||||
'plainPage',
|
||||
'plainCount',
|
||||
]);
|
||||
expect(recorded[0].params.at(-1)).toBe(11);
|
||||
expect(recorded[1].params.at(-1)).toBe(SEARCH_TOTAL_LIMIT + 1);
|
||||
expect(
|
||||
recorded.every((query) => query.settings.join() === keysetHint.join()),
|
||||
).toBe(true);
|
||||
expect(page.hits).toHaveLength(10);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
|
||||
const few = fakeDataSource({
|
||||
plainPage: () => rows(3),
|
||||
plainCount: () => [{ count: 3 }],
|
||||
});
|
||||
expect(
|
||||
await search(few.service, criteria({ hasAttachment: true })),
|
||||
).toMatchObject({ hasNext: false, nextCursor: null, total: 3 });
|
||||
});
|
||||
|
||||
it('다음 쪽은 커서의 자리부터 · total 은 늘 null — 다른 검색의 커서는 묻기 전에 VAL_001', async () => {
|
||||
const given = criteria({ text: '계약' });
|
||||
const position = {
|
||||
receivedAt: new Date('2026-11-01T00:00:00Z'),
|
||||
emailId: 'm40',
|
||||
};
|
||||
const cursor = encodeIndexCursor(
|
||||
position,
|
||||
searchScopeKey(undefined, given),
|
||||
);
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(3, 40),
|
||||
});
|
||||
|
||||
const page = await search(service, given, { limit: 20, cursor });
|
||||
|
||||
expect(recorded[0].params).toEqual(
|
||||
expect.arrayContaining([position.receivedAt, 'm40']),
|
||||
);
|
||||
expect(page.total).toBeNull();
|
||||
expect(page.hasNext).toBe(false);
|
||||
|
||||
const other = fakeDataSource({});
|
||||
await expect(
|
||||
search(other.service, criteria({ text: '견적' }), { limit: 20, cursor }),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(other.recorded).toEqual([]);
|
||||
});
|
||||
|
||||
it('아무것도 맞을 수 없는 계획이면 묻지 않는다 — 빈 쪽 · total 0', async () => {
|
||||
const { service, recorded } = fakeDataSource({});
|
||||
await expect(search(service, criteria({ text: '…' }))).resolves.toEqual({
|
||||
hits: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
await expect(
|
||||
search(service, criteria({ body: '묵' })),
|
||||
).resolves.toMatchObject({ hits: [], total: 0 });
|
||||
expect(recorded).toEqual([]);
|
||||
});
|
||||
|
||||
it('강조에 쓰는 글 — 찾은 쪽의 줄만 한 번에(본문 밖의 접은 칸 넷도 — 미리 보기 고르기), id 가 없으면 묻지 않는다', async () => {
|
||||
const { service, recorded } = fakeDataSource({
|
||||
highlight: () => [
|
||||
{
|
||||
email_id: 'm1',
|
||||
subject: '계약',
|
||||
body_text: '본문',
|
||||
preview: '미리',
|
||||
fold_from: '이도윤 <doyun.lee@arasystem.test>',
|
||||
fold_to: '김아라 <ara.kim@arasystem.test>',
|
||||
fold_subject: '계약',
|
||||
fold_attachment: '계약서.pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.highlightTexts(USER, [])).resolves.toEqual(new Map());
|
||||
expect(recorded).toEqual([]);
|
||||
|
||||
const texts = await service.highlightTexts(USER, ['m1', 'm2']);
|
||||
expect(recorded[0].params).toEqual([USER, ['m1', 'm2']]);
|
||||
expect([...texts.entries()]).toEqual([
|
||||
[
|
||||
'm1',
|
||||
{
|
||||
subject: '계약',
|
||||
bodyText: '본문',
|
||||
preview: '미리',
|
||||
otherFields: [
|
||||
'이도윤 <doyun.lee@arasystem.test>',
|
||||
'김아라 <ara.kim@arasystem.test>',
|
||||
'계약',
|
||||
'계약서.pdf',
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('DB 실패는 그대로 던진다 — 부르는 쪽(목록 서비스)이 첫 쪽이면 엔진 방식으로 찾는다', async () => {
|
||||
const { service } = fakeDataSource({
|
||||
candidates: () => {
|
||||
throw new Error('db down');
|
||||
},
|
||||
});
|
||||
const error = await search(service, criteria({ text: '계약' })).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).not.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('다음 쪽에서는 후보가 적어도 모두 확인하지 않는다 — 쪽이 찰 때까지만 늘려 가며 (total 이 없다 — 리뷰 L3)', async () => {
|
||||
const given = criteria({ text: '계약서' });
|
||||
const cursor = encodeIndexCursor(
|
||||
{ receivedAt: new Date('2026-11-01T00:00:00Z'), emailId: 'm9' },
|
||||
searchScopeKey(undefined, given),
|
||||
);
|
||||
const { service, recorded } = fakeDataSource({
|
||||
candidates: () => rows(1_500, 9),
|
||||
check: answerChecks(() => true),
|
||||
});
|
||||
|
||||
const page = await search(service, given, { limit: 20, cursor });
|
||||
|
||||
const checks = recorded.filter((query) => query.kind === 'check');
|
||||
expect(checks.map((query) => idsOf(query).length)).toEqual([
|
||||
SEARCH_FIRST_CHECK_ROWS,
|
||||
]);
|
||||
expect(page.hits).toHaveLength(20);
|
||||
expect(page.total).toBeNull();
|
||||
});
|
||||
|
||||
it('한 글자 낱말 길의 맞은 줄 · 창의 끝 · 수는 한 스냅숏(REPEATABLE READ 트랜잭션 하나)에서 — 후보 · 낱말 확인 없는 길도 (리뷰 L4)', async () => {
|
||||
const keyset = fakeDataSource({
|
||||
keyset: (params) => rows(Number(params.at(-1))),
|
||||
keysetCount: () => [{ count: 120 }],
|
||||
frontier: () => [{ ...rows(1, 2_399)[0], scanned: 2_400 }],
|
||||
});
|
||||
await search(keyset.service, criteria({ text: '묵' }));
|
||||
expect(new Set(keyset.recorded.map((query) => query.transaction))).toEqual(
|
||||
new Set([1]),
|
||||
);
|
||||
expect(keyset.recorded.map((query) => query.isolation)).toEqual([
|
||||
'REPEATABLE READ',
|
||||
'REPEATABLE READ',
|
||||
'REPEATABLE READ',
|
||||
]);
|
||||
|
||||
const unfinished = fakeDataSource({
|
||||
keyset: () => rows(3),
|
||||
frontier: () => [
|
||||
{ ...rows(1, 2_999)[0], scanned: SEARCH_CHECK_ROWS_LIMIT },
|
||||
],
|
||||
});
|
||||
await search(unfinished.service, criteria({ text: '뷁' }));
|
||||
expect(unfinished.recorded.map((query) => query.transaction)).toEqual([
|
||||
1, 1,
|
||||
]);
|
||||
|
||||
const plain = fakeDataSource({
|
||||
plainPage: () => rows(3),
|
||||
plainCount: () => [{ count: 3 }],
|
||||
});
|
||||
await search(plain.service, criteria({ hasAttachment: true }));
|
||||
expect(plain.recorded.map((query) => query.transaction)).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
SEARCH_CANDIDATE_COUNT_LIMIT,
|
||||
SEARCH_CHECK_ROWS_GROWTH,
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
SEARCH_FIRST_CHECK_ROWS,
|
||||
SEARCH_TOTAL_LIMIT,
|
||||
} from './mail-search.constants';
|
||||
import type { SearchCriteria, SearchScope } from './mail-search.types';
|
||||
import {
|
||||
decodeIndexCursor,
|
||||
encodeIndexCursor,
|
||||
searchScopeKey,
|
||||
} from './search-cursor';
|
||||
import type { SearchPlan } from './search-plan';
|
||||
import {
|
||||
candidatesSql,
|
||||
checkHitsSql,
|
||||
highlightTextsSql,
|
||||
keysetCountSql,
|
||||
keysetFrontierSql,
|
||||
keysetHitsSql,
|
||||
plainCountSql,
|
||||
plainPageSql,
|
||||
type SearchPosition,
|
||||
type SqlQuery,
|
||||
} from './search-sql';
|
||||
|
||||
/** 찾은 메일 하나 — 엔진 id 와 받은 시각 */
|
||||
export interface IndexHit {
|
||||
readonly id: string;
|
||||
readonly receivedAt: Date;
|
||||
}
|
||||
|
||||
/** 색인으로 찾은 한 쪽 */
|
||||
export interface IndexSearchPage {
|
||||
readonly hits: readonly IndexHit[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
/** 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null, 다음 쪽은 늘 null */
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 강조에 쓰는 색인의 글 */
|
||||
export interface IndexHighlightText {
|
||||
readonly subject: string;
|
||||
readonly bodyText: string;
|
||||
readonly preview: string;
|
||||
/** 본문 밖의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (미리 보기 고르기 — search-highlight.ts) */
|
||||
readonly otherFields: readonly string[];
|
||||
}
|
||||
|
||||
/** 쿼리의 줄 */
|
||||
interface HitRow {
|
||||
readonly email_id: string;
|
||||
readonly received_at: Date;
|
||||
}
|
||||
|
||||
/** 한 쪽을 찾은 결과 — 커서는 search() 가 만든다 */
|
||||
interface Found {
|
||||
readonly hits: IndexHit[];
|
||||
readonly hasNext: boolean;
|
||||
/** 이어 찾을 자리 — hasNext 일 때만 */
|
||||
readonly next: SearchPosition | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 계획기를 거드는 설정 — 그 쿼리만 (SET LOCAL — 트랜잭션이 끝나면 돌아온다) */
|
||||
type PlannerHint =
|
||||
/**
|
||||
* 후보 — 찾기 GIN(주소 · 묶음 · 메일함 — 이것 말고 받을 색인이 없다)의 bitmap 으로만 (줄마다 묶음 배열을 풀어 보는 순차 읽기를 고르지 않게)
|
||||
*/
|
||||
| 'bitmap'
|
||||
/** 받은 차례 색인으로 훑는다 — 계획기가 그 직원의 줄을 모두 모은 뒤 놓지 않게 */
|
||||
| 'keyset';
|
||||
|
||||
/** 한 스냅숏 안의 쿼리 하나 */
|
||||
type Ask = <T>(query: SqlQuery) => Promise<T>;
|
||||
|
||||
/** 계획기 설정 — e2e 의 계획 시험(실제 PostgreSQL)도 같은 설정으로 본다 */
|
||||
export const PLANNER_SETTINGS: Readonly<
|
||||
Record<PlannerHint, readonly string[]>
|
||||
> = {
|
||||
bitmap: [
|
||||
'SET LOCAL enable_seqscan = off',
|
||||
'SET LOCAL enable_indexscan = off',
|
||||
],
|
||||
keyset: [
|
||||
'SET LOCAL enable_bitmapscan = off',
|
||||
'SET LOCAL enable_seqscan = off',
|
||||
],
|
||||
};
|
||||
|
||||
function toHit(row: HitRow): IndexHit {
|
||||
return {
|
||||
id: row.email_id,
|
||||
receivedAt:
|
||||
row.received_at instanceof Date
|
||||
? row.received_at
|
||||
: new Date(row.received_at),
|
||||
};
|
||||
}
|
||||
|
||||
function positionOf(hit: IndexHit): SearchPosition {
|
||||
return { receivedAt: hit.receivedAt, emailId: hit.id };
|
||||
}
|
||||
|
||||
/** 첫 쪽의 수 — SEARCH_TOTAL_LIMIT 를 넘으면 null */
|
||||
function capTotal(count: number): number | null {
|
||||
return count > SEARCH_TOTAL_LIMIT ? null : count;
|
||||
}
|
||||
|
||||
/** 맞은 줄(받은 시각 최신부터)에서 한 쪽 — 넘치면 다음 쪽이 있고, 이어 찾을 자리는 이 쪽의 마지막 줄 */
|
||||
function pageOf(
|
||||
hits: readonly IndexHit[],
|
||||
limit: number,
|
||||
total: number | null,
|
||||
): Found {
|
||||
const page = hits.slice(0, limit);
|
||||
const hasNext = hits.length > limit;
|
||||
return {
|
||||
hits: page,
|
||||
hasNext,
|
||||
next: hasNext ? positionOf(page[page.length - 1]) : null,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
|
||||
function normalize(user: string): string {
|
||||
return user.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인으로 찾기 (14단계 설계 C1 · G — 2026-11-05 다시 잼) — 계획(search-plan.ts)을 쿼리(search-sql.ts)로 돌린다. 엔진을 모른다.
|
||||
*
|
||||
* - 낱말 확인이 없으면(기간 · 첨부 · 메일함만): 받은 차례로 바로
|
||||
* - 묶음이 있으면: 후보를 주소 + 묶음 + 메일함(GIN 하나)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지 읽는다(줄의 글을 풀지 않는다). 그다음
|
||||
* - 두 글자 낱말뿐(exactByGrams)이면 후보가 곧 결과
|
||||
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인 — 첫 쪽의 total 을 안다
|
||||
* - 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터 SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다.
|
||||
* SEARCH_CHECK_ROWS_LIMIT 줄을 확인하고도 쪽을 채우지 못하면 찾은 것까지와 마지막으로 확인한 줄의 커서를 준다(hasNext — 쪽이 비어 있을 수도 있다)
|
||||
* - 묶음이 없으면(한 글자 낱말뿐 — 본문은 훑지 않아 확인이 값싸다): 받은 차례로 SEARCH_CHECK_ROWS_LIMIT 줄까지 확인한다(이어 찾기는 위와 같다)
|
||||
* - total 은 첫 쪽에서만 SEARCH_TOTAL_LIMIT 까지 — 넘거나 이 요청 안에서 다 세지 못하면 null
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSearchQueryService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async search(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
criteria: SearchCriteria,
|
||||
plan: SearchPlan,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<IndexSearchPage> {
|
||||
const key = normalize(user);
|
||||
const scopeKey = searchScopeKey(scope.mailboxId ?? undefined, criteria);
|
||||
// 커서는 찾기 전에 본다 — 다른 검색의 커서면 VAL_001
|
||||
const after = page.cursor ? decodeIndexCursor(page.cursor, scopeKey) : null;
|
||||
if (plan.matchesNothing) {
|
||||
return { hits: [], hasNext: false, nextCursor: null, total: 0 };
|
||||
}
|
||||
const firstPage = after === null;
|
||||
const found =
|
||||
plan.checks.length === 0
|
||||
? await this.plain(key, scope, plan, after, page.limit, firstPage)
|
||||
: plan.grams.length === 0
|
||||
? await this.keyset(key, scope, plan, after, page.limit, firstPage)
|
||||
: await this.byGrams(key, scope, plan, after, page.limit, firstPage);
|
||||
return {
|
||||
hits: found.hits,
|
||||
hasNext: found.hasNext,
|
||||
nextCursor:
|
||||
found.hasNext && found.next
|
||||
? encodeIndexCursor(found.next, scopeKey)
|
||||
: null,
|
||||
total: firstPage ? found.total : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 강조에 쓰는 글 — 찾은 쪽의 줄만 (없는 줄은 빠진다) */
|
||||
async highlightTexts(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, IndexHighlightText>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
const query = highlightTextsSql(normalize(user), ids);
|
||||
const rows = await this.dataSource.query<
|
||||
{
|
||||
email_id: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
preview: string;
|
||||
fold_from: string;
|
||||
fold_to: string;
|
||||
fold_subject: string;
|
||||
fold_attachment: string;
|
||||
}[]
|
||||
>(query.sql, query.params);
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.email_id,
|
||||
{
|
||||
subject: row.subject,
|
||||
bodyText: row.body_text,
|
||||
preview: row.preview,
|
||||
otherFields: [
|
||||
row.fold_from,
|
||||
row.fold_to,
|
||||
row.fold_subject,
|
||||
row.fold_attachment,
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 낱말 확인 없이 — 받은 차례로 (첫 쪽은 SEARCH_TOTAL_LIMIT + 1 까지 센다 — 쪽과 수는 같은 스냅숏에서) */
|
||||
private plain(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
limit: number,
|
||||
firstPage: boolean,
|
||||
): Promise<Found> {
|
||||
return this.snapshot('keyset', async (ask) => {
|
||||
const rows = await ask<HitRow[]>(
|
||||
plainPageSql(user, scope, plan, after, limit + 1),
|
||||
);
|
||||
let total: number | null = null;
|
||||
if (firstPage) {
|
||||
const counted = await ask<{ count: number }[]>(
|
||||
plainCountSql(user, scope, plan, SEARCH_TOTAL_LIMIT + 1),
|
||||
);
|
||||
total = capTotal(Number(counted[0]?.count ?? 0));
|
||||
}
|
||||
return pageOf(rows.map(toHit), limit, total);
|
||||
});
|
||||
}
|
||||
|
||||
/** 묶음이 있다 — 후보(GIN)를 받은 시각으로 읽고 필요한 만큼만 확인한다 */
|
||||
private async byGrams(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
limit: number,
|
||||
firstPage: boolean,
|
||||
): Promise<Found> {
|
||||
const candidates = (
|
||||
await this.snapshot('bitmap', (ask) =>
|
||||
ask<HitRow[]>(
|
||||
candidatesSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT + 1),
|
||||
),
|
||||
)
|
||||
).map(toHit);
|
||||
// 확인 상한 밖에도 후보가 있다 — 이 요청은 상한까지만 본다
|
||||
const more = candidates.length > SEARCH_CHECK_ROWS_LIMIT;
|
||||
const window = more
|
||||
? candidates.slice(0, SEARCH_CHECK_ROWS_LIMIT)
|
||||
: candidates;
|
||||
|
||||
// 두 글자 낱말뿐 — 후보가 곧 결과다
|
||||
if (plan.exactByGrams) {
|
||||
return pageOf(candidates, limit, more ? null : capTotal(window.length));
|
||||
}
|
||||
|
||||
// 첫 쪽이고 후보가 적다 — 모두 확인해 total 을 안다 (다음 쪽은 total 이 없으니 쪽이 찰 때까지만 확인한다)
|
||||
if (firstPage && !more && window.length <= SEARCH_CANDIDATE_COUNT_LIMIT) {
|
||||
const matched = await this.check(user, plan, window);
|
||||
const hits = window.filter((hit) => matched.has(hit.id));
|
||||
return pageOf(hits, limit, capTotal(hits.length));
|
||||
}
|
||||
|
||||
// 후보가 많다 — 받은 차례로 조금씩 확인하다 쪽이 차면 멈춘다
|
||||
const hits: IndexHit[] = [];
|
||||
let checked = 0;
|
||||
let size = Math.max(SEARCH_FIRST_CHECK_ROWS, (limit + 1) * 2);
|
||||
while (hits.length <= limit && checked < window.length) {
|
||||
const slice = window.slice(checked, checked + size);
|
||||
const matched = await this.check(user, plan, slice);
|
||||
for (const hit of slice) if (matched.has(hit.id)) hits.push(hit);
|
||||
checked += slice.length;
|
||||
size *= SEARCH_CHECK_ROWS_GROWTH;
|
||||
}
|
||||
if (hits.length > limit) return pageOf(hits, limit, null);
|
||||
// 후보를 다 확인했다 — 끝이고 맞은 수를 안다
|
||||
if (!more) {
|
||||
return { hits, hasNext: false, next: null, total: capTotal(hits.length) };
|
||||
}
|
||||
// 확인 상한에 닿았다 — 마지막으로 확인한 후보 다음부터 이어 찾는다
|
||||
return {
|
||||
hits,
|
||||
hasNext: true,
|
||||
next: positionOf(window[window.length - 1]),
|
||||
total: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 후보 가운데 낱말이 모두 맞은 줄의 id */
|
||||
private async check(
|
||||
user: string,
|
||||
plan: SearchPlan,
|
||||
candidates: readonly IndexHit[],
|
||||
): Promise<Set<string>> {
|
||||
if (candidates.length === 0) return new Set();
|
||||
const query = checkHitsSql(
|
||||
user,
|
||||
plan,
|
||||
candidates.map((hit) => hit.id),
|
||||
);
|
||||
const rows = await this.dataSource.query<{ email_id: string }[]>(
|
||||
query.sql,
|
||||
query.params,
|
||||
);
|
||||
return new Set(rows.map((row) => row.email_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶음이 없다(한 글자 낱말뿐) — 받은 차례로 확인 상한까지 (확인하는 칸이 작아 값싸다).
|
||||
* 맞은 줄 · 창의 끝 · 첫 쪽의 수는 **같은 스냅숏**에서 읽는다 — 그사이 온 메일로 창이 밀리면 이어 찾을 자리가 어긋나 같은 메일이 다음 쪽에 또 나왔다
|
||||
*/
|
||||
private keyset(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
limit: number,
|
||||
firstPage: boolean,
|
||||
): Promise<Found> {
|
||||
return this.snapshot('keyset', async (ask) => {
|
||||
const rows = await ask<HitRow[]>(
|
||||
keysetHitsSql(
|
||||
user,
|
||||
scope,
|
||||
plan,
|
||||
after,
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
limit + 1,
|
||||
),
|
||||
);
|
||||
if (rows.length > limit) {
|
||||
return pageOf(
|
||||
rows.map(toHit),
|
||||
limit,
|
||||
firstPage ? await this.keysetTotal(ask, user, scope, plan) : null,
|
||||
);
|
||||
}
|
||||
// 쪽을 채우지 못했다 — 확인한 창을 다 훑었나(그러면 끝), 아니면 그 창의 끝부터 이어 찾는다
|
||||
const hits = rows.map(toHit);
|
||||
const frontier = await this.frontier(ask, user, scope, plan, after);
|
||||
if (frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT) {
|
||||
return {
|
||||
hits,
|
||||
hasNext: false,
|
||||
next: null,
|
||||
total: capTotal(hits.length),
|
||||
};
|
||||
}
|
||||
return { hits, hasNext: true, next: frontier.last, total: null };
|
||||
});
|
||||
}
|
||||
|
||||
/** 받은 차례로 훑는 창의 끝 — 그 창의 마지막 줄 · 줄 수 (창이 비었으면 null) */
|
||||
private async frontier(
|
||||
ask: Ask,
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
): Promise<{ last: SearchPosition; scanned: number } | null> {
|
||||
const rows = await ask<(HitRow & { scanned: number })[]>(
|
||||
keysetFrontierSql(user, scope, plan, after, SEARCH_CHECK_ROWS_LIMIT),
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
last: positionOf(toHit(row)),
|
||||
scanned: Number(row.scanned),
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 차례 길의 첫 쪽 total — 창 안에서 SEARCH_TOTAL_LIMIT + 1 까지 센다. 넘거나 창을 다 훑지 못했으면 null */
|
||||
private async keysetTotal(
|
||||
ask: Ask,
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
): Promise<number | null> {
|
||||
const counted = await ask<{ count: number }[]>(
|
||||
keysetCountSql(
|
||||
user,
|
||||
scope,
|
||||
plan,
|
||||
SEARCH_CHECK_ROWS_LIMIT,
|
||||
SEARCH_TOTAL_LIMIT + 1,
|
||||
),
|
||||
);
|
||||
const count = Number(counted[0]?.count ?? 0);
|
||||
if (count > SEARCH_TOTAL_LIMIT) return null;
|
||||
const frontier = await this.frontier(ask, user, scope, plan, null);
|
||||
return frontier === null || frontier.scanned < SEARCH_CHECK_ROWS_LIMIT
|
||||
? count
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 스냅숏(REPEATABLE READ — 읽기만 한다)에서 계획기 설정을 건 뒤 쿼리들을 묻는다 (SET LOCAL — 트랜잭션이 끝나면 돌아온다).
|
||||
* 스냅숏은 설정 다음의 첫 쿼리에서 잡힌다
|
||||
*/
|
||||
private snapshot<T>(
|
||||
hint: PlannerHint,
|
||||
work: (ask: Ask) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||
for (const setting of PLANNER_SETTINGS[hint]) {
|
||||
await manager.query(setting);
|
||||
}
|
||||
return work(<R>(query: SqlQuery) =>
|
||||
manager.query<R>(query.sql, query.params),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,90 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 이 줄의 색인을 만든 판 (mail-search.constants.ts 의 INDEX_FORMAT) — 다르면 building 으로 돌려 다시 채운다(그동안 검색은 엔진 방식).
|
||||
* 판을 적기 전에 만든 줄은 1
|
||||
*/
|
||||
@Column({ name: 'index_format', type: 'smallint', default: 1 })
|
||||
indexFormat!: number;
|
||||
|
||||
/** 이어진 실패 수 — 성공하면 0. 많을수록 차례에서 뒤로 미룬다 */
|
||||
@Column({ name: 'failure_count', type: 'integer', default: 0 })
|
||||
failureCount!: number;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
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,
|
||||
INDEX_FORMAT,
|
||||
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[] = [];
|
||||
/** 판 되돌리기 — 부른 수 · 앞으로 실패할 수(DB 장애 흉내) */
|
||||
resetCalls = 0;
|
||||
failResets = 0;
|
||||
|
||||
/** 실제 SQL(mail-search-index.repository.ts)과 같은 판정 — 판이 다르고 주소가 그 도메인으로 끝나는 줄만 */
|
||||
resetOutdatedFormats(suffix: string, format: number, now: Date) {
|
||||
this.resetCalls += 1;
|
||||
if (this.failResets > 0) {
|
||||
this.failResets -= 1;
|
||||
return Promise.reject(new Error('db down'));
|
||||
}
|
||||
let count = 0;
|
||||
for (const state of this.states.values()) {
|
||||
if (state.indexFormat === format || !state.userEmail.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
Object.assign(state, {
|
||||
status: 'building',
|
||||
emailState: null,
|
||||
buildAnchor: null,
|
||||
buildDone: 0,
|
||||
buildTotal: null,
|
||||
buildStartedAt: null,
|
||||
readyAt: null,
|
||||
failureCount: 0,
|
||||
indexFormat: format,
|
||||
updatedAt: now,
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
addState(
|
||||
overrides: Partial<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,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
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);
|
||||
if (change.clearIndex) this.rows.clear();
|
||||
for (const id of change.deletes ?? []) this.rows.delete(id);
|
||||
for (const row of change.upserts ?? []) this.rows.set(row.emailId, row);
|
||||
const updatedIds = new Set<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('판을 되돌리면 채우기의 첫 묶음이 옛 판 줄을 모두 지운다 — 위치로 건너뛴 메일도 마지막 대조가 새로 넣어 옛 판 줄이 남지 않는다 (리뷰 M1)', async () => {
|
||||
engine.seed(120);
|
||||
repository.addState();
|
||||
expect(await build.build(ARA, clock + LONG)).toBe('ready');
|
||||
// 옛 판(다른 접기 규칙)으로 만든 줄처럼 꾸민다
|
||||
for (const [id, row] of repository.rows) {
|
||||
repository.rows.set(id, { ...row, foldBody: 'old-format' });
|
||||
}
|
||||
repository.states.get(ARA)!.indexFormat = INDEX_FORMAT - 1;
|
||||
|
||||
await build.onApplicationBootstrap();
|
||||
expect(repository.states.get(ARA)).toMatchObject({
|
||||
status: 'building',
|
||||
buildStartedAt: null,
|
||||
emailState: null,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
});
|
||||
|
||||
// 시작 + 첫 묶음(old119 ~ old70) → 그 anchor 를 지워 위치로 건너뛰게(old69 를 건너뛴다) → 끝까지
|
||||
await build.build(ARA, clock + STEP_MIN_REMAINING_MS + 300);
|
||||
expect(repository.writes.some((write) => write.clearIndex)).toBe(true);
|
||||
expect(repository.states.get(ARA)?.buildAnchor).toBe('old70');
|
||||
engine.remove('old70');
|
||||
expect(await build.build(ARA, clock + LONG)).toBe('ready');
|
||||
|
||||
const old = [...repository.rows.values()].filter(
|
||||
(row) => row.foldBody === 'old-format',
|
||||
);
|
||||
expect(old).toEqual([]);
|
||||
expect([...repository.rows.keys()].sort()).toEqual(
|
||||
engine.newestFirst().sort(),
|
||||
);
|
||||
// 처음부터 시작할 때만 지운다 — 이어 하는 묶음은 지우지 않는다
|
||||
expect(
|
||||
repository.writes.filter((write) => write.clearIndex),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('서버가 뜰 때 판 되돌리기가 실패하면 1분 차례가 다시 해 본다(낮에도) — 한 번 되면 다시 하지 않고, 사내 도메인 줄만 (리뷰 L5)', async () => {
|
||||
const state = repository.addState({
|
||||
status: 'ready',
|
||||
emailState: 's',
|
||||
buildStartedAt: new Date(0),
|
||||
indexFormat: INDEX_FORMAT - 1,
|
||||
});
|
||||
const other = repository.addState({
|
||||
userEmail: 'search.index@e2e.arasystem.test',
|
||||
status: 'ready',
|
||||
indexFormat: INDEX_FORMAT - 1,
|
||||
});
|
||||
repository.failResets = 1;
|
||||
|
||||
await build.onApplicationBootstrap();
|
||||
expect(state.status).toBe('ready');
|
||||
expect(logs.some((line) => line.includes('되돌리지 못했습니다'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
clock = Date.parse('2026-11-05T12:00:00+09:00');
|
||||
expect(await build.tick()).toBeNull();
|
||||
expect(state).toMatchObject({
|
||||
status: 'building',
|
||||
buildStartedAt: null,
|
||||
indexFormat: INDEX_FORMAT,
|
||||
});
|
||||
// 다른 도메인 줄은 그쪽 서버의 몫
|
||||
expect(other).toMatchObject({
|
||||
status: 'ready',
|
||||
indexFormat: INDEX_FORMAT - 1,
|
||||
});
|
||||
expect(repository.resetCalls).toBe(2);
|
||||
await build.tick();
|
||||
expect(repository.resetCalls).toBe(2);
|
||||
// 낮이라 채우지는 않는다
|
||||
expect(engine.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('채우는 중 엔진이 변화를 셀 수 없으면 대조하되 빠진 메일은 넣지 않는다(채우기가 넣는다) — 지운 것 · 옮긴 것은 맞춘다', async () => {
|
||||
engine.seed(BUILD_BATCH_SIZE * 2);
|
||||
repository.addState();
|
||||
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,483 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** 색인하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null) — 판이 바뀐 줄 되돌리기가 쓴다 */
|
||||
get indexedAddressSuffix(): string | null {
|
||||
return this.addressSuffix;
|
||||
}
|
||||
|
||||
/** 색인하는 주소인가 — 사내 도메인(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,178 @@
|
||||
/**
|
||||
* 메일 검색 색인 (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;
|
||||
/**
|
||||
* 첫 보낸 사람의 이름을 담는 길이 (코드 포인트 — 열 `from_name` 의 varchar 길이와 같다). 묶은 목록의 참여자 이름이다 (14단계 3차 리뷰 L3).
|
||||
* 주소(`from_email`)는 SEARCH_EMAIL_COLUMN_LENGTH 까지 — 넘으면 담지 않는다
|
||||
*/
|
||||
export const INDEX_FROM_NAME_MAX_CHARS = 100;
|
||||
/** 첨부 이름을 담는 수 · 이름 하나의 길이 (코드 포인트) */
|
||||
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';
|
||||
|
||||
/**
|
||||
* 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format).
|
||||
* 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다.
|
||||
* 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게)
|
||||
* · 3: 대화 키를 잇는다(14단계 3차 — 답장 관계 + 말머리를 뗀 제목. 판 2 까지의 줄은 키가 메일마다 따로다)
|
||||
* · 4: 첫 보낸 사람의 주소 · 이름 칸(from_email · from_name — 3차 리뷰 L3, 마이그레이션 AddMailSearchSender. 판 3 까지의 줄은 칸이 비었다)
|
||||
*/
|
||||
export const INDEX_FORMAT = 4;
|
||||
|
||||
/**
|
||||
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼 · 2026-11-06 메일함 범위 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이
|
||||
* 비싸 그 수를 묶는다:
|
||||
* - 후보: 주소 + 묶음 + 메일함(GIN 하나 — 마이그레이션 SearchIndexUserGramsMailboxes 의 lower 식)만으로 받은 시각 최신부터 SEARCH_CHECK_ROWS_LIMIT + 1 줄까지
|
||||
* 읽는다(줄의 글을 풀지 않는다 — 1만 통 직원의 흔한 낱말이 전체 · 큰 메일함 10~30ms, 작은 메일함 1~7ms. 맞은 줄이 표에 흩어져 있으면 그 줄 수만큼 더 든다)
|
||||
* - 두 글자 낱말뿐(검색어 — 다섯 칸)이면 묶음 하나가 곧 그 낱말이라 후보가 곧 결과다 — 확인하지 않는다
|
||||
* - 후보가 SEARCH_CANDIDATE_COUNT_LIMIT 이하면 모두 확인한다(첫 쪽의 total 을 안다), 넘으면 받은 차례로 SEARCH_FIRST_CHECK_ROWS 줄부터
|
||||
* SEARCH_CHECK_ROWS_GROWTH 배씩 확인하다 쪽이 차면 멈춘다 — 한 요청이 확인하는 줄은 SEARCH_CHECK_ROWS_LIMIT 까지(≈ 0.5~0.8초 — 캐시가 찬 때.
|
||||
* 2026-11-06 1만 통 직원의 드문 구절로 3,000줄 0.51~0.58초).
|
||||
* 넘으면 찾은 것까지와 이어 찾기 커서를 준다(쪽이 limit 보다 적어도 hasNext)
|
||||
* - total 은 첫 쪽에서 SEARCH_TOTAL_LIMIT 까지만 센다 — 넘거나 이 요청 안에서 다 세지 못하면 null
|
||||
*/
|
||||
export const SEARCH_CANDIDATE_COUNT_LIMIT = 2_000;
|
||||
export const SEARCH_CHECK_ROWS_LIMIT = 3_000;
|
||||
export const SEARCH_FIRST_CHECK_ROWS = 100;
|
||||
export const SEARCH_CHECK_ROWS_GROWTH = 4;
|
||||
export const SEARCH_TOTAL_LIMIT = 1_000;
|
||||
|
||||
/**
|
||||
* 한 글자 낱말(글자 두 개 묶음이 없는 낱말)만으로 찾을 때 본문은 훑지 않는다 — 제목 · 사람(보낸 사람 · 받는 사람 · 참조) · 첨부 이름에서만 찾는다.
|
||||
* 두 글자 넘는 낱말과 함께면 한 글자 낱말은 확인에만 쓴다(본문 포함). false 로 두면 한 글자 낱말만이어도 본문까지 훑는다(확인 상한 안에서)
|
||||
*/
|
||||
export const SINGLE_CHAR_TERMS_SKIP_BODY = true;
|
||||
|
||||
/** 목록의 미리 보기 둘레 — 맞은 말 앞 글자 수 · 모두 글자 수(남짓 — 말 경계에서 자른다) · 말 경계를 찾는 여유 (UTF-16 — 서로게이트 쌍은 가르지 않는다) */
|
||||
export const SNIPPET_LEAD_CHARS = 20;
|
||||
export const SNIPPET_TOTAL_CHARS = 80;
|
||||
export const SNIPPET_BOUNDARY_SLACK = 10;
|
||||
|
||||
/**
|
||||
* 대화 (14단계 3차 — 설계 C3 · C4). 같은 대화 칸의 한 쪽(기본이자 최대 — 화면이 한 번에 보인다) · 묶은 목록 줄의 참여자 수 상한(보낸 사람 — 받은 차례로 앞에서부터)
|
||||
* · 묶은 목록 줄의 messageIds 상한(그 메일함 안 — 최신부터, 3차 리뷰 L4. 넘는 오래된 메일은 줄의 여러 통 처리에 들지 않는다)
|
||||
*/
|
||||
export const CONVERSATION_PAGE_LIMIT = 100;
|
||||
export const CONVERSATION_PARTICIPANTS_MAX = 10;
|
||||
export const CONVERSATION_MESSAGE_IDS_MAX = 500;
|
||||
|
||||
/**
|
||||
* 엔진 방식 대화(색인 전 · 첫 쪽 DB 장애의 대신 — modules/mail/jmap/jmap-conversations.ts)의 상한 (3차 리뷰 H1) — GitHub · Jira 알림처럼
|
||||
* 수천 통인 스레드에서도 한 요청이 엔진을 크게 쓰지 않게.
|
||||
* - 거르기(휴지통 · 스팸메일함에만 든 메일 · 내부 초안)는 값싼 속성만 읽고, 요약 속성은 내보낼 메일(같은 대화 — 한 쪽 · 묶은 목록 — 줄마다 마지막 메일)만 읽는다
|
||||
* - 같은 대화: 한 요청이 훑는 메일 CONVERSATION_ENGINE_SCAN_MAX 통까지 — 스레드가 그 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 필요한 만큼만(total null)
|
||||
* - 묶은 목록: 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통 · 한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통까지(작은 스레드부터 다 주고 남는 몫을 나눈다)
|
||||
* - 한 요청의 시간 CONVERSATION_ENGINE_BUDGET_MS (메일함 처리의 BulkRun 과 같은 결) — 훑는 호출은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS"
|
||||
* 중 짧은 쪽(마지막 요약 읽기 몫을 늘 남긴다 — 3차 재확인 N1), 마지막 읽기만 남은 시간을 다 쓴다. 남은 시간이 그 몫보다 적으면 더 훑지 않고,
|
||||
* 훑던 호출이 시간 때문에 끊기면 읽은 데까지로 답한다(같은 대화: 다 세야 하는 작은 스레드 · 아무것도 못 읽음은 503 MAIL_001 / 묶은 목록: 어림 줄)
|
||||
*/
|
||||
export const CONVERSATION_ENGINE_SCAN_MAX = 1_000;
|
||||
export const CONVERSATION_ENGINE_THREAD_SCAN_MAX = 500;
|
||||
export const CONVERSATION_ENGINE_LIST_SCAN_MAX = 2_000;
|
||||
export const CONVERSATION_ENGINE_BUDGET_MS = 15_000;
|
||||
export const CONVERSATION_ENGINE_RESERVE_MS = 3_000;
|
||||
|
||||
/** 색인의 키워드(엔진이 준 그대로 — JMAP 표준) 가운데 묶은 목록이 보는 것 — 읽음 · 중요 표시 */
|
||||
export const SEEN_KEYWORD = '$seen';
|
||||
export const FLAGGED_KEYWORD = '$flagged';
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { MailConversationQueryService } from './mail-conversation-query.service';
|
||||
import { MAIL_INDEX_SOURCE } from './mail-index-source';
|
||||
import { MailSearchBuildService } from './mail-search-build.service';
|
||||
import { MailSearchIndexRepository } from './mail-search-index.repository';
|
||||
import { MailSearchIndexService } from './mail-search-index.service';
|
||||
import { MailSearchQueryService } from './mail-search-query.service';
|
||||
import { MailSearchMessageEntity } from './mail-search-message.entity';
|
||||
import { MailSearchRuns } from './mail-search-runs';
|
||||
import { MailSearchStateEntity } from './mail-search-state.entity';
|
||||
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,
|
||||
MailSearchQueryService,
|
||||
MailConversationQueryService,
|
||||
],
|
||||
exports: [
|
||||
MailSearchIndexService,
|
||||
MailSearchSyncService,
|
||||
MailSearchQueryService,
|
||||
MailConversationQueryService,
|
||||
],
|
||||
})
|
||||
export class MailSearchModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 메일 검색 색인의 도메인 타입 (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 조건 (GET /api/messages — 14단계 설계 C1) — DTO 가 검사한 값. 글 칸은 앞뒤 공백을 뺀 값이고 빈 글은 없는 것이다.
|
||||
* after · before 는 ISO 8601 시각 — 받은 시각이 after 이상 · before 미만
|
||||
*/
|
||||
export interface SearchCriteria {
|
||||
/** 검색어(q) — 보낸 사람 · 받는 사람 · 참조 · 제목 · 첨부 이름 · 본문 */
|
||||
readonly text?: string;
|
||||
readonly from?: string;
|
||||
/** 받는 사람 — 참조도 */
|
||||
readonly to?: string;
|
||||
readonly subject?: string;
|
||||
readonly body?: string;
|
||||
readonly after?: string;
|
||||
readonly before?: string;
|
||||
/** true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정) */
|
||||
readonly hasAttachment?: boolean;
|
||||
}
|
||||
|
||||
/** 검색어 밖의 조건 — 엔진 방식 검색이 JMAP 필터로 쓴다 */
|
||||
export type SearchConditions = Omit<SearchCriteria, 'text'>;
|
||||
|
||||
/**
|
||||
* 찾는 범위 (엔진이 알려 준다 — 메일 포트의 searchScope): 메일함 하나, 또는 빼는 메일함(휴지통 · 스팸메일함)에만 있지 않은 메일.
|
||||
* 목록에서 빼는 키워드(발송 중인 내부 초안)가 있는 메일은 찾지 않는다
|
||||
*/
|
||||
export interface SearchScope {
|
||||
readonly mailboxId: string | null;
|
||||
readonly excludedMailboxIds: readonly string[];
|
||||
readonly excludedKeywords: readonly string[];
|
||||
}
|
||||
|
||||
/** 맞은 곳 — [시작, 끝) UTF-16 */
|
||||
export type SearchRange = readonly [start: number, end: number];
|
||||
|
||||
/** 목록 항목의 강조 (색인으로 찾았을 때만 — 엔진 방식이면 null) */
|
||||
export interface MessageListHighlight {
|
||||
readonly subject: SearchRange[];
|
||||
/** 본문에서 맞았으면 맞은 말 둘레, 아니면 엔진의 미리 보기 */
|
||||
readonly preview: { readonly text: string; readonly ranges: SearchRange[] };
|
||||
}
|
||||
|
||||
/** 읽기 화면의 강조 — 첨부는 맞은 것만(첨부 id → 맞은 곳) */
|
||||
export interface MessageDetailHighlight {
|
||||
readonly subject: SearchRange[];
|
||||
readonly attachments: Record<string, SearchRange[]>;
|
||||
readonly textBody: SearchRange[];
|
||||
}
|
||||
|
||||
/** 대화의 참여자 — 보낸 사람 (메일 모듈의 MailAddress 와 같은 모양) */
|
||||
export interface ConversationParticipant {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목 — 14단계 B4) · 엔진의 스레드(색인을 다 만들기 전) */
|
||||
export type ConversationBasis = 'index' | 'engine';
|
||||
@@ -0,0 +1,137 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
|
||||
import type { SearchCriteria } from './mail-search.types';
|
||||
import {
|
||||
cursorMethod,
|
||||
decodeIndexCursor,
|
||||
encodeIndexCursor,
|
||||
searchScopeKey,
|
||||
} from './search-cursor';
|
||||
|
||||
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function codeOf(run: () => unknown): string | null {
|
||||
try {
|
||||
run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('색인 검색의 커서 (search-cursor — 14단계 설계 C1)', () => {
|
||||
const position = {
|
||||
receivedAt: new Date('2026-11-02T01:23:45.678Z'),
|
||||
emailId: 'm9x',
|
||||
};
|
||||
const key = searchScopeKey(undefined, criteria({ text: '계약' }));
|
||||
|
||||
it('자리(받은 시각 ms · id)를 그대로 되돌린다 — URL 에 그대로 넣을 수 있는 글자만', () => {
|
||||
const cursor = encodeIndexCursor(position, key);
|
||||
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(decodeIndexCursor(cursor, key)).toEqual(position);
|
||||
});
|
||||
|
||||
it('방식을 커서로 안다 — 색인 커서는 index, 엔진 커서(1판) · 알 수 없는 값은 engine(엔진 쪽이 검사한다)', () => {
|
||||
expect(cursorMethod(encodeIndexCursor(position, key))).toBe('index');
|
||||
expect(
|
||||
cursorMethod(
|
||||
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
|
||||
),
|
||||
).toBe('engine');
|
||||
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600), 'bnVsbA']) {
|
||||
expect(cursorMethod(cursor)).toBe('engine');
|
||||
}
|
||||
});
|
||||
|
||||
it('범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 하나라도 다르면 다른 검색의 커서라 VAL_001', () => {
|
||||
const base = criteria({
|
||||
text: '계약',
|
||||
from: '이도윤',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
});
|
||||
const cursor = encodeIndexCursor(position, searchScopeKey('a', base));
|
||||
|
||||
expect(decodeIndexCursor(cursor, searchScopeKey('a', base))).toEqual(
|
||||
position,
|
||||
);
|
||||
for (const [mailboxId, other] of [
|
||||
[undefined, base],
|
||||
['b', base],
|
||||
['a', { ...base, text: '견적' }],
|
||||
['a', { ...base, from: undefined }],
|
||||
['a', { ...base, to: '이도윤' }],
|
||||
['a', { ...base, subject: '계약' }],
|
||||
['a', { ...base, body: '계약' }],
|
||||
['a', { ...base, after: '2026-10-30T15:00:00.000Z' }],
|
||||
['a', { ...base, before: '2026-11-30T15:00:00.000Z' }],
|
||||
['a', { ...base, hasAttachment: true }],
|
||||
] as const) {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
decodeIndexCursor(cursor, searchScopeKey(mailboxId, other)),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('칸이 달라도 이은 글이 같아지지 않는다 — 같은 글을 다른 칸에 둔 검색은 다른 범위', () => {
|
||||
expect(searchScopeKey(undefined, criteria({ from: '계약' }))).not.toBe(
|
||||
searchScopeKey(undefined, criteria({ to: '계약' })),
|
||||
);
|
||||
expect(searchScopeKey('a', criteria({}))).not.toBe(
|
||||
searchScopeKey(undefined, criteria({})),
|
||||
);
|
||||
});
|
||||
|
||||
it('모양이 틀린 커서는 VAL_001 — 엔진 커서 · 틀린 판 · 이상한 id · 음수 시각 · 너무 긴 값', () => {
|
||||
const forge = (payload: object) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
for (const cursor of [
|
||||
encodeCursor({ anchor: 'm9x', position: 20 }, { text: '계약' }),
|
||||
forge({ v: 3, m: 'i', t: 1, id: 'm1', s: 'x' }),
|
||||
forge({ v: 2, m: 'e', t: 1, id: 'm1', s: 'x' }),
|
||||
forge({ v: 2, m: 'i', t: -1, id: 'm1', s: 'x' }),
|
||||
forge({ v: 2, m: 'i', t: 1.5, id: 'm1', s: 'x' }),
|
||||
forge({ v: 2, m: 'i', t: 1, id: '../x', s: 'x' }),
|
||||
forge({ v: 2, m: 'i', t: 1, id: 'm1' }),
|
||||
'not-json',
|
||||
'a'.repeat(600),
|
||||
]) {
|
||||
expect(codeOf(() => decodeIndexCursor(cursor, key))).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('Date 로 읽을 수 없는 시각(안전한 정수지만 ±8.64e15 ms 밖)의 커서는 VAL_001 — 범위 지문이 맞아도 500 이 아니다 (리뷰 L1)', () => {
|
||||
// 이 검색의 진짜 커서에서 시각만 바꾼다 — 범위 지문(s)은 맞는다
|
||||
const real = JSON.parse(
|
||||
Buffer.from(encodeIndexCursor(position, key), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
const withTime = (t: number) =>
|
||||
Buffer.from(JSON.stringify({ ...real, t })).toString('base64url');
|
||||
expect(Number.isSafeInteger(9e15)).toBe(true);
|
||||
expect(codeOf(() => decodeIndexCursor(withTime(9e15), key))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
// 한도 끝(8.64e15)까지는 날짜다 — 그대로 읽는다
|
||||
expect(decodeIndexCursor(withTime(8.64e15), key)).toEqual({
|
||||
receivedAt: new Date(8.64e15),
|
||||
emailId: position.emailId,
|
||||
});
|
||||
});
|
||||
|
||||
it('범위 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸을 흉내 내도 다른 범위 (리뷰 L7)', () => {
|
||||
const NL = String.fromCharCode(10);
|
||||
expect(searchScopeKey(undefined, criteria({ from: `a${NL}b` }))).not.toBe(
|
||||
searchScopeKey(undefined, criteria({ from: 'a', to: 'b' })),
|
||||
);
|
||||
expect(searchScopeKey(undefined, criteria({ text: `계약${NL}` }))).not.toBe(
|
||||
searchScopeKey(undefined, criteria({ text: '계약', from: '' })),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
import type { SearchCriteria } from './mail-search.types';
|
||||
import type { SearchPosition } from './search-sql';
|
||||
|
||||
/**
|
||||
* 색인으로 찾은 검색의 커서 (14단계 설계 C1 — 클라이언트에게는 불투명 문자열).
|
||||
* `{ v: 2, m: 'i', t: 받은 시각(ms), id, s: 범위 지문 }` — 그 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 이어 찾는다.
|
||||
* 다 찾지 못하고 확인 상한에 닿은 쪽(이어 찾기)도 같은 꼴이다 — 마지막으로 **훑은** 줄이 자리다.
|
||||
*
|
||||
* 방식(m)이 커서에 실려, 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 그 검색은 처음 방식대로 이어 간다 —
|
||||
* 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로, 이 커서는 색인으로.
|
||||
* 범위 지문에는 메일함 · 검색어 · 모든 조건이 든다 — 다른 검색의 커서를 섞어 쓰면 VAL_001. 지문은 짧은 해시라 글을 담지 않는다
|
||||
*/
|
||||
const CURSOR_VERSION = 2;
|
||||
const INDEX_METHOD = 'i';
|
||||
const MAX_CURSOR_LENGTH = 512;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** "모든 메일함"(휴지통 · 스팸메일함 제외) 범위 — 메일함 id 형식에 없는 글자 */
|
||||
const ALL_MAILBOXES_SCOPE = '*';
|
||||
|
||||
interface EncodedIndexCursor {
|
||||
v: number;
|
||||
m: string;
|
||||
t: number;
|
||||
id: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
/** 검색 방식 — 커서를 읽어 본다. 색인 커서가 아니면(엔진 방식 · 모양이 틀림) 'engine' — 엔진 쪽이 검사한다 */
|
||||
export function cursorMethod(cursor: string): 'index' | 'engine' {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
) as Partial<EncodedIndexCursor> | null;
|
||||
return parsed?.v === CURSOR_VERSION && parsed.m === INDEX_METHOD
|
||||
? 'index'
|
||||
: 'engine';
|
||||
} catch {
|
||||
return 'engine';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 지문의 글 — 메일함 · 검색어 · 조건을 정해진 차례로 (빈 칸은 빈 글).
|
||||
* 배열을 JSON 으로 잇는다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않게 (from 에 줄바꿈을 넣어 to 칸을 흉내 낼 수 없다)
|
||||
*/
|
||||
export function searchScopeKey(
|
||||
mailboxId: string | undefined,
|
||||
criteria: SearchCriteria,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
mailboxId ?? ALL_MAILBOXES_SCOPE,
|
||||
criteria.text ?? '',
|
||||
criteria.from ?? '',
|
||||
criteria.to ?? '',
|
||||
criteria.subject ?? '',
|
||||
criteria.body ?? '',
|
||||
criteria.after ?? '',
|
||||
criteria.before ?? '',
|
||||
criteria.hasAttachment === true,
|
||||
]);
|
||||
}
|
||||
|
||||
function scopeHash(scopeKey: string): string {
|
||||
return createHash('sha256')
|
||||
.update(scopeKey)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
export function encodeIndexCursor(
|
||||
position: SearchPosition,
|
||||
scopeKey: string,
|
||||
): string {
|
||||
const payload: EncodedIndexCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
m: INDEX_METHOD,
|
||||
t: position.receivedAt.getTime(),
|
||||
id: position.emailId,
|
||||
s: scopeHash(scopeKey),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 모양이 틀리거나 다른 검색의 커서면 VAL_001 */
|
||||
export function decodeIndexCursor(
|
||||
cursor: string,
|
||||
scopeKey: string,
|
||||
): SearchPosition {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor('not-json');
|
||||
}
|
||||
const { v, m, t, id, s } = (parsed ?? {}) as Partial<EncodedIndexCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
m === INDEX_METHOD &&
|
||||
typeof t === 'number' &&
|
||||
Number.isSafeInteger(t) &&
|
||||
t >= 0 &&
|
||||
// 날짜로 읽을 수 있는 시각만 — Date 의 한도(±8.64e15 ms) 밖이면 Invalid Date 라 쿼리 값으로 쓰면 500 이었다
|
||||
Number.isFinite(new Date(t).getTime()) &&
|
||||
typeof id === 'string' &&
|
||||
MAIL_ID_PATTERN.test(id) &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('shape');
|
||||
if (s !== scopeHash(scopeKey)) throw invalidCursor('other-list');
|
||||
return { receivedAt: new Date(t), emailId: id };
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { foldText, matchRanges } from '@/shared/utils/search-text';
|
||||
import {
|
||||
SNIPPET_BOUNDARY_SLACK,
|
||||
SNIPPET_LEAD_CHARS,
|
||||
SNIPPET_TOTAL_CHARS,
|
||||
} from './mail-search.constants';
|
||||
import {
|
||||
highlightedSubject,
|
||||
listHighlight,
|
||||
snippetAround,
|
||||
} from './search-highlight';
|
||||
import type { HighlightTerms } from './search-plan';
|
||||
|
||||
const terms = (...words: string[]): HighlightTerms => ({
|
||||
subject: words,
|
||||
body: words,
|
||||
attachment: words,
|
||||
query: words,
|
||||
});
|
||||
|
||||
/** 색인의 접은 칸 — 보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 (listHighlight 의 otherFields) */
|
||||
const otherFields = (fields: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
attachment?: string;
|
||||
}): string[] =>
|
||||
[fields.from, fields.to, fields.subject, fields.attachment].map((field) =>
|
||||
foldText(field ?? ''),
|
||||
);
|
||||
|
||||
/** 글에서 낱말이 처음 맞은 곳 */
|
||||
const firstMatch = (body: string, word: string) =>
|
||||
matchRanges(body, [word], 1)[0];
|
||||
|
||||
/** 범위가 가리키는 글 */
|
||||
const slices = (text: string, ranges: readonly (readonly [number, number])[]) =>
|
||||
ranges.map(([start, end]) => text.slice(start, end));
|
||||
|
||||
describe('목록의 검색 강조 (search-highlight — 14단계 설계 C1)', () => {
|
||||
describe('맞은 말 둘레 (snippetAround)', () => {
|
||||
it('짧은 본문은 그대로 — 잘라 낸 글이 없으면 "…" 도 없다', () => {
|
||||
const body = '이번 달 계약 갱신 일정입니다.';
|
||||
expect(snippetAround(body, firstMatch(body, '계약'))).toBe(body);
|
||||
});
|
||||
|
||||
it(`맞은 곳 앞 ${SNIPPET_LEAD_CHARS}자 남짓부터 모두 ${SNIPPET_TOTAL_CHARS}자 남짓 — 낱말 경계(공백)에서 자르고 앞뒤에 "…"`, () => {
|
||||
const before = Array.from({ length: 30 }, (_, i) => `앞말${i}`).join(' ');
|
||||
const after = Array.from({ length: 30 }, (_, i) => `뒷말${i}`).join(' ');
|
||||
const body = `${before} 계약서를 보내 드립니다 ${after}`;
|
||||
|
||||
const snippet = snippetAround(body, firstMatch(body, '계약'));
|
||||
|
||||
expect(snippet.startsWith('…')).toBe(true);
|
||||
expect(snippet.endsWith('…')).toBe(true);
|
||||
const inner = snippet.slice(1, -1);
|
||||
expect(inner).toContain('계약서를');
|
||||
// 낱말 가운데를 자르지 않았다 — 앞 · 뒤 조각이 본문의 낱말 그대로
|
||||
const words = inner.split(' ');
|
||||
for (const word of [words[0], words[words.length - 1]]) {
|
||||
expect(body.split(' ')).toContain(word);
|
||||
}
|
||||
// 맞은 곳 앞은 SNIPPET_LEAD_CHARS + 여유 안, 모두 SNIPPET_TOTAL_CHARS + 여유 안
|
||||
expect(inner.indexOf('계약')).toBeLessThanOrEqual(
|
||||
SNIPPET_LEAD_CHARS + SNIPPET_BOUNDARY_SLACK,
|
||||
);
|
||||
expect(inner.length).toBeLessThanOrEqual(
|
||||
SNIPPET_TOTAL_CHARS + SNIPPET_BOUNDARY_SLACK * 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('공백이 없는 긴 글은 그 자리에서 자르고 "…" — 맞은 곳은 늘 담는다', () => {
|
||||
const body = `${'가'.repeat(100)}계약${'나'.repeat(200)}`;
|
||||
const snippet = snippetAround(body, firstMatch(body, '계약'));
|
||||
|
||||
expect(snippet).toBe(
|
||||
`…${'가'.repeat(SNIPPET_LEAD_CHARS)}계약${'나'.repeat(SNIPPET_TOTAL_CHARS - SNIPPET_LEAD_CHARS - 2)}…`,
|
||||
);
|
||||
});
|
||||
|
||||
it('맞은 말이 쪽보다 길어도 다 담는다', () => {
|
||||
const long = '계'.repeat(150);
|
||||
const body = `안내 ${long} 끝`;
|
||||
const snippet = snippetAround(body, [3, 3 + long.length]);
|
||||
expect(snippet).toContain(long);
|
||||
});
|
||||
|
||||
it('줄바꿈 · 탭 · 여러 칸 공백은 띄어쓰기 하나로 — 잘라 낸 쪽이 공백뿐이면 "…" 를 붙이지 않는다', () => {
|
||||
const body = `${' '.repeat(30)}계약\n\n\t갱신 일정${'\n'.repeat(5)}`;
|
||||
expect(snippetAround(body, firstMatch(body, '계약'))).toBe(
|
||||
'계약 갱신 일정',
|
||||
);
|
||||
});
|
||||
|
||||
it('서로게이트 쌍(이모지) 가운데를 가르지 않는다', () => {
|
||||
const body = `${'😀'.repeat(40)}계약${'😀'.repeat(60)}`;
|
||||
const snippet = snippetAround(body, firstMatch(body, '계약'));
|
||||
// 짝이 없는 서로게이트가 없다
|
||||
expect(snippet).toBe(snippet.normalize());
|
||||
expect(() => encodeURIComponent(snippet)).not.toThrow();
|
||||
expect(snippet).toContain('계약');
|
||||
});
|
||||
});
|
||||
|
||||
describe('항목 하나의 강조 (listHighlight)', () => {
|
||||
it('제목의 맞은 곳(합친 범위) · 본문에서 처음 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳', () => {
|
||||
const highlight = listHighlight(
|
||||
{
|
||||
subject: '[공지] 계약 갱신 · 계약서',
|
||||
preview: '엔진의 미리 보기',
|
||||
bodyText:
|
||||
'안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.',
|
||||
otherFields: null,
|
||||
},
|
||||
terms('계약', '갱신'),
|
||||
);
|
||||
|
||||
expect(slices('[공지] 계약 갱신 · 계약서', highlight.subject)).toEqual([
|
||||
'계약',
|
||||
'갱신',
|
||||
'계약',
|
||||
]);
|
||||
expect(highlight.preview.text).toBe(
|
||||
'안녕하세요. 계약 갱신 건으로 연락드립니다. 갱신 계약서는 첨부를 보세요.',
|
||||
);
|
||||
expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([
|
||||
'계약',
|
||||
'갱신',
|
||||
'갱신',
|
||||
'계약',
|
||||
]);
|
||||
});
|
||||
|
||||
it('본문에 맞은 곳이 없으면(제목에서만 맞음) 엔진의 미리 보기 — 한 줄로, 맞은 곳이 있으면 그 자리', () => {
|
||||
const highlight = listHighlight(
|
||||
{
|
||||
subject: '계약 안내',
|
||||
preview: '첨부한 계약서를\n확인해 주세요',
|
||||
bodyText: null,
|
||||
otherFields: null,
|
||||
},
|
||||
terms('계약'),
|
||||
);
|
||||
expect(highlight.preview).toEqual({
|
||||
text: '첨부한 계약서를 확인해 주세요',
|
||||
ranges: [[4, 6]],
|
||||
});
|
||||
|
||||
const notInBody = listHighlight(
|
||||
{
|
||||
subject: '계약 안내',
|
||||
preview: '첨부 확인',
|
||||
bodyText: '첨부 확인',
|
||||
otherFields: otherFields({ subject: '계약 안내' }),
|
||||
},
|
||||
terms('계약'),
|
||||
);
|
||||
expect(notInBody.preview).toEqual({ text: '첨부 확인', ranges: [] });
|
||||
expect(notInBody.subject).toEqual([[0, 2]]);
|
||||
});
|
||||
|
||||
it('본문에 강조할 말이 없으면(제목 조건만) 미리 보기는 맞은 곳 없이 엔진의 것', () => {
|
||||
const highlight = listHighlight(
|
||||
{
|
||||
subject: '견적 요청',
|
||||
preview: '견적 부탁드립니다',
|
||||
bodyText: '견적 부탁드립니다',
|
||||
otherFields: null,
|
||||
},
|
||||
{ subject: ['견적'], body: [], attachment: [], query: [] },
|
||||
);
|
||||
expect(highlight).toEqual({
|
||||
subject: [[0, 2]],
|
||||
preview: { text: '견적 부탁드립니다', ranges: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('접어서 맞춘다 — 대문자 · 전각 · NFD 로 적은 미리 보기도 NFC 글 기준 위치', () => {
|
||||
const nfd = '계약 PROJECT'.normalize('NFD');
|
||||
const highlight = listHighlight(
|
||||
{
|
||||
subject: 'PROJECT 안내',
|
||||
preview: nfd,
|
||||
bodyText: null,
|
||||
otherFields: null,
|
||||
},
|
||||
terms('project', '계약'),
|
||||
);
|
||||
expect(highlight.subject).toEqual([[0, 7]]);
|
||||
expect(highlight.preview.text).toBe('계약 PROJECT');
|
||||
expect(slices(highlight.preview.text, highlight.preview.ranges)).toEqual([
|
||||
'계약',
|
||||
'PROJECT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('응답의 제목은 NFC — 강조 위치의 기준', () => {
|
||||
const nfd = '계약 안내'.normalize('NFD');
|
||||
expect(highlightedSubject(nfd)).toBe('계약 안내');
|
||||
expect(highlightedSubject(nfd).length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('답장의 원문 인용(> 줄)과 미리 보기 (2026-11-06 사용자 관점 점검)', () => {
|
||||
/** 미리 보기 — 본문 · 강조할 말 · 색인의 접은 칸(없으면 null) */
|
||||
const previewOf = (
|
||||
bodyText: string,
|
||||
words: HighlightTerms,
|
||||
fields: string[] | null = null,
|
||||
) =>
|
||||
listHighlight(
|
||||
{
|
||||
subject: '제목',
|
||||
preview: '엔진의 미리 보기',
|
||||
bodyText,
|
||||
otherFields: fields,
|
||||
},
|
||||
words,
|
||||
).preview;
|
||||
|
||||
const REPLY_SUBJECT = 'RE: 해오름 프로젝트 견적 검토 요청';
|
||||
const REPLY_FIELDS = otherFields({
|
||||
from: '이도윤 <doyun.lee@arasystem.test>',
|
||||
to: '김아라 <ara.kim@arasystem.test>',
|
||||
subject: REPLY_SUBJECT,
|
||||
});
|
||||
|
||||
it('인용 밖과 안에서 모두 맞으면 인용 밖의 맞은 곳 — 본문에서 먼저 나온 인용 안의 맞은 곳보다 먼저', () => {
|
||||
const preview = previewOf(
|
||||
'> 견적 부탁드립니다.\n\n견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
|
||||
terms('견적'),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '…견적서 확인했습니다. 금요일까지 회신 드리겠습니다.',
|
||||
ranges: [[1, 3]],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 밖에서 맞은 곳의 둘레는 인용 줄에 닿기 전까지 — 뒤따르는 인용은 싣지 않고 "…"', () => {
|
||||
const preview = previewOf(
|
||||
'금요일까지 견적서 회신 드리겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 견적 자료를 보냅니다.',
|
||||
terms('견적'),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '금요일까지 견적서 회신 드리겠습니다.…',
|
||||
ranges: [[6, 8]],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 안에서만 맞고 그 말이 다른 칸(제목 · 사람 · 첨부 이름)에서도 맞으면 인용 앞의 글 — 그 글에 맞은 곳이 없으면 범위도 없다', () => {
|
||||
const body =
|
||||
'네, 확인했습니다. 다음 주에 뵙겠습니다.\n\n> 제목: 해오름 프로젝트 견적 검토 요청\n> > 도윤 님, 견적 부탁드립니다.';
|
||||
const expected = {
|
||||
text: '네, 확인했습니다. 다음 주에 뵙겠습니다.…',
|
||||
ranges: [],
|
||||
};
|
||||
// 제목에서 맞음
|
||||
expect(previewOf(body, terms('견적'), REPLY_FIELDS)).toEqual(expected);
|
||||
// 보낸 사람에서 맞음
|
||||
expect(previewOf(body, terms('도윤'), REPLY_FIELDS)).toEqual(expected);
|
||||
// 첨부 이름에서 맞음
|
||||
expect(
|
||||
previewOf(
|
||||
body,
|
||||
terms('검토'),
|
||||
otherFields({ subject: '회신', attachment: '검토 의견.pdf' }),
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('인용 안에서만 맞고 다른 칸에서는 맞지 않으면 인용 조각 — 줄 앞의 > 를 뺀 글을 그 인용 안에서만, 위치는 보이는 글 기준', () => {
|
||||
const preview = previewOf(
|
||||
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
terms('R9A51'),
|
||||
otherFields({ subject: 'RE: 해오름 프로젝트 안내' }),
|
||||
);
|
||||
expect(preview).toEqual({
|
||||
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
ranges: [[16, 21]],
|
||||
});
|
||||
expect(slices(preview.text, preview.ranges)).toEqual(['R9A51']);
|
||||
});
|
||||
|
||||
it('본문에서 맞은 말 가운데 하나라도 다른 칸에서 맞지 않으면 인용 조각 — 내용 조건의 말은 본문에서만 찾으므로 다른 칸에 있어도 인용 조각', () => {
|
||||
const body =
|
||||
'네, 받았습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.';
|
||||
const fields = otherFields({ subject: 'RE: 해오름 프로젝트 안내' });
|
||||
|
||||
const both = previewOf(body, terms('해오름', 'R9A51'), fields);
|
||||
expect(both.text).toBe(
|
||||
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
);
|
||||
expect(slices(both.text, both.ranges)).toEqual(['해오름', 'R9A51']);
|
||||
|
||||
const bodyCondition = previewOf(
|
||||
body,
|
||||
{ subject: [], body: ['해오름'], attachment: [], query: [] },
|
||||
fields,
|
||||
);
|
||||
expect(bodyCondition.text).toBe(
|
||||
'…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||||
);
|
||||
expect(slices(bodyCondition.text, bodyCondition.ranges)).toEqual([
|
||||
'해오름',
|
||||
]);
|
||||
});
|
||||
|
||||
it('인용으로 시작하는 본문(인용 아래에 쓴 답장)은 인용 밖의 첫 글 — 본문이 모두 인용이면 다른 칸에서 맞아도 인용 조각', () => {
|
||||
expect(
|
||||
previewOf(
|
||||
'> 해오름 프로젝트 견적 부탁드립니다.\n\n네, 금요일까지 보내 드리겠습니다.',
|
||||
terms('견적'),
|
||||
REPLY_FIELDS,
|
||||
),
|
||||
).toEqual({ text: '…네, 금요일까지 보내 드리겠습니다.', ranges: [] });
|
||||
|
||||
const quoteOnly = previewOf(
|
||||
'> 해오름 프로젝트 견적 부탁드립니다.\n> 감사합니다.',
|
||||
terms('견적'),
|
||||
REPLY_FIELDS,
|
||||
);
|
||||
expect(quoteOnly.text).toBe(
|
||||
'해오름 프로젝트 견적 부탁드립니다. 감사합니다.',
|
||||
);
|
||||
expect(slices(quoteOnly.text, quoteOnly.ranges)).toEqual(['견적']);
|
||||
});
|
||||
|
||||
it('인용 줄 알아보기 — 앞 공백 · 탭 뒤의 > · 겹친 표시(>> · > >) · CRLF, 줄 가운데의 > 는 인용이 아니다', () => {
|
||||
expect(
|
||||
previewOf('a > b 견적 정리\r\n >> > 견적 원문', terms('견적')),
|
||||
).toEqual({ text: 'a > b 견적 정리…', ranges: [[6, 8]] });
|
||||
|
||||
expect(
|
||||
previewOf(' >> > 견적 원문\r\n\t> 두 번째 견적', terms('견적')),
|
||||
).toEqual({
|
||||
text: '견적 원문 두 번째 견적',
|
||||
ranges: [
|
||||
[0, 2],
|
||||
[11, 13],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('인용 조각도 서로게이트 쌍을 가르지 않고 위치는 NFC 글 기준 — NFD 로 적은 말도 맞는다', () => {
|
||||
const body = `확인했습니다.\n\n> ${'😀'.repeat(15)} 계약서 보내 드립니다 ${'😀'.repeat(40)}`;
|
||||
const preview = previewOf(body, terms('계약'.normalize('NFD')));
|
||||
|
||||
expect(preview.text).not.toContain('>');
|
||||
expect(() => encodeURIComponent(preview.text)).not.toThrow();
|
||||
expect(slices(preview.text, preview.ranges)).toEqual(['계약']);
|
||||
expect(preview.text.startsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('본문 길이에 비례한다 — 인용 줄이 많은 긴 본문(색인 본문 상한 남짓)에서 끝의 인용 조각을 골라도', () => {
|
||||
const bodyOf = (lines: number) =>
|
||||
`확인했습니다.\n\n${Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `> > 인용 줄 ${index} 가나다라마바사`,
|
||||
).join('\n')}\n> 마지막 견적 줄`;
|
||||
const run = (body: string) => {
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
previewOf(body, terms('견적'));
|
||||
}
|
||||
};
|
||||
const small = bodyOf(50);
|
||||
const large = bodyOf(1_000);
|
||||
expect(previewOf(large, terms('견적')).text).toContain('마지막 견적 줄');
|
||||
run(small);
|
||||
|
||||
const smallCpu = startCpuTimer();
|
||||
run(small);
|
||||
const smallMs = smallCpu();
|
||||
const largeCpu = startCpuTimer();
|
||||
run(large);
|
||||
const largeMs = largeCpu();
|
||||
// 20배 입력 — 제곱이면 400배다. 지금 값의 10배쯤을 한도로 둔다 (test/cpu-time.ts)
|
||||
expect(largeMs).toBeLessThan(Math.max(smallMs, 20) * 20 * 10);
|
||||
expect(largeMs).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
foldTerms,
|
||||
foldText,
|
||||
isSpaceCode,
|
||||
matchFoldedRanges,
|
||||
matchRanges,
|
||||
type MatchRange,
|
||||
} from '@/shared/utils/search-text';
|
||||
import {
|
||||
SNIPPET_BOUNDARY_SLACK,
|
||||
SNIPPET_LEAD_CHARS,
|
||||
SNIPPET_TOTAL_CHARS,
|
||||
} from './mail-search.constants';
|
||||
import type { MessageListHighlight, SearchRange } from './mail-search.types';
|
||||
import type { HighlightTerms } from './search-plan';
|
||||
|
||||
/**
|
||||
* 목록의 검색 강조 (순수 함수 — 14단계 설계 C1) — 제목의 맞은 곳과 미리 보기 한 줄.
|
||||
* - 제목: 검색어 · 제목 조건의 말이 맞은 곳 (위치는 NFC 제목 기준 — 부르는 쪽이 응답의 제목도 NFC 로 둔다)
|
||||
* - 미리 보기: 본문(색인의 글 — NFC)에서 맞은 곳을 아래 차례로 고르고(2026-11-06 사용자 관점 점검 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이라
|
||||
* 셋째 줄로 답장끼리 가를 수 없었다), 본문에서 맞지 않았으면 엔진의 미리 보기
|
||||
* 1. 인용 줄(앞 공백 뒤 `>` 로 시작하는 줄) 밖에서 처음 맞은 곳의 둘레 — 인용 줄에 닿기 전까지만
|
||||
* 2. 인용 안에서만 맞았고 본문에서 맞은 말이 모두 다른 칸(제목 · 사람 · 첨부 이름 — 검색어의 낱말)에서도 맞았으면 인용 밖의 첫 글(보통 인용 앞의 글)
|
||||
* 3. 그 밖에는 인용 안에서 처음 맞은 곳의 둘레 — 줄 앞의 `>` 표시를 뺀 글로, 그 인용(이어진 인용 줄) 안에서만
|
||||
* 다른 메일 프로그램의 인용 꼴(`>` 없이 "-----Original Message-----" 아래에 두는 것 등)은 알아보지 않는다
|
||||
* - 둘레는 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 남짓 · 모두 SNIPPET_TOTAL_CHARS 자 남짓 — SNIPPET_BOUNDARY_SLACK 안의 공백에서 자르고, 앞뒤에 잘라 낸 글이 있으면 "…".
|
||||
* 공백은 하나로 줄인 한 줄이고 맞은 곳은 보이는 그 글에서 다시 찾는다(그 글에 맞은 곳이 없으면 범위도 없다)
|
||||
* 위치는 UTF-16 [시작, 끝)
|
||||
*/
|
||||
|
||||
const ELLIPSIS = '…';
|
||||
/** 인용 줄의 표시 `>` */
|
||||
const QUOTE_MARK_CODE = 0x3e;
|
||||
const LINE_BREAK = '\n';
|
||||
|
||||
function isHighSurrogate(code: number): boolean {
|
||||
return code >= 0xd800 && code <= 0xdbff;
|
||||
}
|
||||
|
||||
/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 앞으로 */
|
||||
function alignStart(text: string, index: number): number {
|
||||
if (index <= 0 || index >= text.length) return Math.max(0, index);
|
||||
return isHighSurrogate(text.charCodeAt(index - 1)) ? index - 1 : index;
|
||||
}
|
||||
|
||||
/** 서로게이트 쌍 가운데를 가르지 않게 — 그 자리가 쌍의 뒤쪽이면 한 칸 뒤로 */
|
||||
function alignEnd(text: string, index: number): number {
|
||||
if (index <= 0 || index >= text.length) return Math.min(text.length, index);
|
||||
return isHighSurrogate(text.charCodeAt(index - 1)) ? index + 1 : index;
|
||||
}
|
||||
|
||||
/** `from` 에서 `to` 쪽으로 가며 처음 만나는 공백의 자리 — 없으면 -1 (from · to 를 포함) */
|
||||
function findSpace(text: string, from: number, to: number): number {
|
||||
const step = from <= to ? 1 : -1;
|
||||
for (let index = from; step > 0 ? index <= to : index >= to; index += step) {
|
||||
if (
|
||||
index >= 0 &&
|
||||
index < text.length &&
|
||||
isSpaceCode(text.charCodeAt(index))
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** [from, to) 에 공백이 아닌 글자가 있나 — 잘라 낸 쪽에 보이는 글이 있을 때만 "…" 를 붙이려고 */
|
||||
function hasVisible(text: string, from: number, to: number): boolean {
|
||||
for (let index = from; index < to; index += 1) {
|
||||
if (!isSpaceCode(text.charCodeAt(index))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 공백류의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 (한 줄로 보이는 미리 보기) */
|
||||
function oneLine(text: string): string {
|
||||
let line = '';
|
||||
let pendingSpace = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (isSpaceCode(text.charCodeAt(index))) {
|
||||
pendingSpace = line !== '';
|
||||
continue;
|
||||
}
|
||||
if (pendingSpace) line += ' ';
|
||||
pendingSpace = false;
|
||||
line += text[index];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 맞은 말 둘레 — 처음 맞은 곳(`first`)을 담아 앞 SNIPPET_LEAD_CHARS · 모두 SNIPPET_TOTAL_CHARS 남짓을 말 경계에서 자른다.
|
||||
* `bounds`([시작, 끝) — 처음 값은 글 전체) 밖으로는 나가지 않는다(인용 줄에 닿기 전까지 등).
|
||||
* 앞 · 뒤에 잘라 낸 글(공백만이 아닌 — `bounds` 밖도 센다)이 있으면 "…" 를 붙이고 한 줄로 만든다
|
||||
*/
|
||||
export function snippetAround(
|
||||
body: string,
|
||||
first: MatchRange,
|
||||
bounds: MatchRange = [0, body.length],
|
||||
): string {
|
||||
const [low, high] = bounds;
|
||||
const [matchStart, matchEnd] = first;
|
||||
// 시작 — 맞은 곳 앞 SNIPPET_LEAD_CHARS 자 둘레의 공백 뒤(앞으로 여유만큼, 없으면 맞은 곳 쪽으로), 그래도 없으면 그 자리
|
||||
const lead = Math.max(low, matchStart - SNIPPET_LEAD_CHARS);
|
||||
let start = lead;
|
||||
if (lead > low) {
|
||||
const before = findSpace(
|
||||
body,
|
||||
lead,
|
||||
Math.max(low, lead - SNIPPET_BOUNDARY_SLACK),
|
||||
);
|
||||
const after =
|
||||
before < 0 ? findSpace(body, lead, Math.max(lead, matchStart - 1)) : -1;
|
||||
const space = before >= 0 ? before : after;
|
||||
if (space >= 0) start = space + 1;
|
||||
}
|
||||
start = Math.max(low, alignStart(body, start));
|
||||
// 끝 — 시작에서 SNIPPET_TOTAL_CHARS 자 둘레의 공백 앞(뒤로 여유만큼, 없으면 앞으로), 맞은 곳은 늘 담는다
|
||||
const target = Math.max(matchEnd, start + SNIPPET_TOTAL_CHARS);
|
||||
let end = Math.min(high, target);
|
||||
if (end < high) {
|
||||
const back = findSpace(
|
||||
body,
|
||||
end,
|
||||
Math.max(matchEnd, end - SNIPPET_BOUNDARY_SLACK),
|
||||
);
|
||||
const forward =
|
||||
back < 0
|
||||
? findSpace(body, end, Math.min(high - 1, end + SNIPPET_BOUNDARY_SLACK))
|
||||
: -1;
|
||||
const space = back >= 0 ? back : forward;
|
||||
if (space >= 0) end = space;
|
||||
}
|
||||
end = Math.min(high, alignEnd(body, end));
|
||||
const text = oneLine(body.slice(start, end));
|
||||
const head = hasVisible(body, 0, start) ? ELLIPSIS : '';
|
||||
const tail = hasVisible(body, end, body.length) ? ELLIPSIS : '';
|
||||
return `${head}${text}${tail}`;
|
||||
}
|
||||
|
||||
/** 본문의 한 줄 — [start, end) (줄바꿈 글자는 빼고). 인용 줄이면 `quoteText` 가 표시 다음 글의 시작, 아니면 null */
|
||||
interface BodyLine {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
readonly quoteText: number | null;
|
||||
}
|
||||
|
||||
/** 인용 줄이면 표시 다음 글의 시작 — 앞 공백 뒤 `>` 로 시작하는 줄 (`> > 글` 처럼 겹친 표시와 그 사이 · 뒤 공백을 모두 넘는다) */
|
||||
function quoteTextStart(
|
||||
body: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): number | null {
|
||||
let index = start;
|
||||
while (index < end && isSpaceCode(body.charCodeAt(index))) index += 1;
|
||||
if (index >= end || body.charCodeAt(index) !== QUOTE_MARK_CODE) return null;
|
||||
while (index < end) {
|
||||
const code = body.charCodeAt(index);
|
||||
if (code !== QUOTE_MARK_CODE && !isSpaceCode(code)) break;
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** 본문의 줄들 — 한 번 훑는다 */
|
||||
function bodyLines(body: string): BodyLine[] {
|
||||
const lines: BodyLine[] = [];
|
||||
for (let start = 0; ; ) {
|
||||
const found = body.indexOf(LINE_BREAK, start);
|
||||
const end = found < 0 ? body.length : found;
|
||||
lines.push({ start, end, quoteText: quoteTextStart(body, start, end) });
|
||||
if (found < 0) return lines;
|
||||
start = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 그 자리를 담은 줄의 차례 (줄은 시작 차례로 놓여 있다) */
|
||||
function lineIndexAt(lines: readonly BodyLine[], position: number): number {
|
||||
let low = 0;
|
||||
let high = lines.length - 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1;
|
||||
if (lines[middle].start <= position) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/** 인용 줄을 공백으로 덮은 글 — 위치 · 길이는 그대로 (인용 밖에서만 맞추려고) */
|
||||
function maskQuotes(text: string, lines: readonly BodyLine[]): string {
|
||||
let masked = '';
|
||||
let from = 0;
|
||||
for (const line of lines) {
|
||||
if (line.quoteText === null) continue;
|
||||
masked += text.slice(from, line.start) + ' '.repeat(line.end - line.start);
|
||||
from = line.end;
|
||||
}
|
||||
return masked + text.slice(from);
|
||||
}
|
||||
|
||||
/** 그 줄과 같은 쪽(인용 · 인용 밖)인 줄들의 이어진 범위 [시작, 끝) — 미리 보기가 인용과 인용 밖을 섞지 않게 */
|
||||
function runAround(lines: readonly BodyLine[], index: number): MatchRange {
|
||||
const quoted = lines[index].quoteText !== null;
|
||||
const same = (line: BodyLine) => (line.quoteText !== null) === quoted;
|
||||
let first = index;
|
||||
while (first > 0 && same(lines[first - 1])) first -= 1;
|
||||
let last = index;
|
||||
while (last + 1 < lines.length && same(lines[last + 1])) last += 1;
|
||||
return [lines[first].start, lines[last].end];
|
||||
}
|
||||
|
||||
/** 인용 밖의 첫 글 — 보이는 글이 있는 첫 인용 밖 줄부터 다음 인용 줄 전까지 (없으면 null) */
|
||||
function firstPlainRun(
|
||||
body: string,
|
||||
lines: readonly BodyLine[],
|
||||
): MatchRange | null {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.quoteText === null && hasVisible(body, line.start, line.end)) {
|
||||
return [line.start, runAround(lines, index)[1]];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 인용 표시를 뺀 글 — 줄은 그대로 잇고, 원래 글의 자리를 이 글의 자리로 바꾸는 함수를 함께 준다 */
|
||||
function stripQuoteMarks(
|
||||
body: string,
|
||||
lines: readonly BodyLine[],
|
||||
): { readonly text: string; readonly at: (position: number) => number } {
|
||||
const parts: string[] = [];
|
||||
const starts: number[] = [];
|
||||
let length = 0;
|
||||
lines.forEach((line, index) => {
|
||||
if (index > 0) {
|
||||
parts.push(LINE_BREAK);
|
||||
length += LINE_BREAK.length;
|
||||
}
|
||||
starts.push(length);
|
||||
const from = line.quoteText ?? line.start;
|
||||
parts.push(body.slice(from, line.end));
|
||||
length += line.end - from;
|
||||
});
|
||||
return {
|
||||
text: parts.join(''),
|
||||
at: (position) => {
|
||||
const index = lineIndexAt(lines, position);
|
||||
const line = lines[index];
|
||||
const from = line.quoteText ?? line.start;
|
||||
return (
|
||||
starts[index] + Math.min(Math.max(0, position - from), line.end - from)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에서 맞은 말(`words` — 접은 말)이 모두 다른 칸에서도 맞았나 — 검색어의 낱말이고(내용 조건의 낱말은 본문에서만 찾는다)
|
||||
* 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름) 가운데 하나에 든다. 칸을 모르면(null) 아니다
|
||||
*/
|
||||
function matchedElsewhere(
|
||||
folded: string,
|
||||
words: readonly string[],
|
||||
query: readonly string[],
|
||||
otherFields: readonly string[] | null,
|
||||
): boolean {
|
||||
if (otherFields === null) return false;
|
||||
const queryWords = new Set(foldTerms(query));
|
||||
return words
|
||||
.filter((word) => folded.includes(word))
|
||||
.every(
|
||||
(word) =>
|
||||
queryWords.has(word) &&
|
||||
otherFields.some((field) => field.includes(word)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 본문에서 고른 미리 보기 한 줄 (위의 1 · 2 · 3) — 본문에서 맞지 않았으면 null */
|
||||
function bodySnippet(
|
||||
text: string,
|
||||
terms: HighlightTerms,
|
||||
otherFields: readonly string[] | null,
|
||||
): string | null {
|
||||
// 접은 글은 NFC 본문과 위치가 같다 — 줄 · 인용은 본문에서, 맞추기는 접은 글에서 (색인의 본문은 이미 NFC 다 — 거의 그대로 돌아온다)
|
||||
const body = text.normalize('NFC');
|
||||
const folded = foldText(body);
|
||||
const words = foldTerms(terms.body);
|
||||
const lines = bodyLines(body);
|
||||
const [outside] = matchFoldedRanges(maskQuotes(folded, lines), words, 1);
|
||||
if (outside) {
|
||||
return snippetAround(
|
||||
body,
|
||||
outside,
|
||||
runAround(lines, lineIndexAt(lines, outside[0])),
|
||||
);
|
||||
}
|
||||
const [inside] = matchFoldedRanges(folded, words, 1);
|
||||
if (!inside) return null;
|
||||
if (matchedElsewhere(folded, words, terms.query, otherFields)) {
|
||||
const run = firstPlainRun(body, lines);
|
||||
if (run) return snippetAround(body, [run[0], run[0]], run);
|
||||
}
|
||||
// 인용 조각 — 그 인용(이어진 인용 줄)에 닿기 전까지, `>` 표시를 뺀 글에서
|
||||
const stripped = stripQuoteMarks(body, lines);
|
||||
const [runStart, runEnd] = runAround(lines, lineIndexAt(lines, inside[0]));
|
||||
return snippetAround(
|
||||
stripped.text,
|
||||
[stripped.at(inside[0]), stripped.at(inside[1])],
|
||||
[stripped.at(runStart), stripped.at(runEnd)],
|
||||
);
|
||||
}
|
||||
|
||||
function toRanges(ranges: readonly MatchRange[]): SearchRange[] {
|
||||
return ranges.map(([start, end]) => [start, end] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 항목 하나의 강조 — `subject` 는 응답에 실을 제목(NFC), `preview` 는 엔진의 미리 보기, `bodyText` 는 색인의 본문 글(NFC — 없으면 null),
|
||||
* `otherFields` 는 색인의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름 — 없으면 null)
|
||||
*/
|
||||
export function listHighlight(
|
||||
input: {
|
||||
readonly subject: string;
|
||||
readonly preview: string;
|
||||
readonly bodyText: string | null;
|
||||
readonly otherFields: readonly string[] | null;
|
||||
},
|
||||
terms: HighlightTerms,
|
||||
): MessageListHighlight {
|
||||
const subject = toRanges(matchRanges(input.subject, terms.subject));
|
||||
const fromBody =
|
||||
input.bodyText && terms.body.length > 0
|
||||
? bodySnippet(input.bodyText, terms, input.otherFields)
|
||||
: null;
|
||||
const text = fromBody ?? oneLine(input.preview.normalize('NFC'));
|
||||
return {
|
||||
subject,
|
||||
preview: { text, ranges: toRanges(matchRanges(text, terms.body)) },
|
||||
};
|
||||
}
|
||||
|
||||
/** 응답에 실을 제목 — 강조 위치의 기준인 NFC (화면에는 같게 보인다) */
|
||||
export function highlightedSubject(subject: string): string {
|
||||
return subject.normalize('NFC');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants';
|
||||
import type { SearchCriteria } from './mail-search.types';
|
||||
import {
|
||||
ALL_FOLD_FIELDS,
|
||||
highlightTermsOf,
|
||||
isSearchCriteria,
|
||||
planSearch,
|
||||
} from './search-plan';
|
||||
|
||||
const criteria = (overrides: Partial<SearchCriteria>): SearchCriteria => ({
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('검색 계획 (search-plan — 14단계 설계 C1 · G)', () => {
|
||||
it('검색인가 — 검색어 · 글 조건 · 기간 · 첨부 가운데 하나라도 (빈 글 · 첨부 false 는 없는 것)', () => {
|
||||
expect(isSearchCriteria(criteria({}))).toBe(false);
|
||||
expect(isSearchCriteria(criteria({ text: '', subject: '' }))).toBe(false);
|
||||
for (const overrides of [
|
||||
{ text: '계약' },
|
||||
{ from: '이도윤' },
|
||||
{ to: 'ara' },
|
||||
{ subject: '견적' },
|
||||
{ body: '일정' },
|
||||
{ after: '2026-10-31T15:00:00.000Z' },
|
||||
{ before: '2026-11-30T15:00:00.000Z' },
|
||||
{ hasAttachment: true },
|
||||
]) {
|
||||
expect(isSearchCriteria(criteria(overrides))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('검색어의 낱말은 다섯 칸 어디서든 — 접은 낱말로 확인하고 글자 두 개 묶음으로 후보를 좁힌다', () => {
|
||||
const plan = planSearch(criteria({ text: '계약 PROJECT' }));
|
||||
|
||||
expect(plan.checks).toEqual([
|
||||
{ term: '계약', fields: ALL_FOLD_FIELDS },
|
||||
{ term: 'project', fields: ALL_FOLD_FIELDS },
|
||||
]);
|
||||
expect(plan.grams).toEqual(['계약', 'pr', 'ro', 'oj', 'je', 'ec', 'ct']);
|
||||
expect(plan.matchesNothing).toBe(false);
|
||||
expect(plan.bodySkipped).toBe(false);
|
||||
// 강조는 접기 전 낱말 그대로 (맞추기가 접는다)
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['계약', 'PROJECT'],
|
||||
body: ['계약', 'PROJECT'],
|
||||
attachment: ['계약', 'PROJECT'],
|
||||
query: ['계약', 'PROJECT'],
|
||||
});
|
||||
});
|
||||
|
||||
it('큰따옴표 구절은 띄어쓰기까지 그대로 하나의 확인 — 묶음은 공백을 넘지 않는다, 앞뒤 문장부호를 뗀 낱말 · 같은 낱말은 한 번', () => {
|
||||
const plan = planSearch(criteria({ text: '"계약 갱신" (계약), 계약' }));
|
||||
|
||||
expect(plan.checks.map((check) => check.term)).toEqual([
|
||||
'계약 갱신',
|
||||
'계약',
|
||||
]);
|
||||
expect(plan.grams).toEqual(['계약', '갱신']);
|
||||
});
|
||||
|
||||
it('조건의 낱말은 그 칸에서만 — 받는 사람(to)은 받는 사람 · 참조를 담은 칸, 보낸 사람 · 받는 사람은 강조하지 않는다', () => {
|
||||
const plan = planSearch(
|
||||
criteria({
|
||||
from: '이도윤',
|
||||
to: 'ara.kim',
|
||||
subject: '견적',
|
||||
body: '"단가 조정"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.checks).toEqual([
|
||||
{ term: '이도윤', fields: ['fold_from'] },
|
||||
{ term: 'ara.kim', fields: ['fold_to'] },
|
||||
{ term: '견적', fields: ['fold_subject'] },
|
||||
{ term: '단가 조정', fields: ['fold_body'] },
|
||||
]);
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['견적'],
|
||||
body: ['단가 조정'],
|
||||
attachment: [],
|
||||
query: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 낱말이라도 찾는 칸이 다르면 따로 확인한다', () => {
|
||||
const plan = planSearch(criteria({ text: '견적', subject: '견적' }));
|
||||
expect(plan.checks).toEqual([
|
||||
{ term: '견적', fields: ALL_FOLD_FIELDS },
|
||||
{ term: '견적', fields: ['fold_subject'] },
|
||||
]);
|
||||
expect(plan.grams).toEqual(['견적']);
|
||||
});
|
||||
|
||||
it('한 글자 낱말은 두 글자 넘는 낱말과 함께면 확인에만 쓴다 — 본문에서도 찾는다', () => {
|
||||
const plan = planSearch(criteria({ text: '묵 계약' }));
|
||||
|
||||
expect(plan.bodySkipped).toBe(false);
|
||||
expect(plan.grams).toEqual(['계약']);
|
||||
expect(plan.checks).toEqual([
|
||||
{ term: '묵', fields: ALL_FOLD_FIELDS },
|
||||
{ term: '계약', fields: ALL_FOLD_FIELDS },
|
||||
]);
|
||||
// 본문 강조는 두 글자 이상 낱말만 — 한 글자 낱말은 제목 · 첨부 이름에만 (2026-11-06 실사용 시험)
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['묵', '계약'],
|
||||
body: ['계약'],
|
||||
attachment: ['묵', '계약'],
|
||||
query: ['묵', '계약'],
|
||||
});
|
||||
});
|
||||
|
||||
it('본문 강조는 본문을 찾은 두 글자 이상 낱말만 — 내용 조건과 섞여도 · 묶음이 없는 구절(한 글자씩 띄운 것)도 한 글자 낱말과 같다', () => {
|
||||
// 내용 조건의 두 글자 이상 낱말과 섞임 — 검색어의 한 글자 낱말은 본문에 강조하지 않는다
|
||||
expect(
|
||||
planSearch(criteria({ text: '견', body: '계약서' })).highlight.body,
|
||||
).toEqual(['계약서']);
|
||||
// 두 글자 이상 낱말이 강조하지 않는 칸(보낸 사람)에만 있어도 섞인 검색이다 — 본문에 강조할 말이 없다
|
||||
const withSender = planSearch(criteria({ text: '견', from: '김아라' }));
|
||||
expect(withSender.bodySkipped).toBe(false);
|
||||
expect(withSender.highlight.body).toEqual([]);
|
||||
expect(withSender.highlight.subject).toEqual(['견']);
|
||||
// 한 글자씩 띄운 구절은 묶음이 없다
|
||||
expect(planSearch(criteria({ text: '"a b" 계약' })).highlight.body).toEqual(
|
||||
['계약'],
|
||||
);
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 본문은 훑지 않는다 — 제목 · 사람 · 첨부 이름에서만 (SINGLE_CHAR_TERMS_SKIP_BODY)', () => {
|
||||
expect(SINGLE_CHAR_TERMS_SKIP_BODY).toBe(true);
|
||||
const plan = planSearch(criteria({ text: '묵 뷁', from: '김' }));
|
||||
|
||||
expect(plan.bodySkipped).toBe(true);
|
||||
expect(plan.grams).toEqual([]);
|
||||
expect(plan.matchesNothing).toBe(false);
|
||||
expect(plan.checks).toEqual([
|
||||
{
|
||||
term: '묵',
|
||||
fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'],
|
||||
},
|
||||
{
|
||||
term: '뷁',
|
||||
fields: ['fold_from', 'fold_to', 'fold_subject', 'fold_attachment'],
|
||||
},
|
||||
{ term: '김', fields: ['fold_from'] },
|
||||
]);
|
||||
// 본문을 찾지 않았으니 본문(미리 보기 · 읽기 화면)에는 강조하지 않는다 — 제목 · 첨부 이름에는 그대로 (2026-11-06 실사용 시험)
|
||||
expect(plan.highlight).toEqual({
|
||||
subject: ['묵', '뷁'],
|
||||
body: [],
|
||||
attachment: ['묵', '뷁'],
|
||||
query: ['묵', '뷁'],
|
||||
});
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐인데 내용 조건이 있으면 찾을 곳이 없다 — 아무것도 맞지 않는다', () => {
|
||||
expect(planSearch(criteria({ body: '묵' })).matchesNothing).toBe(true);
|
||||
expect(
|
||||
planSearch(criteria({ text: '묵', body: '뷁' })).matchesNothing,
|
||||
).toBe(true);
|
||||
// 두 글자 넘는 낱말과 함께면 한 글자 내용 조건도 확인한다
|
||||
const together = planSearch(criteria({ text: '계약', body: '묵' }));
|
||||
expect(together.matchesNothing).toBe(false);
|
||||
expect(together.checks).toContainEqual({
|
||||
term: '묵',
|
||||
fields: ['fold_body'],
|
||||
});
|
||||
});
|
||||
|
||||
it('글을 준 칸에 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다', () => {
|
||||
for (const overrides of [
|
||||
{ text: '…' },
|
||||
{ text: '""' },
|
||||
{ from: '-' },
|
||||
{ text: '계약', subject: '—' },
|
||||
]) {
|
||||
expect(planSearch(criteria(overrides)).matchesNothing).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('기간 · 첨부만의 검색은 확인도 묶음도 없다 — 받은 시각 · 첨부 표시로만', () => {
|
||||
const plan = planSearch(
|
||||
criteria({
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
before: '2026-11-30T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.checks).toEqual([]);
|
||||
expect(plan.grams).toEqual([]);
|
||||
expect(plan.matchesNothing).toBe(false);
|
||||
expect(plan.after?.toISOString()).toBe('2026-10-31T15:00:00.000Z');
|
||||
expect(plan.before?.toISOString()).toBe('2026-11-30T15:00:00.000Z');
|
||||
expect(plan.hasAttachment).toBe(true);
|
||||
});
|
||||
|
||||
it('전각 · 대문자 · 공백류는 접어서 확인한다 — 구절 안의 줄바꿈도 띄어쓰기 하나와 같다', () => {
|
||||
const plan = planSearch(criteria({ text: '"ERP\u3000도입"' }));
|
||||
expect(plan.checks.map((check) => check.term)).toEqual(['erp 도입']);
|
||||
expect(plan.grams).toEqual(['er', 'rp', '도입']);
|
||||
});
|
||||
|
||||
it('두 글자 낱말뿐인 검색어는 묶음만으로 정해진다(exactByGrams) — 세 글자 · 구절 · 한 글자 · 조건 칸이 섞이면 확인한다', () => {
|
||||
// 서로게이트 쌍 글자(CJK 확장 B — 글자)도 코드 포인트 하나로 센다
|
||||
const astralAndLetter = `${String.fromCodePoint(0x20000)}가`;
|
||||
for (const text of [
|
||||
'계약',
|
||||
'계약 확인',
|
||||
'ab',
|
||||
'AB',
|
||||
astralAndLetter,
|
||||
'(계약),',
|
||||
]) {
|
||||
expect(planSearch(criteria({ text })).exactByGrams).toBe(true);
|
||||
}
|
||||
expect(
|
||||
planSearch(
|
||||
criteria({
|
||||
text: '계약',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
}),
|
||||
).exactByGrams,
|
||||
).toBe(true);
|
||||
for (const overrides of [
|
||||
{ text: '계약서' },
|
||||
{ text: '"계약 갱신"' },
|
||||
{ text: '묵 계약' },
|
||||
{ text: '계약', subject: '견적' },
|
||||
{ subject: '계약' },
|
||||
{ hasAttachment: true },
|
||||
]) {
|
||||
expect(planSearch(criteria(overrides)).exactByGrams).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
describe('읽기 화면의 강조할 말 (highlightTermsOf — 설계 C2)', () => {
|
||||
it('검색어는 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에', () => {
|
||||
expect(
|
||||
highlightTermsOf({
|
||||
text: '계약',
|
||||
subject: '견적',
|
||||
body: '"단가 조정"',
|
||||
}),
|
||||
).toEqual({
|
||||
subject: ['계약', '견적'],
|
||||
body: ['계약', '단가 조정'],
|
||||
attachment: ['계약'],
|
||||
query: ['계약'],
|
||||
});
|
||||
});
|
||||
|
||||
it('강조할 말이 없으면 null — 검색어 · 제목 · 내용 조건이 없거나 문장부호뿐', () => {
|
||||
expect(highlightTermsOf({})).toBeNull();
|
||||
expect(highlightTermsOf({ text: '…', subject: '""' })).toBeNull();
|
||||
});
|
||||
|
||||
it('본문에는 목록과 같이 본문을 찾은 낱말만 — 한 글자 낱말뿐이면 제목 · 첨부 이름에만, 섞이면 두 글자 이상 낱말만 (보낸 사람 · 받는 사람 조건도 섞임을 정한다)', () => {
|
||||
expect(highlightTermsOf({ text: '견' })).toEqual({
|
||||
subject: ['견'],
|
||||
body: [],
|
||||
attachment: ['견'],
|
||||
query: ['견'],
|
||||
});
|
||||
expect(highlightTermsOf({ text: '견 계약서' })?.body).toEqual(['계약서']);
|
||||
expect(highlightTermsOf({ text: '견', to: '김아라' })?.body).toEqual([]);
|
||||
// 한 글자 내용 조건뿐 — 찾을 곳이 없어 강조할 말도 없다
|
||||
expect(highlightTermsOf({ body: '견' })).toBeNull();
|
||||
// 목록의 계획과 같은 강조
|
||||
for (const given of [
|
||||
{ text: '견' },
|
||||
{ text: '견 계약서', subject: '회' },
|
||||
{ text: '견', from: '김아라', body: '단가' },
|
||||
]) {
|
||||
expect(highlightTermsOf(given)).toEqual(
|
||||
planSearch(criteria(given)).highlight,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('검색 글의 NUL 은 U+FFFD 로 — 확인 · 묶음 · 강조가 같은 글자를 쓴다 (리뷰 L2)', () => {
|
||||
const NUL = String.fromCharCode(0);
|
||||
const FFFD = String.fromCharCode(0xfffd);
|
||||
const plan = planSearch(criteria({ text: `계${NUL}약`, body: `a${NUL}b` }));
|
||||
expect(plan.checks.map((check) => check.term)).toEqual([
|
||||
`계${FFFD}약`,
|
||||
`a${FFFD}b`,
|
||||
]);
|
||||
expect(plan.grams).toEqual([
|
||||
`계${FFFD}`,
|
||||
`${FFFD}약`,
|
||||
`a${FFFD}`,
|
||||
`${FFFD}b`,
|
||||
]);
|
||||
expect(JSON.stringify(plan)).not.toContain(NUL);
|
||||
expect(plan.highlight.body).toEqual([`계${FFFD}약`, `a${FFFD}b`]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { parseSearchTerms } from '@/shared/utils/search-query';
|
||||
import { foldText, gramsOf, isSpaceCode } from '@/shared/utils/search-text';
|
||||
import { SINGLE_CHAR_TERMS_SKIP_BODY } from './mail-search.constants';
|
||||
import type { SearchCriteria } from './mail-search.types';
|
||||
|
||||
/**
|
||||
* 검색 조건 → 색인에서 찾을 계획 (순수 함수 — 14단계 설계 C1 · G).
|
||||
*
|
||||
* - 검색어(q)의 낱말 · 구절은 다섯 칸(보낸 사람 · 받는 사람(참조 포함) · 제목 · 첨부 이름 · 본문) 가운데 어디든, 조건 칸(from · to · subject · body)의
|
||||
* 낱말 · 구절은 그 칸에서 — 모두 맞아야 한다(AND). 낱말 · 구절 나누기는 엔진 검색과 같다(parseSearchTerms — 큰따옴표 구절 · 앞뒤 문장부호 떼기)
|
||||
* - 낱말마다 접기(foldText) 뒤 부분 일치(strpos)로 확인하고, 모든 낱말의 글자 두 개 묶음(gramsOf)으로 후보를 좁힌다(GIN `grams @>`) —
|
||||
* 낱말이 한 칸 안에 이어 있으니 그 묶음은 늘 그 줄의 묶음 안에 있다
|
||||
* - 한 글자 낱말(묶음이 없는 낱말): 묶음이 있는 낱말과 함께면 확인에만 쓴다. 한 글자 낱말뿐이면(SINGLE_CHAR_TERMS_SKIP_BODY) 본문은 훑지 않는다 —
|
||||
* 검색어의 낱말은 제목 · 사람 · 첨부 이름에서만, 내용(body) 조건의 낱말은 찾을 곳이 없어 아무것도 맞지 않는다
|
||||
* - 글을 준 칸인데 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표) 아무것도 맞지 않는다 — 범위 전체를 돌려주지 않는다(엔진 검색과 같다)
|
||||
* - 검색어의 낱말이 모두 두 글자(코드 포인트 둘 · 공백 없음)면 묶음 하나가 곧 그 낱말이다 — 묶음이 맞은 줄은 다섯 칸 가운데 어딘가에 그 낱말이 있다(exactByGrams).
|
||||
* 색인의 묶음은 저장한 접기 칸 그대로에서 만든다(index-document.ts) — 그래서 확인하지 않아도 된다
|
||||
* - 본문(목록 미리 보기 · 읽기 화면)에는 본문을 찾은 낱말만 강조한다(2026-11-06 실사용 시험 — bodyHighlightOf). 제목 · 첨부 이름 강조는 낱말 그대로다
|
||||
*/
|
||||
|
||||
/** 확인하는 접기 칸 — 표의 열 이름 */
|
||||
export type FoldField =
|
||||
| 'fold_from'
|
||||
| 'fold_to'
|
||||
| 'fold_subject'
|
||||
| 'fold_attachment'
|
||||
| 'fold_body';
|
||||
|
||||
/** 검색어(q)의 낱말을 찾는 칸 — 다섯 칸 모두 */
|
||||
export const ALL_FOLD_FIELDS: readonly FoldField[] = [
|
||||
'fold_from',
|
||||
'fold_to',
|
||||
'fold_subject',
|
||||
'fold_attachment',
|
||||
'fold_body',
|
||||
];
|
||||
|
||||
/** 조건 칸 → 그 칸의 접기 열 */
|
||||
const CONDITION_FIELDS = {
|
||||
from: 'fold_from',
|
||||
to: 'fold_to',
|
||||
subject: 'fold_subject',
|
||||
body: 'fold_body',
|
||||
} as const satisfies Record<string, FoldField>;
|
||||
|
||||
type ConditionName = keyof typeof CONDITION_FIELDS;
|
||||
const CONDITION_NAMES = Object.keys(CONDITION_FIELDS) as ConditionName[];
|
||||
|
||||
/** 낱말 하나의 확인 — 접은 낱말이 그 칸들 가운데 하나에 들어 있어야 한다 */
|
||||
export interface TermCheck {
|
||||
readonly term: string;
|
||||
readonly fields: readonly FoldField[];
|
||||
}
|
||||
|
||||
/** 강조할 말 — 원래 낱말 · 구절(접기 전) — 제목 · 본문 · 첨부 이름 */
|
||||
export interface HighlightTerms {
|
||||
readonly subject: readonly string[];
|
||||
/** 본문을 찾은 낱말만 (bodyHighlightOf) — 한 글자 낱말은 들지 않는다 */
|
||||
readonly body: readonly string[];
|
||||
readonly attachment: readonly string[];
|
||||
/**
|
||||
* 검색어(q)의 낱말 — 다섯 칸 어디서든 찾는다. 목록 미리 보기가 본문의 인용 안에서만 맞은 말을 다른 칸(제목 · 사람 · 첨부 이름)에서도
|
||||
* 찾았는지 볼 때 쓴다(search-highlight.ts). 내용 조건(body)의 낱말은 본문에서만 찾으므로 들지 않는다
|
||||
*/
|
||||
readonly query: readonly string[];
|
||||
}
|
||||
|
||||
export interface SearchPlan {
|
||||
/** 낱말 확인 — 모두 맞아야 한다. 비었으면 범위 · 기간 · 첨부만으로 찾는다 */
|
||||
readonly checks: readonly TermCheck[];
|
||||
/** 후보를 좁히는 글자 두 개 묶음 — 비었으면 GIN 을 쓰지 못한다 */
|
||||
readonly grams: readonly string[];
|
||||
readonly after: Date | null;
|
||||
readonly before: Date | null;
|
||||
readonly hasAttachment: boolean;
|
||||
/** 아무것도 맞을 수 없다 — 묻지 않고 빈 결과 */
|
||||
readonly matchesNothing: boolean;
|
||||
/** 한 글자 낱말뿐이라 본문을 훑지 않았다 (SINGLE_CHAR_TERMS_SKIP_BODY) */
|
||||
readonly bodySkipped: boolean;
|
||||
/** 묶음만으로 정해진다 — 검색어(다섯 칸)의 두 글자 낱말뿐이라 확인(strpos)이 늘 참이다 */
|
||||
readonly exactByGrams: boolean;
|
||||
readonly highlight: HighlightTerms;
|
||||
}
|
||||
|
||||
/** 검색인가 — 검색어나 조건이 하나라도 있으면 (빈 글은 없는 것) */
|
||||
export function isSearchCriteria(criteria: SearchCriteria): boolean {
|
||||
return (
|
||||
Boolean(criteria.text) ||
|
||||
CONDITION_NAMES.some((name) => Boolean(criteria[name])) ||
|
||||
criteria.after !== undefined ||
|
||||
criteria.before !== undefined ||
|
||||
criteria.hasAttachment === true
|
||||
);
|
||||
}
|
||||
|
||||
/** 글을 낱말 · 구절로 — 접기 전 글(강조)과 접은 글(확인) */
|
||||
function termsOf(text: string | undefined): {
|
||||
readonly given: boolean;
|
||||
readonly raw: string[];
|
||||
} {
|
||||
if (!text) return { given: false, raw: [] };
|
||||
return { given: true, raw: parseSearchTerms(text).map((term) => term.text) };
|
||||
}
|
||||
|
||||
/** 두 글자 낱말인가 — 코드 포인트 둘 · 공백 없음 (그 낱말의 묶음 하나가 곧 그 낱말이다) */
|
||||
function isGramWord(term: string): boolean {
|
||||
const points = [...term];
|
||||
return (
|
||||
points.length === 2 &&
|
||||
points.every((point) => !isSpaceCode(point.charCodeAt(0)))
|
||||
);
|
||||
}
|
||||
|
||||
function dateOf(value: string | undefined): Date | null {
|
||||
if (value === undefined) return null;
|
||||
const time = Date.parse(value);
|
||||
return Number.isNaN(time) ? null : new Date(time);
|
||||
}
|
||||
|
||||
/** 묶음이 있는 낱말인가 — 접어서 글자 두 개 묶음이 하나라도 나온다 (한 글자 낱말 · 한 글자씩 띄운 구절은 없다) */
|
||||
function hasGrams(raw: string): boolean {
|
||||
return gramsOf(foldText(raw)).size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 강조할 말 (2026-11-06 실사용 시험) — 본문을 찾은 낱말만이다. 검색어 · 내용 조건의 낱말(`raws`) 가운데
|
||||
* - 한 글자 낱말뿐이라 본문을 훑지 않았으면(bodySkipped) 없다
|
||||
* - 묶음이 있는 낱말(두 글자 이상)이 하나라도 있으면(`anyGrams` — 다섯 칸의 낱말 어디든) 그 낱말만 — 한 글자 낱말은 함께 찾아 본문도 확인했지만
|
||||
* 본문 곳곳에 맞아 미리 보기 · 읽기 화면의 강조가 흩어진다
|
||||
* - 묶음이 하나도 없는데 본문을 훑었으면(SINGLE_CHAR_TERMS_SKIP_BODY 를 끈 때) 모두
|
||||
*/
|
||||
function bodyHighlightOf(
|
||||
raws: readonly string[],
|
||||
bodySkipped: boolean,
|
||||
anyGrams: boolean,
|
||||
): string[] {
|
||||
if (bodySkipped) return [];
|
||||
return anyGrams ? raws.filter(hasGrams) : [...raws];
|
||||
}
|
||||
|
||||
export function planSearch(criteria: SearchCriteria): SearchPlan {
|
||||
const query = termsOf(criteria.text);
|
||||
const conditions = CONDITION_NAMES.map((name) => ({
|
||||
name,
|
||||
...termsOf(criteria[name]),
|
||||
}));
|
||||
// 글을 준 칸에 찾을 낱말이 없다 — 아무것도 맞지 않는다
|
||||
let matchesNothing =
|
||||
(query.given && query.raw.length === 0) ||
|
||||
conditions.some(
|
||||
(condition) => condition.given && condition.raw.length === 0,
|
||||
);
|
||||
|
||||
const grams = new Set<string>();
|
||||
const pending: { term: string; fields: readonly FoldField[] }[] = [];
|
||||
const add = (raw: string, fields: readonly FoldField[]) => {
|
||||
const term = foldText(raw);
|
||||
gramsOf(term, grams);
|
||||
pending.push({ term, fields });
|
||||
};
|
||||
for (const raw of query.raw) add(raw, ALL_FOLD_FIELDS);
|
||||
for (const condition of conditions) {
|
||||
for (const raw of condition.raw)
|
||||
add(raw, [CONDITION_FIELDS[condition.name]]);
|
||||
}
|
||||
|
||||
// 한 글자 낱말뿐 — 본문은 훑지 않는다 (제목 · 사람 · 첨부 이름만)
|
||||
const bodySkipped =
|
||||
SINGLE_CHAR_TERMS_SKIP_BODY && pending.length > 0 && grams.size === 0;
|
||||
const seen = new Set<string>();
|
||||
const checks: TermCheck[] = [];
|
||||
for (const check of pending) {
|
||||
const fields = bodySkipped
|
||||
? check.fields.filter((field) => field !== 'fold_body')
|
||||
: check.fields;
|
||||
if (fields.length === 0) {
|
||||
matchesNothing = true;
|
||||
continue;
|
||||
}
|
||||
const key = `${check.term}\n${fields.join(',')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
checks.push({ term: check.term, fields });
|
||||
}
|
||||
|
||||
const exactByGrams =
|
||||
checks.length > 0 &&
|
||||
checks.every(
|
||||
(check) =>
|
||||
check.fields.length === ALL_FOLD_FIELDS.length &&
|
||||
isGramWord(check.term),
|
||||
);
|
||||
const subjectTerms = conditions.find((c) => c.name === 'subject')?.raw ?? [];
|
||||
const bodyTerms = conditions.find((c) => c.name === 'body')?.raw ?? [];
|
||||
return {
|
||||
checks,
|
||||
grams: [...grams],
|
||||
after: dateOf(criteria.after),
|
||||
before: dateOf(criteria.before),
|
||||
hasAttachment: criteria.hasAttachment === true,
|
||||
matchesNothing,
|
||||
bodySkipped,
|
||||
exactByGrams,
|
||||
highlight: {
|
||||
subject: [...query.raw, ...subjectTerms],
|
||||
body: bodyHighlightOf(
|
||||
[...query.raw, ...bodyTerms],
|
||||
bodySkipped,
|
||||
grams.size > 0,
|
||||
),
|
||||
attachment: [...query.raw],
|
||||
query: [...query.raw],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 강조할 말만 (읽기 화면 — 설계 C2): 검색어의 낱말은 제목 · 본문 · 첨부 이름에, 제목 조건은 제목에, 내용 조건은 본문에 — 목록과 같은 계획(planSearch)에서 꺼낸다.
|
||||
* 본문에는 본문을 찾은 낱말만(한 글자 낱말 — bodyHighlightOf). 보낸 사람 · 받는 사람 조건은 강조하지 않지만 받는다 — 두 글자 이상 낱말이
|
||||
* 그 칸에만 있어도 한 글자 낱말과 "섞인" 검색이다(목록의 찾기와 같은 판정). 강조할 말이 하나도 없으면 null
|
||||
*/
|
||||
export function highlightTermsOf(
|
||||
criteria: Pick<SearchCriteria, 'text' | 'from' | 'to' | 'subject' | 'body'>,
|
||||
): HighlightTerms | null {
|
||||
const { highlight } = planSearch({ ...criteria, hasAttachment: false });
|
||||
// 첨부 이름의 말(검색어)은 늘 제목의 말 안에 든다
|
||||
if (highlight.subject.length === 0 && highlight.body.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return highlight;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { SearchCriteria, SearchScope } from './mail-search.types';
|
||||
import { planSearch } from './search-plan';
|
||||
import {
|
||||
candidatesSql,
|
||||
checkHitsSql,
|
||||
highlightTextsSql,
|
||||
keysetCountSql,
|
||||
keysetFrontierSql,
|
||||
keysetHitsSql,
|
||||
plainCountSql,
|
||||
plainPageSql,
|
||||
} from './search-sql';
|
||||
|
||||
const USER = 'ara.kim@arasystem.test';
|
||||
const ALL: SearchScope = {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
const INBOX: SearchScope = {
|
||||
mailboxId: 'a',
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: [],
|
||||
};
|
||||
|
||||
const plan = (overrides: Partial<SearchCriteria>) =>
|
||||
planSearch({ hasAttachment: false, ...overrides });
|
||||
|
||||
/** SQL 의 공백을 하나로 (비교 편하게) */
|
||||
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
|
||||
|
||||
describe('색인 찾기의 SQL (search-sql — 14단계 설계 G)', () => {
|
||||
it('후보 — 주소(찾기 GIN 의 lower 식) · 빼는 메일함 · 빼는 키워드 · 묶음만으로 받은 시각 최신부터 limit 줄 (확인하지 않는다), 값은 모두 매개변수', () => {
|
||||
const query = candidatesSql(
|
||||
USER,
|
||||
ALL,
|
||||
plan({ text: '계약 갱신' }),
|
||||
null,
|
||||
3_001,
|
||||
);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE lower("user_email") = $1 ' +
|
||||
'AND NOT ("mailbox_ids" <@ $2::text[]) AND NOT ("keywords" && $3::text[]) AND "grams" @> $4::text[] ' +
|
||||
'ORDER BY "received_at" DESC, "email_id" DESC LIMIT $5',
|
||||
);
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['b', 'c'],
|
||||
['$ara-sending'],
|
||||
['계약', '갱신'],
|
||||
3_001,
|
||||
]);
|
||||
// 주소 그대로("user_email" = …)는 btree 로 그 직원의 줄을 다 읽게 둔다 — 후보 쿼리에는 쓰지 않는다
|
||||
expect(query.sql).not.toContain('"user_email" =');
|
||||
expect(query.sql).not.toContain('strpos');
|
||||
});
|
||||
|
||||
it('메일함 범위는 찾기 GIN 이 함께 맞추는 꼴(@>) — = ANY 로 적으면 그 직원의 묶음 줄을 모두 읽고 메일함을 줄마다 거른다 (2026-11-06 실사용 시험), 기간 · 첨부 · 이어 찾을 자리(받은 시각 · id 가 더 작은 쪽)', () => {
|
||||
const after = {
|
||||
receivedAt: new Date('2026-11-01T00:00:00Z'),
|
||||
emailId: 'm9',
|
||||
};
|
||||
const query = candidatesSql(
|
||||
USER,
|
||||
INBOX,
|
||||
plan({
|
||||
text: '계약',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
before: '2026-11-30T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
}),
|
||||
after,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(flat(query.sql)).toContain(
|
||||
'lower("user_email") = $1 AND "mailbox_ids" @> $2::text[] AND "received_at" >= $3::timestamptz AND "received_at" < $4::timestamptz ' +
|
||||
'AND "has_attachment" AND ("received_at", "email_id") < ($5::timestamptz, $6) AND "grams" @> $7::text[]',
|
||||
);
|
||||
expect(query.sql).not.toContain('ANY("mailbox_ids")');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['a'],
|
||||
new Date('2026-10-31T15:00:00.000Z'),
|
||||
new Date('2026-11-30T15:00:00.000Z'),
|
||||
after.receivedAt,
|
||||
'm9',
|
||||
['계약'],
|
||||
10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('확인 — 후보 id 의 줄만 기본 키로 읽어 낱말마다 그 칸들 가운데 하나의 strpos', () => {
|
||||
const query = checkHitsSql(USER, plan({ text: '계약', from: '이도윤' }), [
|
||||
'm1',
|
||||
'm2',
|
||||
]);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "email_id" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[]) ' +
|
||||
'AND (strpos("fold_from", $3) > 0 OR strpos("fold_to", $3) > 0 OR strpos("fold_subject", $3) > 0 ' +
|
||||
'OR strpos("fold_attachment", $3) > 0 OR strpos("fold_body", $3) > 0) AND (strpos("fold_from", $4) > 0)',
|
||||
);
|
||||
expect(query.params).toEqual([USER, ['m1', 'm2'], '계약', '이도윤']);
|
||||
});
|
||||
|
||||
it('묶음이 없는 찾기(한 글자 낱말뿐) — 안쪽에서 확인 상한만큼 받은 차례로 자른 뒤 바깥에서 확인하고 쪽 크기에서 멈춘다, 묶음은 보지 않는다', () => {
|
||||
const query = keysetHitsSql(
|
||||
USER,
|
||||
INBOX,
|
||||
plan({ text: '묵' }),
|
||||
null,
|
||||
3_000,
|
||||
21,
|
||||
);
|
||||
const sql = flat(query.sql);
|
||||
|
||||
expect(sql).toMatch(
|
||||
/^SELECT "email_id", "received_at" FROM \(SELECT "email_id", "received_at", "fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body" FROM "mail_search_messages" WHERE "user_email" = \$1 AND "mailbox_ids" @> \$2::text\[\] ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$3\) AS "scan" WHERE \(strpos\("fold_from", \$4\) > 0 .+\) ORDER BY "received_at" DESC, "email_id" DESC LIMIT \$5$/,
|
||||
);
|
||||
expect(sql).not.toContain('"grams"');
|
||||
// 본문은 훑지 않는다 (SINGLE_CHAR_TERMS_SKIP_BODY)
|
||||
expect(sql).not.toContain('strpos("fold_body"');
|
||||
expect(query.params.slice(2)).toEqual([3_000, '묵', 21]);
|
||||
});
|
||||
|
||||
it('훑은 창의 끝 · 창 안의 맞은 수 — 끝은 확인 없이 그 창의 가장 오래된 줄과 줄 수, 수는 countLimit 까지', () => {
|
||||
const frontier = keysetFrontierSql(
|
||||
USER,
|
||||
INBOX,
|
||||
plan({ text: '묵' }),
|
||||
null,
|
||||
3_000,
|
||||
);
|
||||
expect(flat(frontier.sql)).toMatch(
|
||||
/^SELECT "email_id", "received_at", count\(\*\) OVER \(\)::int AS "scanned" FROM \(SELECT "email_id", "received_at" FROM .+ LIMIT \$3\) AS "scan" ORDER BY "received_at" ASC, "email_id" ASC LIMIT 1$/,
|
||||
);
|
||||
expect(frontier.sql).not.toContain('strpos');
|
||||
|
||||
const count = keysetCountSql(
|
||||
USER,
|
||||
INBOX,
|
||||
plan({ text: '묵' }),
|
||||
3_000,
|
||||
1_001,
|
||||
);
|
||||
expect(flat(count.sql)).toMatch(
|
||||
/^SELECT count\(\*\)::int AS "count" FROM \(SELECT 1 FROM \(SELECT .+ LIMIT \$3\) AS "scan" WHERE .+strpos.+ LIMIT \$5\) AS "hits"$/,
|
||||
);
|
||||
expect(count.params.slice(2)).toEqual([3_000, '묵', 1_001]);
|
||||
});
|
||||
|
||||
it('낱말 확인이 없을 때(기간 · 첨부만) — 묶음 · strpos 없이 받은 차례로 · 수도 같은 조건으로', () => {
|
||||
const only = plan({ hasAttachment: true });
|
||||
const page = plainPageSql(USER, ALL, only, null, 21);
|
||||
expect(flat(page.sql)).toBe(
|
||||
'SELECT "email_id", "received_at" FROM "mail_search_messages" WHERE "user_email" = $1 AND NOT ("mailbox_ids" <@ $2::text[]) ' +
|
||||
'AND NOT ("keywords" && $3::text[]) AND "has_attachment" ORDER BY "received_at" DESC, "email_id" DESC LIMIT $4',
|
||||
);
|
||||
const count = plainCountSql(USER, ALL, only, 1_001);
|
||||
expect(flat(count.sql)).toContain(
|
||||
'AND "has_attachment" LIMIT $4) AS "matches"',
|
||||
);
|
||||
expect(count.params).toEqual([USER, ['b', 'c'], ['$ara-sending'], 1_001]);
|
||||
});
|
||||
|
||||
it('검색 글은 SQL 에 이어 붙이지 않는다 — 따옴표 · 세미콜론이 든 낱말도 매개변수로만', () => {
|
||||
const hostile = `x'); DROP TABLE users; --`;
|
||||
const checked = [
|
||||
checkHitsSql(USER, plan({ text: hostile }), ['m1']),
|
||||
keysetHitsSql(USER, ALL, plan({ subject: hostile }), null, 10, 10),
|
||||
];
|
||||
for (const query of [
|
||||
...checked,
|
||||
candidatesSql(USER, ALL, plan({ body: hostile }), null, 10),
|
||||
]) {
|
||||
expect(query.sql).not.toMatch(/drop|users|x'/i);
|
||||
}
|
||||
// 낱말은 접은 글로 매개변수에만 (앞뒤 문장부호를 뗀 x · drop · table · users)
|
||||
for (const query of checked) {
|
||||
expect(query.params).toEqual(
|
||||
expect.arrayContaining(['x', 'drop', 'table', 'users']),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('강조에 쓰는 글 — 그 직원의 찾은 줄만 (본문 밖의 접은 칸 넷도 — 미리 보기가 인용 안에서만 맞은 말을 다른 칸에서 찾았는지 본다)', () => {
|
||||
const query = highlightTextsSql(USER, ['m1', 'm2']);
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" ' +
|
||||
'FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])',
|
||||
);
|
||||
expect(query.params).toEqual([USER, ['m1', 'm2']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { SEARCH_MESSAGES_TABLE } from './mail-search.constants';
|
||||
import type { SearchScope } from './mail-search.types';
|
||||
import type { SearchPlan } from './search-plan';
|
||||
|
||||
/**
|
||||
* 색인 찾기의 SQL (순수 함수 — 14단계 설계 G). 값은 모두 매개변수다 — 글 · 주소를 SQL 에 이어 붙이지 않는다.
|
||||
*
|
||||
* 줄의 글(묶음 배열 · 접은 본문)은 TOAST 에 있어 줄마다 풀면 비싸다(0.1~0.3ms). 그래서 쿼리를 둘로 나눈다:
|
||||
* - 후보(candidatesSql): 주소 + 묶음 + 메일함을 GIN 하나(`IDX_mail_search_messages_user_grams_mailboxes` — 마이그레이션 SearchIndexUserGramsMailboxes)에서만
|
||||
* 거른다 — 주소를 `lower("user_email")` 식으로 적어(색인과 같은 식) 다른 색인을 쓸 수 없게 한다. 메일함 범위는 `"mailbox_ids" @> $n` 그대로 둔다 —
|
||||
* 같은 GIN 이 메일함까지 맞춰 맞은 줄만 읽는다(`= ANY` 로 적으면 그 직원의 그 묶음 줄을 모두 읽고 메일함을 줄마다 거른다).
|
||||
* 주소 그대로면 계획기가 흔한 묶음에서 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-05 측정 — 260~700ms → 10ms).
|
||||
* 메일함만 담은 GIN 이 있던 때는 계획기가 그것만으로 메일함의 줄을 모두 읽고 묶음을 줄마다 풀었다(2026-11-06 실사용 시험 — 1만 통 메일함 안 1.1초).
|
||||
* 줄에서는 작은 칸(받은 시각 · 메일함 · 키워드 · 첨부)만 읽는다
|
||||
* - 확인(checkHitsSql): 받은 후보 id 의 줄만 기본 키로 읽어 낱말을 strpos 로 본다
|
||||
* 묶음이 없는 찾기(한 글자 낱말뿐 — 본문을 훑지 않는다)는 받은 차례(keyset — btree)로 값싼 칸만 확인하고, 낱말 확인이 없는 찾기(기간 · 첨부만)는
|
||||
* 받은 차례로 바로 읽는다.
|
||||
*/
|
||||
|
||||
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
|
||||
/** 받은 시각 최신부터 (같은 시각은 id 로) — 색인 IDX_mail_search_messages_received 의 거꾸로 */
|
||||
const NEWEST_FIRST = '"received_at" DESC, "email_id" DESC';
|
||||
const OLDEST_FIRST = '"received_at" ASC, "email_id" ASC';
|
||||
/** 확인에 쓰는 접기 칸 — 안쪽 쿼리가 바깥으로 넘긴다 */
|
||||
const FOLD_COLUMNS =
|
||||
'"fold_from", "fold_to", "fold_subject", "fold_attachment", "fold_body"';
|
||||
|
||||
/** 이어 찾을 자리 — 이 줄 다음(받은 시각 · id 가 더 작은 쪽)부터 */
|
||||
export interface SearchPosition {
|
||||
readonly receivedAt: Date;
|
||||
readonly emailId: string;
|
||||
}
|
||||
|
||||
export interface SqlQuery {
|
||||
readonly sql: string;
|
||||
readonly params: unknown[];
|
||||
}
|
||||
|
||||
/** 매개변수를 차례로 쌓는다 */
|
||||
class Params {
|
||||
readonly values: unknown[] = [];
|
||||
|
||||
add(value: unknown): string {
|
||||
this.values.push(value);
|
||||
return `$${this.values.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원을 거르는 길 — `gin`: 찾기 GIN 의 식(lower)으로 묶음 · 메일함과 함께(후보), `btree`: 주소 그대로(받은 차례 · 기본 키 —
|
||||
* 메일함 조건은 줄을 읽은 뒤 거른다. 이 길의 쿼리는 bitmap 을 끈다)
|
||||
* 표의 주소는 늘 소문자라 두 식은 같은 줄을 고른다
|
||||
*/
|
||||
type UserMatch = 'gin' | 'btree';
|
||||
|
||||
/** 값싼 조건 — 직원 · 범위 · 기간 · 첨부 · 빼는 키워드 · 이어 찾을 자리 (`gin` 이면 묶음도) */
|
||||
function cheapConditions(
|
||||
params: Params,
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
userMatch: UserMatch,
|
||||
): string {
|
||||
const where = [
|
||||
userMatch === 'gin'
|
||||
? `lower("user_email") = ${params.add(user)}`
|
||||
: `"user_email" = ${params.add(user)}`,
|
||||
];
|
||||
if (scope.mailboxId !== null) {
|
||||
where.push(`"mailbox_ids" @> ${params.add([scope.mailboxId])}::text[]`);
|
||||
}
|
||||
if (scope.excludedMailboxIds.length > 0) {
|
||||
// 빼는 메일함에만 든 메일을 뺀다 — 다른 메일함에도 있으면 찾는다 (엔진의 inMailboxOtherThan 과 같다)
|
||||
where.push(
|
||||
`NOT ("mailbox_ids" <@ ${params.add([...scope.excludedMailboxIds])}::text[])`,
|
||||
);
|
||||
}
|
||||
if (scope.excludedKeywords.length > 0) {
|
||||
where.push(
|
||||
`NOT ("keywords" && ${params.add([...scope.excludedKeywords])}::text[])`,
|
||||
);
|
||||
}
|
||||
if (plan.after !== null) {
|
||||
where.push(`"received_at" >= ${params.add(plan.after)}::timestamptz`);
|
||||
}
|
||||
if (plan.before !== null) {
|
||||
where.push(`"received_at" < ${params.add(plan.before)}::timestamptz`);
|
||||
}
|
||||
if (plan.hasAttachment) where.push('"has_attachment"');
|
||||
if (after !== null) {
|
||||
where.push(
|
||||
`("received_at", "email_id") < (${params.add(after.receivedAt)}::timestamptz, ${params.add(after.emailId)})`,
|
||||
);
|
||||
}
|
||||
if (userMatch === 'gin') {
|
||||
where.push(`"grams" @> ${params.add([...plan.grams])}::text[]`);
|
||||
}
|
||||
return where.join(' AND ');
|
||||
}
|
||||
|
||||
/** 낱말 확인 — 낱말마다 그 칸들 가운데 하나에 들어 있어야 한다 (모두 AND, 확인할 낱말이 없으면 늘 참) */
|
||||
function checkConditions(params: Params, plan: SearchPlan): string {
|
||||
if (plan.checks.length === 0) return 'TRUE';
|
||||
return plan.checks
|
||||
.map((check) => {
|
||||
const term = params.add(check.term);
|
||||
return `(${check.fields.map((field) => `strpos("${field}", ${term}) > 0`).join(' OR ')})`;
|
||||
})
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
/** 후보 — 주소 + 묶음 + 메일함(GIN 하나)과 값싼 조건만으로 받은 시각 최신부터 `limit` 줄 (확인하지 않는다 — 묶음이 있는 계획만) */
|
||||
export function candidatesSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
limit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = cheapConditions(params, user, scope, plan, after, 'gin');
|
||||
return {
|
||||
sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(limit)}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 확인 — 후보 id 가운데 낱말이 모두 맞은 줄의 id (기본 키로 그 줄만 읽는다) */
|
||||
export function checkHitsSql(
|
||||
user: string,
|
||||
plan: SearchPlan,
|
||||
ids: readonly string[],
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = `"user_email" = ${params.add(user)} AND "email_id" = ANY(${params.add([...ids])}::text[])`;
|
||||
const checks = checkConditions(params, plan);
|
||||
return {
|
||||
sql: `SELECT "email_id" FROM ${TABLE} WHERE ${where} AND ${checks}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 차례 쿼리의 안쪽 — 값싼 조건을 지난 줄을 최신부터 `scanLimit` 까지 (묶음은 보지 않는다) */
|
||||
function scanSubquery(
|
||||
params: Params,
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
scanLimit: number,
|
||||
columns: string,
|
||||
): string {
|
||||
const where = cheapConditions(params, user, scope, plan, after, 'btree');
|
||||
return `SELECT ${columns} FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(scanLimit)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶음이 없는 찾기(한 글자 낱말뿐) — 받은 차례로 `scanLimit` 줄까지 확인하며 맞은 줄을 `pageLimit` 개까지 (차면 멈춘다).
|
||||
* 묶음 배열을 줄마다 풀지 않게 묶음이 있는 계획에는 쓰지 않는다(candidatesSql)
|
||||
*/
|
||||
export function keysetHitsSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
scanLimit: number,
|
||||
pageLimit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const scan = scanSubquery(
|
||||
params,
|
||||
user,
|
||||
scope,
|
||||
plan,
|
||||
after,
|
||||
scanLimit,
|
||||
`"email_id", "received_at", ${FOLD_COLUMNS}`,
|
||||
);
|
||||
const checks = checkConditions(params, plan);
|
||||
return {
|
||||
sql: `SELECT "email_id", "received_at" FROM (${scan}) AS "scan" WHERE ${checks} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 차례로 훑은 창의 끝 — 그 창의 마지막 줄과 줄 수 (확인하지 않는다 — 다 훑었는지 · 이어 찾을 자리) */
|
||||
export function keysetFrontierSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
scanLimit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const scan = scanSubquery(
|
||||
params,
|
||||
user,
|
||||
scope,
|
||||
plan,
|
||||
after,
|
||||
scanLimit,
|
||||
'"email_id", "received_at"',
|
||||
);
|
||||
return {
|
||||
sql: `SELECT "email_id", "received_at", count(*) OVER ()::int AS "scanned" FROM (${scan}) AS "scan" ORDER BY ${OLDEST_FIRST} LIMIT 1`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 차례로 훑은 창 안의 맞은 줄 수 — `countLimit` 까지 */
|
||||
export function keysetCountSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
scanLimit: number,
|
||||
countLimit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const scan = scanSubquery(
|
||||
params,
|
||||
user,
|
||||
scope,
|
||||
plan,
|
||||
null,
|
||||
scanLimit,
|
||||
`"email_id", "received_at", ${FOLD_COLUMNS}`,
|
||||
);
|
||||
const checks = checkConditions(params, plan);
|
||||
return {
|
||||
sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM (${scan}) AS "scan" WHERE ${checks} LIMIT ${params.add(countLimit)}) AS "hits"`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 낱말 확인이 없을 때(기간 · 첨부 · 메일함만) — 받은 차례로 `pageLimit` 개 */
|
||||
export function plainPageSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
after: SearchPosition | null,
|
||||
pageLimit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = cheapConditions(params, user, scope, plan, after, 'btree');
|
||||
return {
|
||||
sql: `SELECT "email_id", "received_at" FROM ${TABLE} WHERE ${where} ORDER BY ${NEWEST_FIRST} LIMIT ${params.add(pageLimit)}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 낱말 확인이 없을 때의 수 — `countLimit` 까지 */
|
||||
export function plainCountSql(
|
||||
user: string,
|
||||
scope: SearchScope,
|
||||
plan: SearchPlan,
|
||||
countLimit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = cheapConditions(params, user, scope, plan, null, 'btree');
|
||||
return {
|
||||
sql: `SELECT count(*)::int AS "count" FROM (SELECT 1 FROM ${TABLE} WHERE ${where} LIMIT ${params.add(countLimit)}) AS "matches"`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 강조에 쓰는 글 — 찾은 쪽의 줄만. 본문 밖의 접은 칸(보낸 사람 · 받는 사람과 참조 · 제목 · 첨부 이름)은 미리 보기를 고를 때
|
||||
* 본문의 인용 안에서만 맞은 말을 그 칸에서도 찾았는지 본다(search-highlight.ts)
|
||||
*/
|
||||
export function highlightTextsSql(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): SqlQuery {
|
||||
return {
|
||||
sql: `SELECT "email_id", "subject", "body_text", "preview", "fold_from", "fold_to", "fold_subject", "fold_attachment" FROM ${TABLE} WHERE "user_email" = $1 AND "email_id" = ANY($2::text[])`,
|
||||
params: [user, [...ids]],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { mergeThreadKeys } from './thread-keys';
|
||||
|
||||
describe('대화 키 합치기 (mergeThreadKeys — 14단계 설계 B4)', () => {
|
||||
it('이어진 두 키는 작은 키로 — 바뀌는 쪽만 돌려준다', () => {
|
||||
expect([...mergeThreadKeys([['m9', 'm2']])]).toEqual([['m9', 'm2']]);
|
||||
expect([...mergeThreadKeys([['m2', 'm9']])]).toEqual([['m9', 'm2']]);
|
||||
});
|
||||
|
||||
it('잇닿은 쌍은 하나로 — 대화 전체가 가장 작은 키 (늦게 온 원본이 두 답장을 잇는 꼴)', () => {
|
||||
const renames = mergeThreadKeys([
|
||||
['reply-b', 'orig-z'],
|
||||
['reply-a', 'orig-z'],
|
||||
['reply-c', 'reply-b'],
|
||||
]);
|
||||
// 키는 글자 차례로 견준다 — 'orig-z' < 'reply-a'
|
||||
expect(Object.fromEntries(renames)).toEqual({
|
||||
'reply-a': 'orig-z',
|
||||
'reply-b': 'orig-z',
|
||||
'reply-c': 'orig-z',
|
||||
});
|
||||
});
|
||||
|
||||
it('따로인 대화는 따로 — 쌍의 차례와 상관없이 같은 결과', () => {
|
||||
const links: [string, string][] = [
|
||||
['b', 'a'],
|
||||
['y', 'x'],
|
||||
['c', 'b'],
|
||||
['z', 'y'],
|
||||
];
|
||||
const expected = { b: 'a', c: 'a', y: 'x', z: 'x' };
|
||||
expect(Object.fromEntries(mergeThreadKeys(links))).toEqual(expected);
|
||||
expect(Object.fromEntries(mergeThreadKeys([...links].reverse()))).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 키끼리 · 빈 쌍은 바꿀 것이 없다', () => {
|
||||
expect(mergeThreadKeys([])).toEqual(new Map());
|
||||
expect(mergeThreadKeys([['a', 'a']])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('긴 사슬도 쌍 수에 비례한다 (CPU 시간)', () => {
|
||||
const links = Array.from(
|
||||
{ length: 50_000 },
|
||||
(_, index) =>
|
||||
[
|
||||
`k${String(index + 1).padStart(6, '0')}`,
|
||||
`k${String(index).padStart(6, '0')}`,
|
||||
] as const,
|
||||
);
|
||||
const cpuMs = startCpuTimer();
|
||||
const renames = mergeThreadKeys(links);
|
||||
// 지금 수십 ms — 부하를 견디게 10배 넘게
|
||||
expect(cpuMs()).toBeLessThan(1_000);
|
||||
expect(renames.size).toBe(50_000);
|
||||
expect(new Set(renames.values())).toEqual(new Set(['k000000']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 대화 키 합치기 (순수 함수 — 14단계 설계 B4).
|
||||
* 색인 줄마다 대화 키(thread_key)가 있다 — 처음에는 그 메일의 id 다. 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면
|
||||
* 두 키의 줄을 모두 **가장 작은 키**로 바꾼다. 이미 합친 대화는 한 키를 나눠 쓰므로, 새로 들어온 메일과 바로 이어진 메일의 키만 합치면
|
||||
* 대화 전체가 한 키가 된다(늦게 온 원본이 둘로 갈려 있던 답장들을 잇는 것도 같다).
|
||||
*
|
||||
* `links` 는 [키, 키] 쌍들 — 이어진 두 메일의 지금 키. 돌려주는 것은 바꿀 키 → 새 키(가장 작은 키)이다. 바꿀 것이 없으면 빈 Map.
|
||||
* 쌍 수에 거의 비례한다(경로 줄이기 · 크기로 잇기).
|
||||
*/
|
||||
export function mergeThreadKeys(
|
||||
links: readonly (readonly [string, string])[],
|
||||
): Map<string, string> {
|
||||
const parent = new Map<string, string>();
|
||||
const find = (key: string): string => {
|
||||
let root = key;
|
||||
for (;;) {
|
||||
const up = parent.get(root);
|
||||
if (up === undefined || up === root) break;
|
||||
root = up;
|
||||
}
|
||||
// 지나온 키를 뿌리에 바로 잇는다 (다음 찾기가 짧게)
|
||||
let node = key;
|
||||
while (node !== root) {
|
||||
const up = parent.get(node) as string;
|
||||
parent.set(node, root);
|
||||
node = up;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
for (const [a, b] of links) {
|
||||
if (!parent.has(a)) parent.set(a, a);
|
||||
if (!parent.has(b)) parent.set(b, b);
|
||||
const rootA = find(a);
|
||||
const rootB = find(b);
|
||||
if (rootA === rootB) continue;
|
||||
// 가장 작은 키가 뿌리가 되게 — 합친 대화의 키는 늘 그 대화의 가장 작은 키다
|
||||
if (rootA < rootB) parent.set(rootB, rootA);
|
||||
else parent.set(rootA, rootB);
|
||||
}
|
||||
const renames = new Map<string, string>();
|
||||
for (const key of parent.keys()) {
|
||||
const root = find(key);
|
||||
if (root !== key) renames.set(key, root);
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
@@ -46,4 +46,15 @@ describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', (
|
||||
'whitelistValidation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('대화로 묶어 보기(14단계)는 참 · 거짓만 — 글로 쓴 값 · 숫자 · null 은 형식 오류', async () => {
|
||||
for (const groupByConversation of [true, false]) {
|
||||
expect(await constraintsOf({ groupByConversation })).toEqual([]);
|
||||
}
|
||||
for (const groupByConversation of ['true', 1, 0, null, 'on']) {
|
||||
expect(await constraintsOf({ groupByConversation })).toEqual([
|
||||
'isBoolean',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, ValidateIf } from 'class-validator';
|
||||
import { IsBoolean, IsIn, ValidateIf } from 'class-validator';
|
||||
|
||||
import {
|
||||
EXTERNAL_IMAGE_MODES,
|
||||
@@ -16,6 +16,8 @@ const whenSent = (_object: object, value: unknown) => value !== undefined;
|
||||
const UNDO_DESCRIPTION =
|
||||
`보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` +
|
||||
'예약 발송에는 더하지 않는다';
|
||||
const GROUP_DESCRIPTION =
|
||||
'목록을 대화로 묶어 보기(14단계) — true 면 화면이 메일함 목록을 GET /api/conversations 로 받는다. 처음 false. 검색 결과는 묶지 않는다';
|
||||
const IMAGES_DESCRIPTION =
|
||||
'외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' +
|
||||
'스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)';
|
||||
@@ -35,6 +37,9 @@ export class MailUserSettingsDto implements MailUserSettings {
|
||||
example: 'ask',
|
||||
})
|
||||
externalImages!: ExternalImageMode;
|
||||
|
||||
@ApiProperty({ description: GROUP_DESCRIPTION, example: false })
|
||||
groupByConversation!: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,4 +68,9 @@ export class MailUserSettingsPatchDto implements MailUserSettingsPatch {
|
||||
message: '외부 이미지 설정을 다시 골라 주세요.',
|
||||
})
|
||||
externalImages?: ExternalImageMode;
|
||||
|
||||
@ApiPropertyOptional({ description: GROUP_DESCRIPTION, example: true })
|
||||
@ValidateIf(whenSent)
|
||||
@IsBoolean({ message: '대화로 묶어 보기를 다시 골라 주세요.' })
|
||||
groupByConversation?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class MailUserSettingsController {
|
||||
@ApiOperation({
|
||||
summary: '보내기 · 읽기 설정',
|
||||
description:
|
||||
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.',
|
||||
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask", groupByConversation: false } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다. groupByConversation 은 화면만 본다(14단계).',
|
||||
})
|
||||
@ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
@@ -49,7 +49,7 @@ export class MailUserSettingsController {
|
||||
@ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드',
|
||||
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, 대화로 묶기가 true · false 아님, null) · 모르는 필드',
|
||||
)
|
||||
update(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@@ -58,6 +58,7 @@ export class MailUserSettingsController {
|
||||
return this.settings.update(user.email, {
|
||||
undoSendSeconds: dto.undoSendSeconds,
|
||||
externalImages: dto.externalImages,
|
||||
groupByConversation: dto.groupByConversation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
const COLUMNS: Readonly<Record<keyof MailUserSettings, string>> = {
|
||||
undoSendSeconds: 'undo_send_seconds',
|
||||
externalImages: 'external_images',
|
||||
groupByConversation: 'group_by_conversation',
|
||||
};
|
||||
|
||||
/** RETURNING 한 줄 (열 이름이 키다) */
|
||||
@@ -19,6 +20,7 @@ interface ReturnedRow {
|
||||
readonly user_email: string;
|
||||
readonly undo_send_seconds: number;
|
||||
readonly external_images: string;
|
||||
readonly group_by_conversation: boolean;
|
||||
readonly updated_at: Date;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,8 @@ export class MailUserSettingsRepository {
|
||||
userEmail,
|
||||
undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds,
|
||||
externalImages: patch.externalImages ?? base.externalImages,
|
||||
groupByConversation:
|
||||
patch.groupByConversation ?? base.groupByConversation,
|
||||
updatedAt: now,
|
||||
})
|
||||
.orUpdate([...changed, 'updated_at'], ['user_email'])
|
||||
@@ -66,6 +70,7 @@ export class MailUserSettingsRepository {
|
||||
'userEmail',
|
||||
'undoSendSeconds',
|
||||
'externalImages',
|
||||
'groupByConversation',
|
||||
'updatedAt',
|
||||
])
|
||||
.execute();
|
||||
@@ -74,6 +79,7 @@ export class MailUserSettingsRepository {
|
||||
saved.userEmail = row?.user_email ?? userEmail;
|
||||
saved.undoSendSeconds = Number(row?.undo_send_seconds);
|
||||
saved.externalImages = row?.external_images ?? '';
|
||||
saved.groupByConversation = row?.group_by_conversation === true;
|
||||
saved.updatedAt = row?.updated_at ?? now;
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ function row(
|
||||
userEmail: string,
|
||||
undoSendSeconds: number,
|
||||
externalImages: string,
|
||||
groupByConversation = false,
|
||||
): MailUserSettingsEntity {
|
||||
const entity = new MailUserSettingsEntity();
|
||||
entity.userEmail = userEmail;
|
||||
entity.undoSendSeconds = undoSendSeconds;
|
||||
entity.externalImages = externalImages;
|
||||
entity.groupByConversation = groupByConversation;
|
||||
entity.updatedAt = new Date('2026-10-29T00:00:00.000Z');
|
||||
return entity;
|
||||
}
|
||||
@@ -48,6 +50,9 @@ function fakeRepository() {
|
||||
current?.undoSendSeconds ??
|
||||
base.undoSendSeconds,
|
||||
patch.externalImages ?? current?.externalImages ?? base.externalImages,
|
||||
patch.groupByConversation ??
|
||||
current?.groupByConversation ??
|
||||
base.groupByConversation,
|
||||
);
|
||||
rows.set(userEmail, saved);
|
||||
return Promise.resolve(saved);
|
||||
@@ -94,10 +99,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -111,10 +118,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(service.get('b@arasystem.test')).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,7 +150,11 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
|
||||
await expect(
|
||||
service.update(ARA, { externalImages: 'always' }),
|
||||
).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' });
|
||||
).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.upsert).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
{ externalImages: 'always' },
|
||||
@@ -151,6 +164,7 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -190,10 +204,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(reading).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -215,4 +231,41 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await service.get('first@arasystem.test');
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('대화로 묶어 보기(14단계) — 처음 끔, 보낸 칸만 바꾸고 다른 칸은 그대로 · 로그에 켬/끔', async () => {
|
||||
const fake = fakeRepository();
|
||||
fake.rows.set(ARA, row(ARA, 20, 'trusted'));
|
||||
const service = new MailUserSettingsService(fake.repository);
|
||||
|
||||
await expect(service.get(ARA)).resolves.toMatchObject({
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(
|
||||
service.update(ARA, { groupByConversation: true }),
|
||||
).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'trusted',
|
||||
groupByConversation: true,
|
||||
});
|
||||
expect(fake.upsert).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
{ groupByConversation: true },
|
||||
DEFAULT_MAIL_USER_SETTINGS,
|
||||
expect.any(Date),
|
||||
);
|
||||
const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n');
|
||||
expect(lines).toContain('대화로 묶기 켬');
|
||||
});
|
||||
|
||||
it('표의 대화로 묶기 값이 참 · 거짓이 아니면(손으로 고친 값) 처음 값(끔)으로 읽는다', async () => {
|
||||
const fake = fakeRepository();
|
||||
const odd = row(ARA, 20, 'trusted');
|
||||
(odd as unknown as { groupByConversation: unknown }).groupByConversation =
|
||||
'yes';
|
||||
fake.rows.set(ARA, odd);
|
||||
const service = new MailUserSettingsService(fake.repository);
|
||||
await expect(service.get(ARA)).resolves.toMatchObject({
|
||||
groupByConversation: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,11 +39,16 @@ function toSettings(row: MailUserSettingsEntity | null): MailUserSettings {
|
||||
externalImages: isExternalImageMode(row.externalImages)
|
||||
? row.externalImages
|
||||
: DEFAULT_MAIL_USER_SETTINGS.externalImages,
|
||||
groupByConversation:
|
||||
typeof row.groupByConversation === 'boolean'
|
||||
? row.groupByConversation
|
||||
: DEFAULT_MAIL_USER_SETTINGS.groupByConversation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
|
||||
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계 · 14단계의 대화로 묶기) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
|
||||
* 대화로 묶기는 화면만 본다(서버는 GET /api/conversations 를 부르면 묶는다 — 설정과 상관없이).
|
||||
* - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다
|
||||
* - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다
|
||||
*
|
||||
@@ -83,7 +88,8 @@ export class MailUserSettingsService {
|
||||
): Promise<MailUserSettings> {
|
||||
if (
|
||||
patch.undoSendSeconds === undefined &&
|
||||
patch.externalImages === undefined
|
||||
patch.externalImages === undefined &&
|
||||
patch.groupByConversation === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-mail-settings-patch' },
|
||||
@@ -99,7 +105,7 @@ export class MailUserSettingsService {
|
||||
);
|
||||
this.remember(cacheKey(userEmail), saved);
|
||||
this.logger.log(
|
||||
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`,
|
||||
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}, 대화로 묶기 ${saved.groupByConversation ? '켬' : '끔'}`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -17,15 +17,18 @@ export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number];
|
||||
export interface MailUserSettings {
|
||||
readonly undoSendSeconds: UndoSendSeconds;
|
||||
readonly externalImages: ExternalImageMode;
|
||||
/** 목록을 대화로 묶어 보기 (14단계 — 처음 끔: 전환 직후 보던 목록을 바꾸지 않는다) */
|
||||
readonly groupByConversation: boolean;
|
||||
}
|
||||
|
||||
/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */
|
||||
export type MailUserSettingsPatch = Partial<MailUserSettings>;
|
||||
|
||||
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */
|
||||
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기 · 대화로 묶지 않음) */
|
||||
export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
|
||||
/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
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 { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import {
|
||||
ConversationListQueryDto,
|
||||
ConversationQueryDto,
|
||||
} from './dto/conversation-query.dto';
|
||||
import {
|
||||
ConversationListPageDto,
|
||||
ConversationPageDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MESSAGES_ROUTE } from './mail.constants';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
|
||||
/**
|
||||
* 대화 (14단계 — 계약 2절 "같은 대화" · "대화로 묶어 보기"). 같은 대화는 메일 자원 아래(`/api/messages/{id}/conversation`),
|
||||
* 묶은 목록은 따로(`/api/conversations`) — 검색은 묶지 않는다
|
||||
*/
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller()
|
||||
export class ConversationController {
|
||||
constructor(private readonly conversations: MailConversationService) {}
|
||||
|
||||
@Get(`${MESSAGES_ROUTE}/:id/conversation`)
|
||||
@ApiOperation({
|
||||
summary: '같은 대화 — 그 메일과 답장 · 전달로 이어진 메일 (오래된 차례)',
|
||||
description:
|
||||
'보낸편지함을 포함한 모든 메일함에서 — 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나다. ' +
|
||||
'기준(basis): 그 직원의 색인이 준비됐으면 index — 답장 관계(References · In-Reply-To)가 있고 말머리(Re · Fw · 회신 · 답장 · 전달 · [꼬리표] — 여러 겹)를 뗀 제목이 같은 메일, ' +
|
||||
'준비 중이면 engine — 엔진의 스레드. 쪽을 넘기는 중에는 커서의 기준대로 이어 간다. ' +
|
||||
`limit 은 처음 값이자 최대가 ${CONVERSATION_PAGE_LIMIT} 다. 항목은 목록과 같은 요약(기다리는 발송 pendingSend 포함)이다.`,
|
||||
})
|
||||
@ApiStandardResponse(ConversationPageDto, { description: '같은 대화' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`limit 범위(1~${CONVERSATION_PAGE_LIMIT}) 밖 · 다른 대화의 커서 · 모르는 쿼리`,
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일 (남의 메일 포함)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
conversation(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Query() query: ConversationQueryDto,
|
||||
) {
|
||||
return this.conversations.conversation(user, id, query);
|
||||
}
|
||||
|
||||
@Get('conversations')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'대화로 묶은 목록 — 그 메일함의 대화 하나가 한 줄 (마지막 받은 시각 최신부터)',
|
||||
description:
|
||||
'그 메일함 안의 메일을 대화로 묶는다 — 줄마다 그 메일함 안 마지막 메일(latest) · 보낸 사람들 · 대화 전체의 통 수 · 그 메일함 안 메일 id(여러 통 처리에) · 읽음 · 중요 · 첨부. ' +
|
||||
'기준(basis)은 같은 대화와 같다(색인 준비 전에는 엔진의 스레드 — collapseThreads). 발송 중인 내부 초안은 빠지고, 붙잡힌 메일(보내기 취소 · 예약 — pendingSend)은 목록처럼 보인다. ' +
|
||||
'검색은 묶지 않는다(GET /api/messages).',
|
||||
})
|
||||
@ApiStandardResponse(ConversationListPageDto, {
|
||||
description: '대화로 묶은 목록',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'mailboxId 없음 · 형식이 틀림 · limit 범위 밖 · 다른 메일함의 커서 · 모르는 쿼리',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일함')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: ConversationListQueryDto,
|
||||
) {
|
||||
return this.conversations.list(user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeConversationCursor } from '@/modules/mail-search/conversation-cursor';
|
||||
import type { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
|
||||
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import { encodeCursor } from './jmap/jmap-cursor';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MessageSummary } from './mail.types';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
const SCOPE = {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
function summary(id: string, mailboxIds = ['a']): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds,
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-11-02T01:00:00.000Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
pendingSend: null,
|
||||
};
|
||||
}
|
||||
|
||||
const forged = (payload: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
|
||||
function setup(ready = true) {
|
||||
const summaries = (ids: readonly string[]) =>
|
||||
ids
|
||||
.filter((id) => id !== 'gone')
|
||||
.map((id) => summary(id, id === 'moved' ? ['e'] : ['a']));
|
||||
const provider = {
|
||||
conversationScope: jest.fn().mockResolvedValue(SCOPE),
|
||||
getSummaries: jest.fn((_user: string, ids: readonly string[]) =>
|
||||
Promise.resolve(summaries(ids)),
|
||||
),
|
||||
getSummariesWithHeld: jest.fn((_user: string, ids: readonly string[]) =>
|
||||
Promise.resolve({
|
||||
summaries: summaries(ids),
|
||||
heldIds: new Set(['m1', 'elsewhere']),
|
||||
}),
|
||||
),
|
||||
getConversation: jest.fn().mockResolvedValue({
|
||||
items: [summary('e1')],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
}),
|
||||
listConversations: jest.fn().mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
}),
|
||||
};
|
||||
const searchIndex = { isSearchReady: jest.fn().mockResolvedValue(ready) };
|
||||
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
|
||||
const conversations = {
|
||||
conversation: jest.fn().mockResolvedValue({
|
||||
ids: ['m1', 'm2', 'gone'],
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 3,
|
||||
}),
|
||||
mailboxConversations: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
key: 'k1',
|
||||
latestId: 'm2',
|
||||
mailboxMessageIds: ['m2', 'm1'],
|
||||
messageCount: 4,
|
||||
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
latestId: 'moved',
|
||||
mailboxMessageIds: ['moved'],
|
||||
messageCount: 1,
|
||||
participants: [],
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
},
|
||||
],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
}),
|
||||
};
|
||||
const service = new MailConversationService(
|
||||
provider as unknown as MailProvider,
|
||||
searchIndex as unknown as MailSearchIndexService,
|
||||
searchSync as unknown as MailSearchSyncService,
|
||||
conversations as unknown as MailConversationQueryService,
|
||||
);
|
||||
return { service, provider, searchIndex, searchSync, conversations };
|
||||
}
|
||||
|
||||
describe('MailConversationService (같은 대화 · 묶은 목록 — 14단계 C3 · C4)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
});
|
||||
afterEach(() => warnSpy.mockRestore());
|
||||
|
||||
it('색인이 준비됐으면 색인 — 바로 전 맞추기 → 범위 → 색인의 쪽(그 메일의 키와 한 문장) → 요약은 엔진에서(없는 메일은 뺀다), basis index', async () => {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
|
||||
const result = await service.conversation(user, 'm2', { limit: 100 });
|
||||
|
||||
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
|
||||
expect(provider.conversationScope).toHaveBeenCalledWith(user.email);
|
||||
expect(conversations.conversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
SCOPE,
|
||||
{ limit: 100, after: null },
|
||||
);
|
||||
expect(result.items.map((item) => item.id)).toEqual(['m1', 'm2']);
|
||||
expect(result.pageInfo).toEqual({
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 3,
|
||||
});
|
||||
expect(result.basis).toBe('index');
|
||||
expect(provider.getConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('색인이 준비 중 · 그 메일이 색인에 아직 없으면 엔진의 스레드 — basis engine', async () => {
|
||||
const notReady = setup(false);
|
||||
const engine = await notReady.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(engine.basis).toBe('engine');
|
||||
expect(notReady.provider.getConversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
{ limit: 100, cursor: undefined },
|
||||
);
|
||||
|
||||
const notIndexed = setup();
|
||||
notIndexed.conversations.conversation.mockResolvedValue(null);
|
||||
const fallback = await notIndexed.service.conversation(user, 'new', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(fallback.basis).toBe('engine');
|
||||
expect(notIndexed.provider.getSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 입구에서 읽어 자리로 넘기고, 그 메일이 색인에서 사라졌으면 404 RES_001', async () => {
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const indexCursor = encodeConversationCursor(
|
||||
'thread',
|
||||
{ at, key: 'm1' },
|
||||
'm2',
|
||||
);
|
||||
const notReady = setup(false);
|
||||
await notReady.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: indexCursor,
|
||||
});
|
||||
expect(notReady.conversations.conversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
SCOPE,
|
||||
{ limit: 100, after: { at, key: 'm1' } },
|
||||
);
|
||||
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
|
||||
const ready = setup();
|
||||
await ready.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: encodeCursor(
|
||||
{ anchor: 'm1', position: 1 },
|
||||
{ conditions: ['conversation', 'm2'] },
|
||||
),
|
||||
});
|
||||
expect(ready.provider.getConversation).toHaveBeenCalled();
|
||||
expect(ready.conversations.conversation).not.toHaveBeenCalled();
|
||||
|
||||
const vanished = setup();
|
||||
vanished.conversations.conversation.mockResolvedValue(null);
|
||||
await expect(
|
||||
vanished.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: indexCursor,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
});
|
||||
|
||||
it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음 · 다른 메일의 커서)는 맞추기 · 엔진 · DB 에 묻기 전에 400 VAL_001 — 두 경로 모두 (리뷰 L1)', async () => {
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const threadCursors = [
|
||||
forged({ m: 'ti' }),
|
||||
forged({ v: 1, m: 'ti', t: 'x', k: 'm1', s: 'zz' }),
|
||||
encodeConversationCursor('thread', { at, key: 'm1' }, 'other'),
|
||||
];
|
||||
for (const cursor of threadCursors) {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
await expect(
|
||||
service.conversation(user, 'm2', { limit: 100, cursor }),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
|
||||
expect(provider.conversationScope).not.toHaveBeenCalled();
|
||||
expect(conversations.conversation).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
const listCursors = [
|
||||
forged({ m: 'li' }),
|
||||
forged({ v: 1, m: 'li', t: -5, k: 'k1', s: 'zz' }),
|
||||
encodeConversationCursor('list', { at, key: 'k1' }, 'sent'),
|
||||
];
|
||||
for (const cursor of listCursors) {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
await expect(
|
||||
service.list(user, { mailboxId: 'a', limit: 20, cursor }),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
|
||||
expect(provider.conversationScope).not.toHaveBeenCalled();
|
||||
expect(conversations.mailboxConversations).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('첫 쪽의 색인 읽기가 DB 장애면 엔진으로 — 코드가 있는 실패(RES_001 · MAIL_001)는 그대로', async () => {
|
||||
const broken = setup();
|
||||
broken.conversations.conversation.mockRejectedValue(new Error('db down'));
|
||||
const result = await broken.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(result.basis).toBe('engine');
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = warnSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('[SYS_001]');
|
||||
expect(line).not.toContain(user.email);
|
||||
|
||||
const coded = setup();
|
||||
coded.provider.conversationScope.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(
|
||||
coded.service.conversation(user, 'm2', { limit: 100 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
expect(coded.provider.getConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('묶은 목록 — 색인이면 그 메일함의 범위(있는지 본다) → 색인의 쪽 → 마지막 메일의 요약과 붙잡힌 메일을 한 번에, 줄의 pendingSendIds · 그 메일함을 떠난 대화는 뺀다 (리뷰 L6)', async () => {
|
||||
const { service, provider, conversations } = setup();
|
||||
|
||||
const result = await service.list(user, { mailboxId: 'a', limit: 20 });
|
||||
|
||||
expect(provider.conversationScope).toHaveBeenCalledWith(user.email, 'a');
|
||||
expect(conversations.mailboxConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
SCOPE,
|
||||
{ limit: 20, after: null },
|
||||
);
|
||||
expect(provider.getSummariesWithHeld).toHaveBeenCalledTimes(1);
|
||||
expect(provider.getSummariesWithHeld).toHaveBeenCalledWith(user.email, [
|
||||
'm2',
|
||||
'moved',
|
||||
]);
|
||||
expect(provider.getSummaries).not.toHaveBeenCalled();
|
||||
expect(result.basis).toBe('index');
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
id: 'k1',
|
||||
latest: summary('m2'),
|
||||
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
messageCount: 4,
|
||||
messageIds: ['m2', 'm1'],
|
||||
pendingSendIds: ['m1'],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
hasAttachment: false,
|
||||
},
|
||||
]);
|
||||
expect(result.pageInfo.total).toBe(2);
|
||||
|
||||
// 빈 쪽이면 엔진에 요약 · 제출을 묻지 않는다
|
||||
const empty = setup();
|
||||
empty.conversations.mailboxConversations.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
const none = await empty.service.list(user, { mailboxId: 'a', limit: 20 });
|
||||
expect(none.items).toEqual([]);
|
||||
expect(empty.provider.getSummariesWithHeld).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('묶은 목록 — 준비 중이면 엔진(collapseThreads), 커서의 방식은 이어 간다', async () => {
|
||||
const notReady = setup(false);
|
||||
const engine = await notReady.service.list(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
});
|
||||
expect(engine.basis).toBe('engine');
|
||||
expect(notReady.provider.listConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
{ mailboxId: 'a', limit: 20, cursor: undefined },
|
||||
);
|
||||
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const indexCursor = encodeConversationCursor(
|
||||
'list',
|
||||
{ at, key: 'k9' },
|
||||
'a',
|
||||
);
|
||||
const resumed = setup(false);
|
||||
const next = await resumed.service.list(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
cursor: indexCursor,
|
||||
});
|
||||
expect(next.basis).toBe('index');
|
||||
expect(resumed.conversations.mailboxConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
SCOPE,
|
||||
{ limit: 20, after: { at, key: 'k9' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
conversationCursorMethod,
|
||||
decodeConversationCursor,
|
||||
type ConversationPosition,
|
||||
} from '@/modules/mail-search/conversation-cursor';
|
||||
import { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
|
||||
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import type { ConversationBasis } from '@/modules/mail-search/mail-search.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type {
|
||||
ConversationListQueryDto,
|
||||
ConversationQueryDto,
|
||||
} from './dto/conversation-query.dto';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import type { ConversationSummary, MessageSummary } from './mail.types';
|
||||
|
||||
/** 대화 응답 — 목록 모양에 기준(basis)을 더한다 */
|
||||
export type ConversationResult<T> = PaginatedResult<T> & {
|
||||
readonly basis: ConversationBasis;
|
||||
};
|
||||
|
||||
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단계 설계 C3 · C4 — 디자인 README "같은 대화" · "대화로 묶어 보기").
|
||||
* - 기준: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 준비가 끝나거나 다시 채우기에 들어가도 처음 방식대로), 없으면 그 직원의 색인이 준비됐으면(ready · 지금 판)
|
||||
* 우리 색인(대화 키 — 답장 관계 + 말머리를 뗀 제목), 아니면 엔진의 스레드. 응답에 기준(basis)을 싣는다.
|
||||
* 색인 커서는 입구에서 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 맞추기 · 엔진 · DB 에 묻기 전에 VAL_001 (3차 리뷰 L1)
|
||||
* - 색인 방식: 바로 전 맞추기(1.5초 안) → 엔진에 범위(휴지통 · 스팸메일함 · 내부 초안 키워드 — 메일함이 있는지) → 색인 → 보일 메일의 요약은 엔진에서(목록과 같은 모양 · 기다리는 발송)
|
||||
* 엔진에 없는 메일(그사이 지움)과 그 메일함을 떠난 마지막 메일의 대화는 뺀다. 첫 쪽의 색인 읽기가 DB 장애면 엔진 방식으로 답한다
|
||||
* - 같은 대화: 그 메일이 색인에 없으면(방금 와 아직 넣지 않음) 엔진 방식 — 엔진에도 없으면 RES_001. 색인 커서로 이어 가는데 없으면 RES_001
|
||||
* - 묶은 목록의 붙잡힌 메일(pendingSendIds): 마지막 메일의 요약을 읽는 요청에서 계정의 기다리는 제출을 한 번 읽어 줄의 messageIds 와 맞춘다(getSummariesWithHeld)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailConversationService {
|
||||
private readonly logger = new Logger(MailConversationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
private readonly searchIndex: MailSearchIndexService,
|
||||
private readonly searchSync: MailSearchSyncService,
|
||||
private readonly conversations: MailConversationQueryService,
|
||||
) {}
|
||||
|
||||
/** GET /api/messages/{id}/conversation */
|
||||
async conversation(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
query: ConversationQueryDto,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
if (query.cursor) {
|
||||
if (conversationCursorMethod(query.cursor, 'thread') === 'index') {
|
||||
const after = decodeConversationCursor('thread', query.cursor, emailId);
|
||||
return this.conversationFromIndex(user, emailId, query.limit, after);
|
||||
}
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
if (!(await this.searchIndex.isSearchReady(user.email))) {
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
try {
|
||||
return await this.conversationFromIndex(user, emailId, query.limit, null);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) throw error;
|
||||
this.warnFallback(user, error);
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/conversations */
|
||||
async list(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
if (query.cursor) {
|
||||
if (conversationCursorMethod(query.cursor, 'list') === 'index') {
|
||||
const after = decodeConversationCursor(
|
||||
'list',
|
||||
query.cursor,
|
||||
query.mailboxId,
|
||||
);
|
||||
return this.listFromIndex(user, query, after);
|
||||
}
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
if (!(await this.searchIndex.isSearchReady(user.email))) {
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
try {
|
||||
return await this.listFromIndex(user, query, null);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) throw error;
|
||||
this.warnFallback(user, error);
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
}
|
||||
|
||||
private async conversationFromEngine(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
query: ConversationQueryDto,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
const page = await this.provider.getConversation(user.email, emailId, {
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'engine',
|
||||
};
|
||||
}
|
||||
|
||||
private async conversationFromIndex(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
limit: number,
|
||||
after: ConversationPosition | null,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
await this.searchSync.syncBeforeRead(user.email);
|
||||
const scope = await this.provider.conversationScope(user.email);
|
||||
const page = await this.conversations.conversation(
|
||||
user.email,
|
||||
emailId,
|
||||
scope,
|
||||
{ limit, after },
|
||||
);
|
||||
if (page === null) {
|
||||
// 색인에 없다 — 첫 쪽이면 엔진의 스레드로(방금 온 메일), 이어 가는 쪽이면 그 메일이 그사이 사라졌다
|
||||
if (after !== null) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'conversation-message-not-indexed' },
|
||||
});
|
||||
}
|
||||
return this.conversationFromEngine(user, emailId, { limit });
|
||||
}
|
||||
const summaries =
|
||||
page.ids.length > 0
|
||||
? await this.provider.getSummaries(user.email, page.ids)
|
||||
: [];
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const items = page.ids.flatMap((id) => {
|
||||
const summary = byId.get(id);
|
||||
return summary ? [summary] : [];
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'index',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFromEngine(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
const page = await this.provider.listConversations(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'engine',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFromIndex(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
after: ConversationPosition | null,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
await this.searchSync.syncBeforeRead(user.email);
|
||||
const scope = await this.provider.conversationScope(
|
||||
user.email,
|
||||
query.mailboxId,
|
||||
);
|
||||
const page = await this.conversations.mailboxConversations(
|
||||
user.email,
|
||||
query.mailboxId,
|
||||
scope,
|
||||
{ limit: query.limit, after },
|
||||
);
|
||||
const latestIds = page.items.map((item) => item.latestId);
|
||||
// 마지막 메일의 요약과 계정의 붙잡힌 메일 — 기다리는 제출은 이 쪽에서 한 번만 읽는다 (줄이 없으면 묻지 않는다)
|
||||
const { summaries, heldIds } =
|
||||
latestIds.length > 0
|
||||
? await this.provider.getSummariesWithHeld(user.email, latestIds)
|
||||
: { summaries: [], heldIds: new Set<string>() };
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const items = page.items.flatMap((item): ConversationSummary[] => {
|
||||
const latest = byId.get(item.latestId);
|
||||
// 엔진에 없거나(그사이 지움) 그 메일함을 떠났다 — 맞추기가 곧 고친다
|
||||
if (!latest || !latest.mailboxIds.includes(query.mailboxId)) return [];
|
||||
const messageIds = [...item.mailboxMessageIds];
|
||||
return [
|
||||
{
|
||||
id: item.key,
|
||||
latest,
|
||||
participants: [...item.participants],
|
||||
messageCount: item.messageCount,
|
||||
messageIds,
|
||||
pendingSendIds: messageIds.filter((id) => heldIds.has(id)),
|
||||
isRead: item.isRead,
|
||||
isFlagged: item.isFlagged,
|
||||
hasAttachment: item.hasAttachment,
|
||||
},
|
||||
];
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'index',
|
||||
};
|
||||
}
|
||||
|
||||
private warnFallback(user: AuthenticatedUser, error: unknown): void {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 검색 색인으로 대화를 읽지 못해 엔진의 스레드로 답합니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
|
||||
/**
|
||||
* GET /api/messages/{id}/conversation — 같은 대화의 쪽 (14단계). limit 의 처음 값이자 최대가 CONVERSATION_PAGE_LIMIT 다(화면은 한 번에 보인다)
|
||||
*/
|
||||
export class ConversationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'이전 응답의 nextCursor 값 — 그 메일의 대화에서만 쓴다(다르면 400 VAL_001). 첫 쪽은 생략한다',
|
||||
example: 'eyJ2IjoxfQ',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `한 번에 가져올 메일 수 (1~${CONVERSATION_PAGE_LIMIT})`,
|
||||
default: CONVERSATION_PAGE_LIMIT,
|
||||
example: CONVERSATION_PAGE_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
|
||||
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
|
||||
@Max(CONVERSATION_PAGE_LIMIT, {
|
||||
message: `조회 개수는 ${CONVERSATION_PAGE_LIMIT} 이하여야 합니다.`,
|
||||
})
|
||||
limit: number = CONVERSATION_PAGE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/conversations — 대화로 묶은 목록 (14단계). 메일함이 꼭 있어야 한다(검색은 묶지 않는다)
|
||||
*/
|
||||
export class ConversationListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일함 id — 그 메일함의 대화를 묶는다. 없는 메일함은 404 RES_001',
|
||||
example: 'a',
|
||||
})
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId!: string;
|
||||
}
|
||||
@@ -15,7 +15,18 @@ import {
|
||||
type ContactEmailLabel,
|
||||
} from '@/modules/contacts/contacts.types';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import type {
|
||||
ConversationBasis,
|
||||
MessageDetailHighlight,
|
||||
MessageListHighlight,
|
||||
SearchRange,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
import type {
|
||||
ConversationSummary,
|
||||
Draft,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
@@ -29,6 +40,7 @@ import type {
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageLargeFile,
|
||||
MessageListItem,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
PendingSend,
|
||||
@@ -311,6 +323,90 @@ export class MessageSummaryDto implements MessageSummary {
|
||||
pendingSend!: PendingSendDto | null;
|
||||
}
|
||||
|
||||
/** 맞은 곳 [시작, 끝) — UTF-16, 끝은 들지 않는다 (14단계 검색 강조) */
|
||||
const RANGE_LIST_SCHEMA = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'integer', minimum: 0 },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** 목록 항목의 미리 보기 강조 */
|
||||
export class MessageListPreviewHighlightDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(앞 20자 남짓 · 모두 80자 남짓, 말 경계에서 자르고 잘린 쪽에 "…"), 아니면 엔진의 미리 보기',
|
||||
example: '…지난주 보내 드린 계약서를 다시 확인해 주세요',
|
||||
})
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({
|
||||
...RANGE_LIST_SCHEMA,
|
||||
description: 'text 안의 맞은 곳 [시작, 끝) — UTF-16',
|
||||
example: [[12, 14]],
|
||||
})
|
||||
ranges!: SearchRange[];
|
||||
}
|
||||
|
||||
/** 목록 항목의 검색 강조 (색인으로 찾았을 때만) */
|
||||
export class MessageListHighlightDto implements MessageListHighlight {
|
||||
@ApiProperty({
|
||||
...RANGE_LIST_SCHEMA,
|
||||
description:
|
||||
'제목의 맞은 곳 [시작, 끝) — UTF-16. 강조가 있으면 subject 는 NFC 로 맞춘 글이다(위치가 그 글 기준)',
|
||||
example: [[0, 2]],
|
||||
})
|
||||
subject!: SearchRange[];
|
||||
|
||||
@ApiProperty({ type: MessageListPreviewHighlightDto })
|
||||
preview!: MessageListPreviewHighlightDto;
|
||||
}
|
||||
|
||||
/** 목록 항목 (GET /api/messages) — 요약 + 검색 강조 */
|
||||
export class MessageListItemDto
|
||||
extends MessageSummaryDto
|
||||
implements MessageListItem
|
||||
{
|
||||
@ApiProperty({
|
||||
description:
|
||||
'검색 강조 (14단계) — 색인으로 찾은 검색 결과에만, 목록 · 엔진 방식 검색(색인 준비 중)이면 null',
|
||||
type: MessageListHighlightDto,
|
||||
nullable: true,
|
||||
})
|
||||
searchHighlight!: MessageListHighlightDto | null;
|
||||
}
|
||||
|
||||
/** 읽기 화면의 검색 강조 */
|
||||
export class MessageDetailHighlightDto implements MessageDetailHighlight {
|
||||
@ApiProperty({
|
||||
...RANGE_LIST_SCHEMA,
|
||||
description:
|
||||
'제목의 맞은 곳 [시작, 끝) — UTF-16 (subject 는 NFC 로 맞춘 글이다)',
|
||||
example: [[0, 2]],
|
||||
})
|
||||
subject!: SearchRange[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'첨부 이름의 맞은 곳 — 첨부 id → [시작, 끝) 목록. 맞은 첨부만 든다 (이름은 NFC 로 맞춘 글이다)',
|
||||
type: 'object',
|
||||
additionalProperties: RANGE_LIST_SCHEMA,
|
||||
example: { blob1: [[0, 3]] },
|
||||
})
|
||||
attachments!: Record<string, SearchRange[]>;
|
||||
|
||||
@ApiProperty({
|
||||
...RANGE_LIST_SCHEMA,
|
||||
description:
|
||||
'textBody 의 맞은 곳 [시작, 끝) — UTF-16 (textBody 는 NFC 로 맞춘 글이다). htmlBody 는 글 조각에 <mark> 가 들어 있다',
|
||||
example: [[120, 122]],
|
||||
})
|
||||
textBody!: SearchRange[];
|
||||
}
|
||||
|
||||
export class MessageDetailDto
|
||||
extends MessageSummaryDto
|
||||
implements MessageDetail
|
||||
@@ -406,6 +502,14 @@ export class MessageDetailDto
|
||||
type: () => [MessageLargeFileDto],
|
||||
})
|
||||
largeFiles!: MessageLargeFileDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'검색 강조 (14단계) — 검색 쿼리(q · subject · body)를 함께 보냈을 때만, 없으면 null. 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고 htmlBody 의 맞은 글 조각에 <mark> 가 있다',
|
||||
type: MessageDetailHighlightDto,
|
||||
nullable: true,
|
||||
})
|
||||
searchHighlight!: MessageDetailHighlightDto | null;
|
||||
}
|
||||
|
||||
const SHARED_FILE_STATUSES: SharedFileStatus[] = [
|
||||
@@ -475,13 +579,132 @@ export class MessageLargeFileDto implements MessageLargeFile {
|
||||
|
||||
export class MessagePageDto {
|
||||
@ApiProperty({
|
||||
type: [MessageSummaryDto],
|
||||
type: [MessageListItemDto],
|
||||
description: '받은 시각 내림차순',
|
||||
})
|
||||
items!: MessageListItemDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description:
|
||||
'total — 목록 · 엔진 방식 검색은 늘, 색인 방식 검색은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). 색인 방식은 쪽이 limit 보다 적거나 비어도 hasNext 일 수 있다(이어 찾기)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목) 또는 엔진의 스레드(색인을 다 만들기 전) — 14단계 */
|
||||
const CONVERSATION_BASES: ConversationBasis[] = ['index', 'engine'];
|
||||
|
||||
/** 같은 대화 (GET /api/messages/{id}/conversation — 14단계) */
|
||||
export class ConversationPageDto {
|
||||
@ApiProperty({
|
||||
type: [MessageSummaryDto],
|
||||
description:
|
||||
'그 메일의 대화 — 오래된 차례(받은 시각). 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나',
|
||||
})
|
||||
items!: MessageSummaryDto[];
|
||||
|
||||
@ApiProperty({ type: PageInfoDto, description: 'total 포함' })
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description:
|
||||
'total — 대화의 메일 수. 색인 방식은 첫 쪽에서만, 엔진 방식은 스레드를 모두 읽었을 때(상한 이하)만 쪽마다 — 넘으면 null (쪽이 limit 보다 적어도 hasNext 면 이어 읽는다)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전)',
|
||||
enum: CONVERSATION_BASES,
|
||||
example: 'index',
|
||||
})
|
||||
basis!: ConversationBasis;
|
||||
}
|
||||
|
||||
/** 묶은 목록의 대화 하나 (GET /api/conversations — 14단계) */
|
||||
export class ConversationSummaryDto implements ConversationSummary {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화 — 불투명 문자열(색인이면 대화 키, 엔진이면 스레드 id). 한 목록 안에서 대화를 가리는 데만 쓴다',
|
||||
example: 'eaaaaab',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: MessageSummaryDto,
|
||||
description: '그 메일함 안 마지막 메일 — 줄을 누르면 연다',
|
||||
})
|
||||
latest!: MessageSummaryDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: [MailAddressDto],
|
||||
description: `보낸 사람들 — 대화 전체에서 받은 차례, 주소마다 한 번, ${CONVERSATION_PARTICIPANTS_MAX}명까지 (화면은 내 주소를 "나"로). 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만`,
|
||||
})
|
||||
participants!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다',
|
||||
example: 4,
|
||||
})
|
||||
messageCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: `그 메일함 안 그 대화의 메일 — 최신부터 ${CONVERSATION_MESSAGE_IDS_MAX}통까지(넘는 오래된 메일은 빠진다). 고르기 · 읽음 · 중요 · 이동 · 삭제는 이 id 들로 여러 통 API(PATCH /api/messages 등)에 — pendingSendIds 는 뺀다`,
|
||||
example: ['eaaaaab', 'eaaaaaa'],
|
||||
})
|
||||
messageIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일)의 id — 없으면 빈 배열. 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(여러 통 처리에서 뺀다)',
|
||||
example: [],
|
||||
})
|
||||
pendingSendIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '그 메일함 안 메일을 모두 읽었다',
|
||||
example: false,
|
||||
})
|
||||
isRead!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그 메일함 안 메일 가운데 하나라도 중요 표시',
|
||||
example: false,
|
||||
})
|
||||
isFlagged!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 hasAttachment 와 같은 판정)',
|
||||
example: true,
|
||||
})
|
||||
hasAttachment!: boolean;
|
||||
}
|
||||
|
||||
/** 대화로 묶은 목록 (GET /api/conversations — 14단계) */
|
||||
export class ConversationListPageDto {
|
||||
@ApiProperty({
|
||||
type: [ConversationSummaryDto],
|
||||
description: '그 메일함의 대화 — 그 메일함 안 마지막 받은 시각 최신부터',
|
||||
})
|
||||
items!: ConversationSummaryDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description:
|
||||
'total — 그 메일함의 대화 수(색인 방식은 첫 쪽에서만 · 엔진 방식은 엔진이 센 스레드 수)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'묶은 기준 — index 우리 색인 · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전 — 통 수 · 묶음이 엔진 규칙이다)',
|
||||
enum: CONVERSATION_BASES,
|
||||
example: 'index',
|
||||
})
|
||||
basis!: ConversationBasis;
|
||||
}
|
||||
|
||||
const REJECTION_REASONS: RecipientRejectionReason[] = [
|
||||
|
||||
@@ -25,3 +25,72 @@ describe('메일 한 통 읽기 쿼리 (GET /api/messages/{id} — 9단계 image
|
||||
expect(await constraintsOf({ show: '1' })).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 한 통 읽기 쿼리 — 검색 강조 (14단계 q · from · to · subject · body)', () => {
|
||||
it('목록의 검색 칸과 같은 검사 — 앞뒤 공백을 지우고, 길이 · 낱말 수를 본다', async () => {
|
||||
for (const field of ['q', 'from', 'to', 'subject', 'body']) {
|
||||
expect(await constraintsOf({ [field]: ' 계약 ' })).toEqual([]);
|
||||
expect(await constraintsOf({ [field]: '가'.repeat(201) })).toEqual([
|
||||
'maxCodeUnits',
|
||||
]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
[field]: Array.from({ length: 9 }, (_, i) => `w${i}`).join(' '),
|
||||
}),
|
||||
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
|
||||
expect(await constraintsOf({ [field]: ['a', 'b'] })).toContain(
|
||||
'isString',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('images=show 와 함께 받는다 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다', async () => {
|
||||
expect(await constraintsOf({ images: 'show', q: '계약' })).toEqual([]);
|
||||
expect(await constraintsOf({ after: '2026-10-31T15:00:00.000Z' })).toEqual([
|
||||
'whitelistValidation',
|
||||
]);
|
||||
expect(await constraintsOf({ hasAttachment: 'true' })).toEqual([
|
||||
'whitelistValidation',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 한 통 읽기 쿼리 — 받는 칸은 계약과 같다 (14단계 리뷰 M2 · M3)', () => {
|
||||
it('q · from · to · subject · body 의 낱말은 합쳐 8개까지 — 목록과 같다', async () => {
|
||||
const words = (prefix: string, count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
|
||||
' ',
|
||||
);
|
||||
expect(
|
||||
await constraintsOf({ q: words('q', 4), subject: words('s', 4) }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf({ q: words('q', 4), body: words('b', 5) }),
|
||||
).toEqual(['maxCombinedSearchWords']);
|
||||
});
|
||||
|
||||
it('images · q · from · to · subject · body 밖의 이름(cursor · limit · mailboxId · after · before · hasAttachment)은 400', async () => {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
images: 'show',
|
||||
q: '계약',
|
||||
from: '이도윤',
|
||||
to: 'ara',
|
||||
subject: '견적',
|
||||
body: '일정',
|
||||
}),
|
||||
).toEqual([]);
|
||||
for (const name of [
|
||||
'cursor',
|
||||
'limit',
|
||||
'mailboxId',
|
||||
'after',
|
||||
'before',
|
||||
'hasAttachment',
|
||||
]) {
|
||||
expect(await constraintsOf({ [name]: '1' })).toEqual([
|
||||
'whitelistValidation',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { SearchTextField } from './search-query-fields';
|
||||
|
||||
/** 외부 이미지를 보이는 본문 (GET /api/messages/{id}?images=show) — 계약 2절 "외부 이미지 보기" (9단계) */
|
||||
export const SHOW_IMAGES = 'show';
|
||||
|
||||
/** 강조에 쓰는 검색 칸의 설명 꼬리 */
|
||||
const HIGHLIGHT_RULE = `검색 결과에서 연 메일을 강조할 때 목록과 같은 값을 보낸다 — 검사는 목록의 검색어와 같다(다섯 칸의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지)`;
|
||||
|
||||
/**
|
||||
* GET /api/messages/{id} 의 쿼리 — `images=show` 면 외부 이미지를 중계 주소로 바꿔 넣는다(스팸메일함의 메일도).
|
||||
* 없으면 그 직원의 설정(externalImages)을 따른다. 모르는 값 · 모르는 쿼리는 400 VAL_001
|
||||
* 없으면 그 직원의 설정(externalImages)을 따른다. 검색 칸(q · from · to · subject · body — 14단계)을 받으면 맞은 곳을 강조한다
|
||||
* (searchHighlight · htmlBody 의 `<mark>`). from · to 는 받기만 하고 강조하지 않는다. 모르는 값 · 모르는 쿼리는 400 VAL_001 —
|
||||
* 목록에만 있는 cursor · limit · mailboxId · after · before · hasAttachment 도 받지 않는다(계약 2절 "검색 — 14단계" — 화면은 다섯 칸만 넘긴다)
|
||||
*/
|
||||
export class MessageDetailQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
@@ -18,4 +29,39 @@ export class MessageDetailQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn([SHOW_IMAGES], { message: '이미지 보기 값이 올바르지 않습니다.' })
|
||||
images?: typeof SHOW_IMAGES;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `검색어 — 제목 · 본문 · 첨부 이름의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('검색어', '검색어는')
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `보낸 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('보낸 사람', '보낸 사람은')
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `받는 사람 조건 — 받기만 하고 강조하지 않는다. ${HIGHLIGHT_RULE}`,
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('받는 사람', '받는 사람은')
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `제목 조건 — 제목의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('제목', '제목은')
|
||||
subject?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `내용 조건 — 본문의 맞은 곳을 강조한다. ${HIGHLIGHT_RULE}`,
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('내용', '내용은')
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { createValidationPipe } from '@/app.setup';
|
||||
import {
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
@@ -63,10 +66,10 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
|
||||
);
|
||||
expect(
|
||||
(await check({ q: words(MAX_SEARCH_WORDS + 1) })).constraints,
|
||||
).toEqual(['maxSearchWords']);
|
||||
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
|
||||
expect(
|
||||
(await check({ q: `"${words(MAX_SEARCH_WORDS + 1)}"` })).constraints,
|
||||
).toEqual(['maxSearchWords']);
|
||||
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
|
||||
});
|
||||
|
||||
it('검색어가 문자열이 아니면(같은 이름을 두 번 보냄) 형식 오류', async () => {
|
||||
@@ -75,3 +78,154 @@ describe('MessageListQueryDto (GET /api/messages)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageListQueryDto — 조건 검색 (14단계)', () => {
|
||||
const TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const;
|
||||
|
||||
it('글 조건(from · to · subject · body)은 검색어와 같다 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것', async () => {
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const trimmed = await check({ [field]: ' 이도윤 계약 ' });
|
||||
expect(trimmed.constraints).toEqual([]);
|
||||
expect(trimmed.dto[field]).toBe('이도윤 계약');
|
||||
|
||||
const blank = await check({ [field]: ' \u3000 ' });
|
||||
expect(blank.constraints).toEqual([]);
|
||||
expect(blank.dto[field]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it(`글 조건도 앞뒤 공백을 뺀 ${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지, 글이 아니면 형식 오류`, async () => {
|
||||
const words = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
|
||||
for (const field of TEXT_FIELDS) {
|
||||
expect(
|
||||
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH) })).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check({ [field]: '가'.repeat(MAX_SEARCH_LENGTH + 1) }))
|
||||
.constraints,
|
||||
).toEqual(['maxCodeUnits']);
|
||||
expect(
|
||||
(await check({ [field]: words(MAX_SEARCH_WORDS) })).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check({ [field]: words(MAX_SEARCH_WORDS + 1) })).constraints,
|
||||
).toEqual(['maxSearchWords', 'maxCombinedSearchWords']);
|
||||
expect((await check({ [field]: ['a', 'b'] })).constraints).toContain(
|
||||
'isString',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('기간(after · before)은 ISO 8601 UTC(Z)만 — 없는 날짜 · 시간대 표기 · 날짜만 · 빈 값은 형식 오류', async () => {
|
||||
expect(
|
||||
(
|
||||
await check({
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
before: '2026-11-30T15:00:00Z',
|
||||
})
|
||||
).constraints,
|
||||
).toEqual([]);
|
||||
for (const value of [
|
||||
'',
|
||||
'2026-11-01',
|
||||
'2026-11-01T00:00:00+09:00',
|
||||
'2026-02-30T00:00:00.000Z',
|
||||
'2026-11-01T00:00:00.0000Z',
|
||||
'yesterday',
|
||||
]) {
|
||||
expect((await check({ after: value })).constraints).toEqual([
|
||||
'isUtcDateTime',
|
||||
]);
|
||||
expect((await check({ before: value })).constraints).toEqual([
|
||||
'isUtcDateTime',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('첨부 조건은 true 만 — false · 1 · 대문자 · 빈 값은 형식 오류, 모르는 쿼리는 받지 않는다', async () => {
|
||||
expect((await check({ hasAttachment: 'true' })).constraints).toEqual([]);
|
||||
for (const hasAttachment of ['false', '1', 'TRUE', '']) {
|
||||
expect((await check({ hasAttachment })).constraints).toEqual(['isIn']);
|
||||
}
|
||||
expect((await check({ cc: 'ara' })).constraints).toEqual([
|
||||
'whitelistValidation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('전역 검사 그대로(stopAtFirstError) — 긴 글은 길이에서 멈춘다: 낱말을 나누지 않고 싸게 400 (CPU 시간)', async () => {
|
||||
const pipe = createValidationPipe();
|
||||
// 낱말 10만 개 · 30만 자 — 길이(MaxCodeUnits — 길이만 본다)에서 걸려 낱말 수 검사(나누기)까지 가지 않는다
|
||||
const huge = 'ab '.repeat(100_000);
|
||||
for (const field of ['q', ...TEXT_FIELDS]) {
|
||||
const cpuMs = startCpuTimer();
|
||||
const error = await pipe
|
||||
.transform(
|
||||
{ [field]: huge },
|
||||
{ type: 'query', metatype: MessageListQueryDto },
|
||||
)
|
||||
.then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
// 지금 수 ms — 부하를 견디게 10배 넘게
|
||||
expect(cpuMs()).toBeLessThan(500);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
const { message } = (error as BadRequestException).getResponse() as {
|
||||
message: string[];
|
||||
};
|
||||
expect(message).toHaveLength(1);
|
||||
expect(message[0]).toContain(`${MAX_SEARCH_LENGTH}자 이하`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageListQueryDto — 검색어와 조건을 합친 낱말 수 (14단계 리뷰 M2)', () => {
|
||||
const words = (prefix: string, count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' ');
|
||||
|
||||
it(`q 와 조건 칸 넷(from · to · subject · body)의 낱말을 합쳐 ${MAX_SEARCH_WORDS}개까지 — 넘으면 한 번만 알린다`, async () => {
|
||||
expect(
|
||||
(
|
||||
await check({
|
||||
q: words('q', 2),
|
||||
from: words('f', 2),
|
||||
to: words('t', 2),
|
||||
subject: words('s', 1),
|
||||
body: words('b', 1),
|
||||
})
|
||||
).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check({ q: words('q', 4), to: words('t', 5) })).constraints,
|
||||
).toEqual(['maxCombinedSearchWords']);
|
||||
// 검색어 없이 조건만이어도 같다
|
||||
expect(
|
||||
(await check({ from: words('f', 5), body: words('b', 4) })).constraints,
|
||||
).toEqual(['maxCombinedSearchWords']);
|
||||
});
|
||||
|
||||
it('기간 · 첨부 · 메일함은 세지 않는다 · 따옴표 안의 낱말은 센다', async () => {
|
||||
expect(
|
||||
(
|
||||
await check({
|
||||
q: words('q', MAX_SEARCH_WORDS),
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
hasAttachment: 'true',
|
||||
mailboxId: 'a',
|
||||
})
|
||||
).constraints,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(await check({ q: `"${words('q', 5)}"`, subject: words('s', 4) }))
|
||||
.constraints,
|
||||
).toEqual(['maxCombinedSearchWords']);
|
||||
});
|
||||
|
||||
it('칸 하나가 길이를 넘으면 그 칸의 검사만 — 합친 수를 세려고 긴 글을 나누지 않는다', async () => {
|
||||
// (이 검사는 stopAtFirstError 를 뺀 것이라 그 칸의 규칙이 모두 보인다 — 합친 수는 보지 않았다)
|
||||
expect(
|
||||
(await check({ q: '계약', to: 'ab '.repeat(100) })).constraints,
|
||||
).toEqual(['maxCodeUnits', 'maxSearchWords']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateBy,
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { IsIn, IsOptional, Matches } from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
MAIL_ID_PATTERN,
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
SEARCH_WORD_SEPARATOR,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { IsUtcDateTime } from './mail-vacation.dto';
|
||||
import { SearchTextField } from './search-query-fields';
|
||||
|
||||
/** 첨부 있음 조건의 값 — 이것만 받는다 (없으면 첨부와 상관없이) */
|
||||
export const HAS_ATTACHMENT_ONLY = 'true';
|
||||
|
||||
/** 글 칸의 설명 꼬리 — 검사는 검색어와 같다 */
|
||||
const TEXT_FIELD_RULE = `앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자 · 낱말 ${MAX_SEARCH_WORDS}개까지(검색어와 같은 검사 — q 와 조건 넷의 낱말을 합쳐서도 ${MAX_SEARCH_WORDS}개까지), 공백뿐이면 없는 것으로 본다. 큰따옴표 구절 · 앞뒤 문장부호 떼기 · NUL 을 U+FFFD 로 바꾸기도 검색어와 같다`;
|
||||
|
||||
/**
|
||||
* 검색어 — 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 본다 (계약 2절 "검색").
|
||||
* 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다.
|
||||
*/
|
||||
function normalizeSearchQuery({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** 검색어의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
|
||||
function countSearchWords(text: string): number {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
|
||||
}
|
||||
|
||||
/** 최대 낱말 수 */
|
||||
function MaxSearchWords(max: number, options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxSearchWords',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && countSearchWords(value) <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어.
|
||||
* mailboxId 와 q 중 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (mail.service.ts 의 listMessages)
|
||||
* GET /api/messages — 커서 페이지네이션 + 메일함 + 검색어 + 조건(14단계).
|
||||
* mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 — 둘 다 없으면 서비스가 400 VAL_001 (message-list.service.ts)
|
||||
*/
|
||||
export class MessageListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'메일함 id — 없으면 q 가 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (mailboxId 와 q 가 둘 다 없으면 400 VAL_001)',
|
||||
'메일함 id — 없으면 검색(q 나 조건)이 있어야 하고, 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (둘 다 없으면 400 VAL_001)',
|
||||
example: 'a',
|
||||
})
|
||||
@IsOptional()
|
||||
@@ -64,21 +32,76 @@ export class MessageListQueryDto extends CursorPaginationQueryDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지. 공백뿐이면 없는 것으로 본다. ` +
|
||||
'보낸 사람·받는 사람·참조·제목·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
|
||||
'한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다(회의 → 회의를 · 회의에서 …). ' +
|
||||
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·범위로만 쓴다',
|
||||
`검색어 — 앞뒤 공백을 뺀 1~${MAX_SEARCH_LENGTH}자(JS 문자열 길이), 낱말(띄어쓰기로 나눈 것) ${MAX_SEARCH_WORDS}개까지(조건 from · to · subject · body 의 낱말과 합쳐서도 ${MAX_SEARCH_WORDS}개까지). 공백뿐이면 없는 것으로 본다. ` +
|
||||
'보낸 사람·받는 사람·참조·제목·첨부 이름·본문에서 찾고, 여러 낱말이면 모두 들어간 메일만. 큰따옴표로 감싼 부분은 그 구절 그대로 찾는다. ' +
|
||||
'색인이 준비됐으면(GET /api/search-index 의 ready) 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 없이), ' +
|
||||
'준비 중이면 낱말이 통째로 같을 때만 맞고 한글로 끝나는 낱말은 흔한 조사를 붙인 형태도 함께 찾는다. ' +
|
||||
'낱말 앞뒤의 문장부호는 떼고(회의, → 회의), 문자·숫자가 없는 낱말(- · … 등)은 빼고, 찾을 낱말이 하나도 없으면 빈 목록. 커서는 같은 검색어·조건·범위로만 쓴다',
|
||||
example: '서버 이전',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@Transform(normalizeSearchQuery)
|
||||
@IsOptional()
|
||||
@IsString({ message: '검색어 형식이 올바르지 않습니다.' })
|
||||
@MaxCodeUnits(MAX_SEARCH_LENGTH, {
|
||||
message: `검색어는 ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
|
||||
})
|
||||
@MaxSearchWords(MAX_SEARCH_WORDS, {
|
||||
message: `검색어는 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
|
||||
})
|
||||
@SearchTextField('검색어', '검색어는')
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `보낸 사람 조건 — 보낸 사람의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
|
||||
example: '이도윤',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('보낸 사람', '보낸 사람은')
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `받는 사람 조건 — 받는 사람과 참조의 이름 · 주소에서 찾는다. ${TEXT_FIELD_RULE}`,
|
||||
example: 'ara.kim',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('받는 사람', '받는 사람은')
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `제목 조건. ${TEXT_FIELD_RULE}`,
|
||||
example: '견적',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('제목', '제목은')
|
||||
subject?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `내용 조건 — 본문에서 찾는다(첨부 파일 안의 글은 찾지 않는다). ${TEXT_FIELD_RULE}`,
|
||||
example: '계약',
|
||||
maxLength: MAX_SEARCH_LENGTH,
|
||||
})
|
||||
@SearchTextField('내용', '내용은')
|
||||
body?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'받은 시각이 이 시각 이후(이상)인 메일만 — ISO 8601 UTC(Z). 화면은 브라우저 시간대의 시작 날짜 0시를 UTC 로 바꿔 보낸다',
|
||||
example: '2026-10-31T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUtcDateTime({ message: '시작 날짜 형식이 올바르지 않습니다.' })
|
||||
after?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'받은 시각이 이 시각 앞(미만)인 메일만 — ISO 8601 UTC(Z). 화면은 끝 날짜의 다음 날 0시를 보낸다',
|
||||
example: '2026-11-30T15:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUtcDateTime({ message: '끝 날짜 형식이 올바르지 않습니다.' })
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true 면 첨부 파일이 있는 메일만 (목록의 첨부 표시와 같은 판정 — 본문 그림만 있는 메일은 빠진다). 다른 값은 400 VAL_001',
|
||||
enum: [HAS_ATTACHMENT_ONLY],
|
||||
example: HAS_ATTACHMENT_ONLY,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([HAS_ATTACHMENT_ONLY], {
|
||||
message: '첨부 조건이 올바르지 않습니다.',
|
||||
})
|
||||
hasAttachment?: typeof HAS_ATTACHMENT_ONLY;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateBy,
|
||||
type ValidationArguments,
|
||||
type ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAX_SEARCH_LENGTH,
|
||||
MAX_SEARCH_WORDS,
|
||||
SEARCH_WORD_SEPARATOR,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
|
||||
/**
|
||||
* 검색 글 칸의 검사 (계약 2절 "검색" · "검색 — 14단계" — 검색어 q 와 조건 검색의 from · to · subject · body 가 같이 쓴다).
|
||||
* 앞뒤 공백을 지우고 공백뿐이면 없는 것으로 본다. 앞뒤 공백을 뺀 1~MAX_SEARCH_LENGTH 자(JS 문자열 길이) · 낱말 MAX_SEARCH_WORDS 개까지,
|
||||
* 그리고 **다섯 칸을 합친 낱말도 MAX_SEARCH_WORDS 개까지** — 색인을 채우는 동안의 엔진 방식이 낱말마다 조사 형태를 붙여 묻는다(3단계에서 잰 비용 안에 두려고).
|
||||
* 검사는 적은 차례대로 돈다(applyDecorators — 8단계 DTO 와 같다): 형식 → 길이 → 낱말 수 → 합친 낱말 수. 길이에서 걸리면 낱말을 나누지 않는다
|
||||
*/
|
||||
|
||||
/** 낱말 수를 합쳐 세는 검색 글 칸 — 목록(GET /api/messages)과 읽기(GET /api/messages/{id})의 같은 이름 */
|
||||
export const SEARCH_TEXT_FIELDS = [
|
||||
'q',
|
||||
'from',
|
||||
'to',
|
||||
'subject',
|
||||
'body',
|
||||
] as const;
|
||||
|
||||
/** 앞뒤 공백을 지우고, 공백뿐이면 없는 것으로 — 문자열이 아니면(같은 이름을 두 번 보낸 배열 등) 그대로 두어 IsString 이 막는다 */
|
||||
function normalizeSearchText({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** 검색 글의 낱말 수 — 띄어쓰기(공백 문자)로 나눈 조각 (따옴표와 상관없이 센다) */
|
||||
export function countSearchWords(text: string): number {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length;
|
||||
}
|
||||
|
||||
/** 최대 낱말 수 */
|
||||
function MaxSearchWords(max: number, options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxSearchWords',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && countSearchWords(value) <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다섯 칸을 합친 낱말 수 — 값이 있는 첫 칸에서만 센다(한 번만 알린다). 다른 칸이 글이 아니거나 길이를 넘으면 세지 않는다 —
|
||||
* 그 칸의 검사가 막는다(긴 글을 나누지 않게)
|
||||
*/
|
||||
export function combinedSearchWords(
|
||||
object: Readonly<Record<string, unknown>>,
|
||||
): number | null {
|
||||
let total = 0;
|
||||
for (const field of SEARCH_TEXT_FIELDS) {
|
||||
const value = object[field];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== 'string' || value.length > MAX_SEARCH_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
total += countSearchWords(value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function MaxCombinedSearchWords(max: number, options: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxCombinedSearchWords',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (_value: unknown, args?: ValidationArguments) => {
|
||||
const object = (args?.object ?? {}) as Record<string, unknown>;
|
||||
const first = SEARCH_TEXT_FIELDS.find(
|
||||
(field) => object[field] !== undefined,
|
||||
);
|
||||
if (first !== args?.property) return true;
|
||||
const total = combinedSearchWords(object);
|
||||
return total === null || total <= max;
|
||||
},
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 글 칸 하나 — `label` 은 검사 글의 이름(검색어 · 보낸 사람 …), `topic` 은 조사를 붙인 꼴(검색어는 · 보낸 사람은 …).
|
||||
* 검사 글은 로그 · 응답에 나가지 않는다(응답은 늘 VAL_001 의 사전 문구 — 계약 머리말)
|
||||
*/
|
||||
export function SearchTextField(label: string, topic: string) {
|
||||
return applyDecorators(
|
||||
Transform(normalizeSearchText),
|
||||
IsOptional(),
|
||||
IsString({ message: `${label} 형식이 올바르지 않습니다.` }),
|
||||
MaxCodeUnits(MAX_SEARCH_LENGTH, {
|
||||
message: `${topic} ${MAX_SEARCH_LENGTH}자 이하로 입력해 주세요.`,
|
||||
}),
|
||||
MaxSearchWords(MAX_SEARCH_WORDS, {
|
||||
message: `${topic} 낱말 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
|
||||
}),
|
||||
MaxCombinedSearchWords(MAX_SEARCH_WORDS, {
|
||||
message: `검색어와 조건은 낱말을 모두 합쳐 ${MAX_SEARCH_WORDS}개까지 입력해 주세요.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { MAX_HTML_MARKS, markHtmlText } from './html-mark';
|
||||
import { sanitizeMailHtml } from './sanitize-mail-html';
|
||||
|
||||
const marked = (html: string, ...terms: string[]) =>
|
||||
markHtmlText(html, terms).html;
|
||||
|
||||
const countMarks = (html: string) => html.split('<mark>').length - 1;
|
||||
|
||||
describe('본문 HTML 강조 (html-mark — 14단계 설계 C2)', () => {
|
||||
it('글 조각의 맞은 곳에 <mark> — 대소문자 · 전각은 접어서 맞추고 원래 글자는 그대로', () => {
|
||||
expect(marked('<p>계약 갱신 안내</p>', '계약')).toBe(
|
||||
'<p><mark>계약</mark> 갱신 안내</p>',
|
||||
);
|
||||
expect(marked('<p>ERP 와 Erp 와 ERP</p>', 'erp')).toBe(
|
||||
'<p><mark>ERP</mark> 와 <mark>Erp</mark> 와 <mark>ERP</mark></p>',
|
||||
);
|
||||
expect(markHtmlText('<p>계약 계약</p>', ['계약']).marks).toBe(2);
|
||||
});
|
||||
|
||||
it('겹치거나 맞닿은 맞은 곳은 하나로 — 여러 말이 한 자리에 맞아도 <mark> 는 겹치지 않는다', () => {
|
||||
expect(marked('<p>계약서 갱신</p>', '계약', '약서')).toBe(
|
||||
'<p><mark>계약서</mark> 갱신</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('태그 안(이름 · 속성 · style · 주소)은 건드리지 않는다', () => {
|
||||
const html =
|
||||
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약">계약</a><img alt="계약" src="cid:x">';
|
||||
expect(marked(html, '계약')).toBe(
|
||||
'<a href="https://example.com/계약?q=계약" title="계약 안내" style="font-family:계약"><mark>계약</mark></a><img alt="계약" src="cid:x">',
|
||||
);
|
||||
});
|
||||
|
||||
it('주석 · 글을 그대로 두는 요소(style · script · textarea · title)의 안은 그대로', () => {
|
||||
expect(
|
||||
marked(
|
||||
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p>계약</p><!-- 닫히지 않은 계약',
|
||||
'계약',
|
||||
),
|
||||
).toBe(
|
||||
'<!-- 계약 --><style>.계약{color:red}</style><title>계약</title><p><mark>계약</mark></p><!-- 닫히지 않은 계약',
|
||||
);
|
||||
expect(marked('<SCRIPT>계약</Script >계약', '계약')).toBe(
|
||||
'<SCRIPT>계약</Script ><mark>계약</mark>',
|
||||
);
|
||||
});
|
||||
|
||||
it('문자 참조를 풀어 맞추고 다시 적는다 — & · < · > · · 10진 · 16진', () => {
|
||||
expect(marked('<p>A&B 계약 <중요></p>', 'a&b', '<중요>')).toBe(
|
||||
'<p><mark>A&B</mark> 계약 <mark><중요></mark></p>',
|
||||
);
|
||||
// 는 띄어쓰기와 같이 접힌다 — 구절이 맞는다 (다시 적을 때는 글자 그대로)
|
||||
expect(marked('<p>계약 갱신</p>', '계약 갱신')).toBe(
|
||||
'<p><mark>계약\u00a0갱신</mark></p>',
|
||||
);
|
||||
expect(marked('<p>계약 건</p>', '계약')).toBe(
|
||||
'<p><mark>계약</mark> 건</p>',
|
||||
);
|
||||
// 맞은 곳이 없는 조각은 원래 글자 그대로 (참조를 풀어 다시 적지 않는다)
|
||||
expect(marked('<p>계 "x"</p>', '없는말')).toBe(
|
||||
'<p>계 "x"</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('모르는 참조 · 닫히지 않은 & 가 있는 조각은 그대로 둔다 (정화 결과에는 나오지 않는다)', () => {
|
||||
expect(marked('<p>© 계약</p>', '계약')).toBe('<p>© 계약</p>');
|
||||
expect(marked('<p>R&D 계약</p><p>계약</p>', '계약')).toBe(
|
||||
'<p>R&D 계약</p><p><mark>계약</mark></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('줄바꿈 · 탭으로 갈린 구절도 맞는다 — 태그를 건너는 말은 강조하지 않는다', () => {
|
||||
expect(marked('<p>계약\n갱신</p>', '계약 갱신')).toBe(
|
||||
'<p><mark>계약\n갱신</mark></p>',
|
||||
);
|
||||
expect(marked('<p>계<b>약</b></p>', '계약')).toBe('<p>계<b>약</b></p>');
|
||||
});
|
||||
|
||||
it('NFD 로 적은 글도 맞춘다 — 맞은 조각은 NFC 로 다시 적는다', () => {
|
||||
const nfd = '계약 안내'.normalize('NFD');
|
||||
expect(marked(`<p>${nfd}</p>`, '계약')).toBe(
|
||||
'<p><mark>계약</mark> 안내</p>',
|
||||
);
|
||||
expect(marked(`<p>${nfd}</p>`, '없는말')).toBe(`<p>${nfd}</p>`);
|
||||
});
|
||||
|
||||
it('강조할 말이 없거나 빈 본문이면 그대로', () => {
|
||||
expect(markHtmlText('<p>계약</p>', [])).toEqual({
|
||||
html: '<p>계약</p>',
|
||||
marks: 0,
|
||||
});
|
||||
expect(markHtmlText('<p>계약</p>', ['', ' '])).toEqual({
|
||||
html: '<p>계약</p>',
|
||||
marks: 0,
|
||||
});
|
||||
expect(markHtmlText('', ['계약'])).toEqual({ html: '', marks: 0 });
|
||||
});
|
||||
|
||||
it(`<mark> 는 ${MAX_HTML_MARKS}개까지 — 흔한 말이 수만 번 나와도`, () => {
|
||||
const html = `<p>${'가 '.repeat(5_000)}</p><p>${'가 '.repeat(5_000)}</p>`;
|
||||
const result = markHtmlText(html, ['가']);
|
||||
expect(result.marks).toBe(MAX_HTML_MARKS);
|
||||
expect(countMarks(result.html)).toBe(MAX_HTML_MARKS);
|
||||
});
|
||||
|
||||
it('정화한 HTML 그대로 — 속성 값의 > · 따옴표 · 링크가 깨지지 않고, 넣은 <mark> 는 받은 메일 정화를 다시 지나도 남는다', () => {
|
||||
const { html } = sanitizeMailHtml(
|
||||
'<div title="a > 계약 "b""><a href="https://example.com/?q=계약&x=1">계약 바로가기</a> 계약&견적 <b>중요</b></div>',
|
||||
);
|
||||
const result = markHtmlText(html, ['계약']);
|
||||
|
||||
expect(result.marks).toBe(2);
|
||||
// 속성은 정화 결과 그대로
|
||||
expect(result.html).toContain('title="a > 계약 "b""');
|
||||
expect(result.html).toContain('href="https://example.com/?q=계약&x=1"');
|
||||
expect(result.html).toContain('<mark>계약</mark> 바로가기');
|
||||
expect(result.html).toContain('<mark>계약</mark>&견적');
|
||||
// <mark> 는 받은 메일 정화의 허용 태그다 — 다시 정화해도 같다(클라이언트가 그대로 그린다)
|
||||
expect(sanitizeMailHtml(result.html).html).toBe(result.html);
|
||||
});
|
||||
|
||||
it('깊게 중첩된 HTML 도 한 번 훑는다 — 길이에 비례 (CPU 시간)', () => {
|
||||
const depth = 100_000;
|
||||
const html = `${'<div>'.repeat(depth)}계약 안내${'</div>'.repeat(depth)}`;
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
const result = markHtmlText(html, ['계약']);
|
||||
|
||||
// 지금 30ms 안팎 — 부하를 견디게 10배 넘게
|
||||
expect(cpuMs()).toBeLessThan(1_000);
|
||||
expect(result.marks).toBe(1);
|
||||
expect(result.html.length).toBe(html.length + '<mark></mark>'.length);
|
||||
});
|
||||
|
||||
it('긴 글(2MB · 조각 수만 개) · 낱말 8개도 길이에 비례한다 (CPU 시간)', () => {
|
||||
const paragraph = `<p>이번 분기 계약 갱신과 단가 조정 일정을 공유합니다 & 확인 부탁드립니다.</p>`;
|
||||
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
|
||||
const terms = [
|
||||
'계약',
|
||||
'갱신',
|
||||
'단가',
|
||||
'조정',
|
||||
'일정',
|
||||
'공유',
|
||||
'확인',
|
||||
'"부탁드립니다."',
|
||||
];
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
const result = markHtmlText(html, terms);
|
||||
|
||||
expect(cpuMs()).toBeLessThan(3_000);
|
||||
expect(result.marks).toBe(MAX_HTML_MARKS);
|
||||
});
|
||||
|
||||
it('맞는 말이 없는 2MB 도 한 번 훑고 원래 글 그대로 (CPU 시간 — 리뷰 L6)', () => {
|
||||
const paragraph =
|
||||
'<p>이번 분기 일정을 공유합니다 & 확인 부탁드립니다.<br><b>굵게</b> <a href="https://example.com/a?b=1&c=2">링크</a></p>';
|
||||
const html = paragraph.repeat(Math.ceil(2_000_000 / paragraph.length));
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
const result = markHtmlText(html, ['없는말', '"계약 갱신"', 'zzz']);
|
||||
|
||||
// 지금 0.1초 안팎 — 부하를 견디게 10배 넘게
|
||||
expect(cpuMs()).toBeLessThan(2_000);
|
||||
expect(result).toEqual({ html, marks: 0 });
|
||||
});
|
||||
|
||||
it('검색 글의 NUL(U+FFFD 로 바꾼 말)은 본문의 U+FFFD 에 맞는다 — 색인과 같은 글자 (리뷰 L2)', () => {
|
||||
const FFFD = String.fromCharCode(0xfffd);
|
||||
expect(marked(`<p>계${FFFD}약 건</p>`, `계${FFFD}약`)).toBe(
|
||||
`<p><mark>계${FFFD}약</mark> 건</p>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
foldTerms,
|
||||
foldText,
|
||||
matchFoldedRanges,
|
||||
} from '@/shared/utils/search-text';
|
||||
|
||||
/**
|
||||
* 정화한 메일 HTML 의 글 조각에 `<mark>` 를 넣는다 (순수 함수 — 14단계 설계 C2, 읽기 화면의 검색 강조).
|
||||
* **정화한 뒤에만** 부른다(sanitize-mail-html.ts 의 결과 — 태그가 닫혀 있고 속성 값의 `>` 는 `>` 다). 다시 정화하지 않는다 — 넣는 것은 속성 없는 `<mark>` 뿐이다
|
||||
* (mark 는 받은 메일 정화의 허용 태그다).
|
||||
*
|
||||
* - 태그 안(이름 · 속성 · style)은 건드리지 않는다 — `<` 부터 `>` 까지는 그대로 옮긴다. 주석(`<!-- … -->`)과 글을 그대로 두어야 하는 요소
|
||||
* (script · style · textarea · title · xmp — 정화가 이미 지우지만)도 그대로
|
||||
* - 글 조각마다 문자 참조를 풀어(& · < · > · " · ' · · 10진 · 16진) NFC 로 맞춘 뒤 찾기와 같은 접기 · 부분 일치로 맞춘다
|
||||
* (search-text.ts — 목록 · 색인과 같은 규칙). 맞은 조각만 다시 적는다(& · < · > 로) — 모르는 이름의 문자 참조가 있는 조각은 그대로 둔다
|
||||
* - 태그를 건너는 말(`계<b>약</b>`)은 강조하지 않는다 — 글 조각 안에서만
|
||||
* - 넣는 `<mark>` 는 `limit`(MAX_HTML_MARKS)개까지
|
||||
* 입력을 한 번 훑는다 — 길이에 비례한다(중첩 깊이와 상관없다)
|
||||
*/
|
||||
|
||||
/** 한 본문에 넣는 `<mark>` 의 수 */
|
||||
export const MAX_HTML_MARKS = 1_000;
|
||||
|
||||
/** 문자 참조 이름 · 숫자의 최대 길이 — 넘으면 참조가 아니다 */
|
||||
const MAX_REFERENCE_LENGTH = 32;
|
||||
|
||||
/** 푸는 이름 있는 참조 — 정화 결과에 나오는 것(sanitize-html 은 글을 풀어 & · < · > 만 다시 적는다)과 흔한 것 */
|
||||
const NAMED_REFERENCES: Readonly<Record<string, string>> = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: '\u00a0',
|
||||
};
|
||||
|
||||
/** 글을 그대로 두는 요소 — 정화가 지우지만 혹시 남으면 그 안은 건드리지 않는다 */
|
||||
const RAW_TEXT_TAGS: ReadonlySet<string> = new Set([
|
||||
'script',
|
||||
'style',
|
||||
'textarea',
|
||||
'title',
|
||||
'xmp',
|
||||
]);
|
||||
|
||||
const COMMENT_OPEN = '<!--';
|
||||
const COMMENT_CLOSE = '-->';
|
||||
const MAX_CODE_POINT = 0x10ffff;
|
||||
const REPLACEMENT_CHARACTER = '\ufffd';
|
||||
|
||||
/** 태그 이름 (`<style …>` → style, 닫는 태그 · 이름 없음은 null) — 소문자 */
|
||||
const OPEN_TAG_NAME = /^<([A-Za-z][A-Za-z0-9-]*)/;
|
||||
|
||||
function decodeReference(reference: string): string | null {
|
||||
if (reference.startsWith('#')) {
|
||||
const hex = reference[1] === 'x' || reference[1] === 'X';
|
||||
const digits = reference.slice(hex ? 2 : 1);
|
||||
if (!(hex ? /^[0-9A-Fa-f]{1,8}$/ : /^[0-9]{1,8}$/).test(digits)) {
|
||||
return null;
|
||||
}
|
||||
const point = Number.parseInt(digits, hex ? 16 : 10);
|
||||
if (
|
||||
point <= 0 ||
|
||||
point > MAX_CODE_POINT ||
|
||||
(point >= 0xd800 && point <= 0xdfff)
|
||||
) {
|
||||
return REPLACEMENT_CHARACTER;
|
||||
}
|
||||
return String.fromCodePoint(point);
|
||||
}
|
||||
return NAMED_REFERENCES[reference] ?? null;
|
||||
}
|
||||
|
||||
/** 글 조각의 문자 참조를 푼다 — 모르는 참조 · 닫히지 않은 `&` 가 있으면 null(그 조각은 그대로 둔다) */
|
||||
function decodeText(text: string): string | null {
|
||||
if (!text.includes('&')) return text;
|
||||
let decoded = '';
|
||||
let index = 0;
|
||||
for (;;) {
|
||||
const amp = text.indexOf('&', index);
|
||||
if (amp < 0) return decoded + text.slice(index);
|
||||
decoded += text.slice(index, amp);
|
||||
const limit = Math.min(text.length, amp + 1 + MAX_REFERENCE_LENGTH + 1);
|
||||
const semi = text.indexOf(';', amp + 1);
|
||||
if (semi < 0 || semi >= limit) return null;
|
||||
const value = decodeReference(text.slice(amp + 1, semi));
|
||||
if (value === null) return null;
|
||||
decoded += value;
|
||||
index = semi + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 글을 HTML 글 조각으로 — `&` · `<` · `>` 만 적는다 */
|
||||
function escapeText(text: string): string {
|
||||
return text.replace(/[&<>]/g, (char) =>
|
||||
char === '&' ? '&' : char === '<' ? '<' : '>',
|
||||
);
|
||||
}
|
||||
|
||||
/** 글 조각 하나 — 맞은 곳에 `<mark>` 를 넣은 조각과 넣은 수 (맞은 곳이 없으면 원래 조각 그대로) */
|
||||
function markText(
|
||||
source: string,
|
||||
terms: readonly string[],
|
||||
limit: number,
|
||||
): { readonly html: string; readonly marks: number } {
|
||||
const decoded = decodeText(source);
|
||||
if (decoded === null) return { html: source, marks: 0 };
|
||||
const text = decoded.normalize('NFC');
|
||||
const ranges = matchFoldedRanges(foldText(text), terms, limit);
|
||||
if (ranges.length === 0) return { html: source, marks: 0 };
|
||||
let html = '';
|
||||
let last = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
html += `${escapeText(text.slice(last, start))}<mark>${escapeText(text.slice(start, end))}</mark>`;
|
||||
last = end;
|
||||
}
|
||||
return { html: html + escapeText(text.slice(last)), marks: ranges.length };
|
||||
}
|
||||
|
||||
/** `name` 의 닫는 태그 자리 (대소문자 없이) — 없으면 끝 */
|
||||
function closingTagAt(html: string, name: string, from: number): number {
|
||||
const pattern = new RegExp(`</${name}[\\s/>]`, 'gi');
|
||||
pattern.lastIndex = from;
|
||||
const found = pattern.exec(html);
|
||||
return found ? found.index : html.length;
|
||||
}
|
||||
|
||||
export function markHtmlText(
|
||||
html: string,
|
||||
terms: readonly string[],
|
||||
limit: number = MAX_HTML_MARKS,
|
||||
): { readonly html: string; readonly marks: number } {
|
||||
const folded = foldTerms(terms);
|
||||
if (folded.length === 0 || html === '') return { html, marks: 0 };
|
||||
const out: string[] = [];
|
||||
let marks = 0;
|
||||
let index = 0;
|
||||
while (index < html.length) {
|
||||
const lt = html.indexOf('<', index);
|
||||
const textEnd = lt < 0 ? html.length : lt;
|
||||
if (textEnd > index) {
|
||||
const text = html.slice(index, textEnd);
|
||||
if (marks < limit) {
|
||||
const marked = markText(text, folded, limit - marks);
|
||||
out.push(marked.html);
|
||||
marks += marked.marks;
|
||||
} else {
|
||||
out.push(text);
|
||||
}
|
||||
}
|
||||
if (lt < 0) break;
|
||||
if (html.startsWith(COMMENT_OPEN, lt)) {
|
||||
const close = html.indexOf(COMMENT_CLOSE, lt + COMMENT_OPEN.length);
|
||||
const end = close < 0 ? html.length : close + COMMENT_CLOSE.length;
|
||||
out.push(html.slice(lt, end));
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
const gt = html.indexOf('>', lt + 1);
|
||||
if (gt < 0) {
|
||||
out.push(html.slice(lt));
|
||||
break;
|
||||
}
|
||||
const tag = html.slice(lt, gt + 1);
|
||||
out.push(tag);
|
||||
index = gt + 1;
|
||||
const name = OPEN_TAG_NAME.exec(tag)?.[1].toLowerCase();
|
||||
if (name !== undefined && RAW_TEXT_TAGS.has(name)) {
|
||||
const close = closingTagAt(html, name, index);
|
||||
out.push(html.slice(index, close));
|
||||
index = close;
|
||||
}
|
||||
}
|
||||
return { html: out.join(''), marks };
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { firstSender } from '@/modules/mail-search/index-document';
|
||||
import {
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import type { JmapClient } from './jmap.client';
|
||||
import { JmapConversations, shareScan } from './jmap-conversations';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
/** 내보낼 메일만 읽어야 하는 요약 속성의 표 — 미리 보기 */
|
||||
const SUMMARY_ONLY = 'preview';
|
||||
const FUTURE = '2099-01-01T00:00:00Z';
|
||||
|
||||
/** 호출 한 번이 시작될 때 — 손으로 돌리는 시계를 넘기거나, Error 를 돌려주면 그 호출을 그 오류로 끝낸다 */
|
||||
type CallHook = (
|
||||
index: number,
|
||||
options: { timeoutMs?: number } | undefined,
|
||||
) => unknown;
|
||||
|
||||
/** 엔진 대역 — 받은 호출을 적고 준비한 응답을 차례로 */
|
||||
function fakeClient(responses: JmapInvocation[][], onCall?: CallHook) {
|
||||
const recorded: {
|
||||
calls: JmapInvocation[];
|
||||
options: { timeoutMs?: number } | undefined;
|
||||
}[] = [];
|
||||
const queue = [...responses];
|
||||
const call = jest.fn(
|
||||
(
|
||||
_user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
recorded.push({ calls: build('d'), options });
|
||||
const failure = onCall?.(recorded.length - 1, options);
|
||||
if (failure instanceof Error) return Promise.reject(failure);
|
||||
const next = queue.shift();
|
||||
if (!next) return Promise.reject(new Error('준비한 응답이 없습니다'));
|
||||
return Promise.resolve(next);
|
||||
},
|
||||
);
|
||||
return {
|
||||
recorded,
|
||||
call,
|
||||
reader: new JmapConversations({ call } as unknown as JmapClient),
|
||||
};
|
||||
}
|
||||
|
||||
const MAILBOX_LIST = [
|
||||
{ id: 'a', role: 'inbox' },
|
||||
{ id: 'b', role: 'trash' },
|
||||
{ id: 'c', role: 'junk' },
|
||||
{ id: 'e', role: 'sent' },
|
||||
];
|
||||
const mailboxes: JmapInvocation = ['Mailbox/get', { list: MAILBOX_LIST }, 'm'];
|
||||
|
||||
/** 메일 하나 (엔진이 주는 꼴 — 요청한 속성과 상관없이 다 싣는다) */
|
||||
function email(
|
||||
id: string,
|
||||
receivedAt: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: { a: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: `보낸 ${id}`, email: `${id}@example.com` }],
|
||||
to: [{ name: null, email: ARA }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '미리 보기',
|
||||
receivedAt,
|
||||
attachments: [],
|
||||
size: 100,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const noHeld: JmapInvocation[] = [
|
||||
['EmailSubmission/query', { ids: [], total: 0, position: 0 }, 'pq'],
|
||||
['EmailSubmission/get', { list: [] }, 'pg'],
|
||||
];
|
||||
|
||||
/** 한 요청에서 그 호출 id 의 인자 */
|
||||
function argsOf(calls: JmapInvocation[], id: string): Record<string, unknown> {
|
||||
return calls.find(([, , callId]) => callId === id)?.[1] ?? {};
|
||||
}
|
||||
|
||||
/** 그 요청의 Email/get 이 요약 속성(미리 보기)을 읽었나 */
|
||||
function readsSummary(calls: JmapInvocation[]): boolean {
|
||||
return calls.some(
|
||||
([method, args]) =>
|
||||
method === 'Email/get' &&
|
||||
Array.isArray(args.properties) &&
|
||||
(args.properties as string[]).includes(SUMMARY_ONLY),
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 요청의 Email/get 이 묻는 id (back-reference 면 빈 배열) */
|
||||
function idsOf(calls: JmapInvocation[]): string[] {
|
||||
return calls.flatMap(([method, args]) =>
|
||||
method === 'Email/get' && Array.isArray(args.ids)
|
||||
? (args.ids as string[])
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
interface FakeMail {
|
||||
readonly id: string;
|
||||
readonly threadId: string;
|
||||
readonly receivedAt: string;
|
||||
readonly mailboxIds: Record<string, boolean>;
|
||||
readonly keywords?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 들고 요청마다 답하는 엔진 대역 (큰 스레드 — 3차 리뷰 H1). Email/get · Thread/get · Mailbox/get · collapseThreads 의 Email/query ·
|
||||
* 제출 조회를 안다(back-reference 는 /ids · /list/[*]/threadId 만)
|
||||
*/
|
||||
function fakeEngine(mails: readonly FakeMail[], onCall?: CallHook) {
|
||||
const byId = new Map(mails.map((mail) => [mail.id, mail]));
|
||||
const threads = new Map<string, string[]>();
|
||||
for (const mail of [...mails].sort((a, b) =>
|
||||
a.receivedAt.localeCompare(b.receivedAt),
|
||||
)) {
|
||||
threads.set(mail.threadId, [
|
||||
...(threads.get(mail.threadId) ?? []),
|
||||
mail.id,
|
||||
]);
|
||||
}
|
||||
const requests: JmapInvocation[][] = [];
|
||||
/** 호출마다 받은 제한 시간 */
|
||||
const timeouts: (number | undefined)[] = [];
|
||||
const full = (mail: FakeMail) => email(mail.id, mail.receivedAt, { ...mail });
|
||||
const call = jest.fn(
|
||||
(
|
||||
_user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
const invocations = build('d');
|
||||
requests.push(invocations);
|
||||
timeouts.push(options?.timeoutMs);
|
||||
const failure = onCall?.(requests.length - 1, options);
|
||||
if (failure instanceof Error) return Promise.reject(failure);
|
||||
const results = new Map<string, Record<string, unknown>>();
|
||||
const resolve = (args: Record<string, unknown>): string[] => {
|
||||
const ref = args['#ids'] as
|
||||
| { resultOf: string; path: string }
|
||||
| undefined;
|
||||
if (!ref) return (args.ids as string[] | null) ?? [];
|
||||
const source = results.get(ref.resultOf) ?? {};
|
||||
if (ref.path === '/ids') return (source.ids as string[]) ?? [];
|
||||
return ((source.list as { threadId: string }[]) ?? []).map(
|
||||
(item) => item.threadId,
|
||||
);
|
||||
};
|
||||
const responses: JmapInvocation[] = invocations.map(
|
||||
([method, args, id]) => {
|
||||
let result: Record<string, unknown>;
|
||||
if (method === 'Email/get') {
|
||||
const ids = resolve(args);
|
||||
result = {
|
||||
list: ids.flatMap((emailId) => {
|
||||
const mail = byId.get(emailId);
|
||||
return mail ? [full(mail)] : [];
|
||||
}),
|
||||
notFound: ids.filter((emailId) => !byId.has(emailId)),
|
||||
};
|
||||
} else if (method === 'Thread/get') {
|
||||
result = {
|
||||
list: resolve(args).map((threadId) => ({
|
||||
id: threadId,
|
||||
emailIds: threads.get(threadId) ?? [],
|
||||
})),
|
||||
};
|
||||
} else if (method === 'Mailbox/get') {
|
||||
result = { list: MAILBOX_LIST };
|
||||
} else if (method === 'Email/query') {
|
||||
const filter = args.filter as { inMailbox: string };
|
||||
const seen = new Set<string>();
|
||||
const ids = [...mails]
|
||||
.filter((mail) => mail.mailboxIds[filter.inMailbox])
|
||||
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
|
||||
.filter((mail) => {
|
||||
if (seen.has(mail.threadId)) return false;
|
||||
seen.add(mail.threadId);
|
||||
return true;
|
||||
})
|
||||
.map((mail) => mail.id);
|
||||
const position = (args.position as number | undefined) ?? 0;
|
||||
result = {
|
||||
ids: ids.slice(position, position + (args.limit as number)),
|
||||
position,
|
||||
total: ids.length,
|
||||
};
|
||||
} else if (method === 'EmailSubmission/query') {
|
||||
result = { ids: [], total: 0, position: 0 };
|
||||
} else {
|
||||
result = { list: [] };
|
||||
}
|
||||
results.set(id, result);
|
||||
return [method, result, id];
|
||||
},
|
||||
);
|
||||
return Promise.resolve(responses);
|
||||
},
|
||||
);
|
||||
return {
|
||||
requests,
|
||||
timeouts,
|
||||
call,
|
||||
reader: new JmapConversations({ call } as unknown as JmapClient),
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 시각 — n 초 뒤 (차례가 id 차례와 같게) */
|
||||
const second = (n: number) =>
|
||||
new Date(Date.UTC(2026, 10, 1) + n * 1000).toISOString();
|
||||
|
||||
describe('엔진의 스레드로 본 대화 (JmapConversations — 14단계, 색인 전)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('같은 대화 (conversation)', () => {
|
||||
const lookup = (emailIds: string[]): JmapInvocation[] => [
|
||||
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
|
||||
['Thread/get', { list: [{ id: 't1', emailIds }] }, 't'],
|
||||
mailboxes,
|
||||
];
|
||||
|
||||
it('그 메일의 스레드 → 값싼 속성으로 걸러 오래된 차례 — 휴지통 · 스팸에만 든 메일 · 내부 초안은 빼되 지금 메일은 넣고, 요약 속성은 내보낼 메일만', async () => {
|
||||
const { reader, recorded } = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3', 'm4', 'm5']),
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m1', '2026-11-01T00:00:00Z', {
|
||||
mailboxIds: { e: true },
|
||||
}),
|
||||
email('m2', '2026-11-02T00:00:00Z', {
|
||||
mailboxIds: { c: true },
|
||||
}),
|
||||
email('m3', '2026-11-03T00:00:00Z', {
|
||||
mailboxIds: { b: true },
|
||||
}),
|
||||
email('m4', '2026-11-04T00:00:00Z', {
|
||||
keywords: { $draft: true, '$ara-sending': true },
|
||||
}),
|
||||
email('m5', '2026-11-05T00:00:00Z', {
|
||||
mailboxIds: { b: true, a: true },
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: ['m1', 'm2', 'm5'].map((id, index) =>
|
||||
email(id, `2026-11-0${index + 1}T00:00:00Z`),
|
||||
),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
|
||||
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
|
||||
|
||||
// m3(휴지통에만) · m4(내부 초안) 빠짐, m2(스팸이지만 지금 메일) · m5(휴지통과 받은편지함) 듦
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm5']);
|
||||
expect(page.total).toBe(3);
|
||||
expect(page.hasNext).toBe(false);
|
||||
expect(recorded[0].options).toEqual({
|
||||
invalidRequestCode: 'RES_001',
|
||||
timeoutMs: expect.any(Number) as number,
|
||||
});
|
||||
expect(recorded[0].calls.map(([method]) => method)).toEqual([
|
||||
'Email/get',
|
||||
'Thread/get',
|
||||
'Mailbox/get',
|
||||
]);
|
||||
// 거르기는 값싼 속성만, 요약은 내보낼 세 통만 (기다리는 발송과 함께)
|
||||
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'keywords',
|
||||
'receivedAt',
|
||||
]);
|
||||
expect(readsSummary(recorded[1].calls)).toBe(false);
|
||||
expect(idsOf(recorded[2].calls)).toEqual(['m1', 'm2', 'm5']);
|
||||
expect(recorded[2].calls.map(([method]) => method)).toEqual([
|
||||
'Email/get',
|
||||
'EmailSubmission/query',
|
||||
'EmailSubmission/get',
|
||||
]);
|
||||
});
|
||||
|
||||
it('쪽을 넘긴다 — 커서는 그 메일의 대화에서만(다른 메일의 커서는 엔진에 묻기 전에 VAL_001), 스레드 안 위치 · 마지막 메일이 빠졌으면 위치로', async () => {
|
||||
const cheap: JmapInvocation[] = [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: ['m1', 'm2', 'm3'].map((id, index) =>
|
||||
email(id, `2026-11-0${index + 1}T00:00:00Z`),
|
||||
),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
];
|
||||
const summaries = (ids: string[]): JmapInvocation[] => [
|
||||
[
|
||||
'Email/get',
|
||||
{ list: ids.map((id) => email(id, '2026-11-01T00:00:00Z')) },
|
||||
'g',
|
||||
],
|
||||
...noHeld,
|
||||
];
|
||||
const first = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m1', 'm2']),
|
||||
]);
|
||||
const page = await first.reader.conversation(ARA, 'm2', { limit: 2 });
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBe(3);
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, { conditions: ['conversation', 'm2'] }),
|
||||
).toEqual({ anchor: 'm2', position: 2 });
|
||||
|
||||
const next = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m3']),
|
||||
]);
|
||||
const second = await next.reader.conversation(ARA, 'm2', {
|
||||
limit: 2,
|
||||
cursor: page.nextCursor!,
|
||||
});
|
||||
expect(second.items.map((item) => item.id)).toEqual(['m3']);
|
||||
expect(second.hasNext).toBe(false);
|
||||
expect(idsOf(next.recorded[2].calls)).toEqual(['m3']);
|
||||
|
||||
const other = fakeClient([]);
|
||||
await expect(
|
||||
other.reader.conversation(ARA, 'm9', {
|
||||
limit: 2,
|
||||
cursor: page.nextCursor!,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(other.call).not.toHaveBeenCalled();
|
||||
|
||||
const gone = encodeCursor(
|
||||
{ anchor: 'm-gone', position: 1 },
|
||||
{ conditions: ['conversation', 'm2'] },
|
||||
);
|
||||
const resumed = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m2', 'm3']),
|
||||
]);
|
||||
const fromPosition = await resumed.reader.conversation(ARA, 'm2', {
|
||||
limit: 5,
|
||||
cursor: gone,
|
||||
});
|
||||
expect(fromPosition.items.map((item) => item.id)).toEqual(['m2', 'm3']);
|
||||
});
|
||||
|
||||
it('그 메일이 없으면(남의 메일 · 지움) 404 RES_001', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['Email/get', { list: [], notFound: ['zz'] }, 'e'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
mailboxes,
|
||||
],
|
||||
]);
|
||||
const error = await reader
|
||||
.conversation(ARA, 'zz', { limit: 100 })
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(BusinessException);
|
||||
expect((error as BusinessException).code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('메일함 역할을 읽지 못하면 빼지 않고 이어 간다 — 경고는 코드 · 가린 주소만 (리뷰 L5)', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
|
||||
['Thread/get', { list: [{ id: 't1', emailIds: ['m2'] }] }, 't'],
|
||||
['error', { type: 'serverFail' }, 'm'],
|
||||
],
|
||||
[['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g']],
|
||||
[
|
||||
['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g'],
|
||||
// 기다리는 제출을 다 주지 못했다 — 목록과 같은 경고
|
||||
[
|
||||
'EmailSubmission/query',
|
||||
{ ids: ['s1'], total: 600, position: 0 },
|
||||
'pq',
|
||||
],
|
||||
['EmailSubmission/get', { list: [] }, 'pg'],
|
||||
],
|
||||
]);
|
||||
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m2']);
|
||||
const lines = warnSpy.mock.calls.map(([line]) => String(line));
|
||||
expect(lines).toHaveLength(2);
|
||||
for (const line of lines) {
|
||||
expect(line).toMatch(/^\[MAIL_001\]/);
|
||||
expect(line).toContain('user=a***@arasystem.test');
|
||||
expect(line).not.toContain(ARA);
|
||||
}
|
||||
});
|
||||
|
||||
it('2,000통짜리 스레드 — 거르기는 커서 자리부터 상한까지만, 요약 속성은 한 쪽(100통)만 · total 은 null (리뷰 H1)', async () => {
|
||||
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
mailboxIds: { a: true },
|
||||
}));
|
||||
const engine = fakeEngine(mails);
|
||||
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(page.items).toHaveLength(100);
|
||||
expect(page.items[0].id).toBe('m0000');
|
||||
expect(page.total).toBeNull();
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({ anchor: 'm0099', position: 100 });
|
||||
// 엔진 요청: 찾기 · 거르기 한 번(500통) · 요약 한 번
|
||||
expect(engine.call).toHaveBeenCalledTimes(3);
|
||||
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
|
||||
expect(summaryIds).toHaveLength(100);
|
||||
const scanned = engine.requests
|
||||
.filter((request) => !readsSummary(request))
|
||||
.flatMap(idsOf)
|
||||
.filter((id) => id !== 'm1999');
|
||||
expect(scanned.length).toBeLessThanOrEqual(CONVERSATION_ENGINE_SCAN_MAX);
|
||||
});
|
||||
|
||||
it('거를 메일이 많은 큰 스레드 — 한 요청은 상한까지만 훑고 빈 쪽이어도 이어 갈 자리를 준다, 다음 요청에서 지금 메일이 든다', async () => {
|
||||
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
// 지금 메일(맨 끝) 말고는 모두 휴지통에만
|
||||
mailboxIds: { [index === 1_999 ? 'a' : 'b']: true },
|
||||
}));
|
||||
const engine = fakeEngine(mails);
|
||||
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items).toEqual([]);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({
|
||||
anchor: 'm0999',
|
||||
position: CONVERSATION_ENGINE_SCAN_MAX,
|
||||
});
|
||||
// 찾기 · 거르기 두 번(500 + 500) — 요약은 읽지 않는다
|
||||
expect(engine.call).toHaveBeenCalledTimes(3);
|
||||
expect(engine.requests.some(readsSummary)).toBe(false);
|
||||
|
||||
const next = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
cursor: page.nextCursor!,
|
||||
});
|
||||
expect(next.items.map((item) => item.id)).toEqual(['m1999']);
|
||||
expect(next.hasNext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('묶은 목록 (list)', () => {
|
||||
it('collapseThreads 로 그 메일함 안 스레드마다 마지막 메일(요약은 이것만) → 스레드의 나머지 메일을 값싼 속성으로 — 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일 (리뷰 L6)', async () => {
|
||||
const { reader, recorded } = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['m3', 'x1'], position: 0, total: 2 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m3', '2026-11-03T00:00:00Z', {
|
||||
keywords: {},
|
||||
threadId: 't1',
|
||||
}),
|
||||
email('x1', '2026-11-02T00:00:00Z', { threadId: 't2' }),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
list: [
|
||||
{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] },
|
||||
{ id: 't2', emailIds: ['x1'] },
|
||||
],
|
||||
},
|
||||
't',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/query',
|
||||
{ ids: ['s1'], total: 1, position: 0 },
|
||||
'pq',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 's1',
|
||||
emailId: 'm2',
|
||||
undoStatus: 'pending',
|
||||
sendAt: FUTURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
'pg',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m1', '2026-11-01T00:00:00Z', {
|
||||
mailboxIds: { e: true },
|
||||
from: [{ name: '김아라', email: ARA }],
|
||||
}),
|
||||
email('m2', '2026-11-02T00:00:00Z', {
|
||||
attachments: [
|
||||
{
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment',
|
||||
size: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
email('m4', '2026-11-04T00:00:00Z', {
|
||||
mailboxIds: { b: true },
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
|
||||
expect(argsOf(recorded[0].calls, 'q')).toMatchObject({
|
||||
filter: { inMailbox: 'a', notKeyword: '$ara-sending' },
|
||||
collapseThreads: true,
|
||||
calculateTotal: true,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
});
|
||||
// 마지막 메일(m3 · x1)은 다시 읽지 않는다 — 나머지는 값싼 속성 + 보낸 사람 · 첨부 부분
|
||||
expect(recorded).toHaveLength(2);
|
||||
expect(idsOf(recorded[1].calls)).toEqual(['m1', 'm2', 'm4']);
|
||||
expect(readsSummary(recorded[1].calls)).toBe(false);
|
||||
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'keywords',
|
||||
'receivedAt',
|
||||
'from',
|
||||
'attachments',
|
||||
]);
|
||||
expect(page.total).toBe(2);
|
||||
const [thread, single] = page.items;
|
||||
expect(thread).toMatchObject({
|
||||
id: 't1',
|
||||
messageCount: 3, // m4 는 휴지통에만
|
||||
messageIds: ['m3', 'm2'], // 받은편지함 안 — 최신부터
|
||||
pendingSendIds: ['m2'],
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(thread.latest.id).toBe('m3');
|
||||
expect(thread.participants.map((p) => p.email)).toEqual([
|
||||
ARA,
|
||||
'm2@example.com',
|
||||
'm3@example.com',
|
||||
]);
|
||||
expect(single).toMatchObject({
|
||||
id: 't2',
|
||||
messageCount: 1,
|
||||
messageIds: ['x1'],
|
||||
pendingSendIds: [],
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함 목록을 엔진이 거절하면(메서드 오류) 503 MAIL_001 — 없는 메일함 404 와 가른다 (리뷰 M1)', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['error', { type: 'serverFail' }, 'm'],
|
||||
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
|
||||
['Email/get', { list: [] }, 'g'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
reader.list(ARA, { mailboxId: 'a', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
});
|
||||
|
||||
it('없는 메일함이면 404 RES_001 · 커서는 그 메일함의 묶은 목록에서만', async () => {
|
||||
const missing = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
|
||||
['Email/get', { list: [] }, 'g'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
missing.reader.list(ARA, { mailboxId: 'zz', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
|
||||
const cursor = encodeCursor(
|
||||
{ anchor: 'm3', position: 20 },
|
||||
{ mailboxId: 'a', conditions: ['conversations'] },
|
||||
);
|
||||
const other = fakeClient([]);
|
||||
for (const query of [
|
||||
{ mailboxId: 'e', limit: 20, cursor },
|
||||
// 메일 목록의 커서는 쓸 수 없다
|
||||
{
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
cursor: encodeCursor(
|
||||
{ anchor: 'm3', position: 20 },
|
||||
{ mailboxId: 'a' },
|
||||
),
|
||||
},
|
||||
]) {
|
||||
await expect(other.reader.list(ARA, query)).rejects.toMatchObject({
|
||||
code: 'VAL_001',
|
||||
});
|
||||
}
|
||||
expect(other.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('2,000통짜리 스레드가 든 쪽 — 스레드마다 최신 상한까지 · 요약 속성은 줄마다 마지막 메일만, 상한에 걸린 줄의 통 수는 읽지 못한 메일을 거르지 않고 더한다 (리뷰 H1)', async () => {
|
||||
const big: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `g${String(index).padStart(4, '0')}`,
|
||||
threadId: 'tg',
|
||||
receivedAt: second(index),
|
||||
// 오래된 절반은 휴지통에만 — 읽지 못한 쪽이라 통 수에는 그대로 든다
|
||||
mailboxIds: { [index < 1_000 ? 'b' : 'a']: true },
|
||||
}));
|
||||
const small: FakeMail[] = ['s1', 's2'].map((id, index) => ({
|
||||
id,
|
||||
threadId: 'ts',
|
||||
receivedAt: second(5_000 + index),
|
||||
mailboxIds: { a: true },
|
||||
}));
|
||||
const engine = fakeEngine([...big, ...small]);
|
||||
|
||||
const page = await engine.reader.list(ARA, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.items.map((row) => row.id)).toEqual(['ts', 'tg']);
|
||||
// 엔진 요청: 목록 한 번 · 스레드 메일 한 번
|
||||
expect(engine.call).toHaveBeenCalledTimes(2);
|
||||
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
|
||||
expect(summaryIds).toEqual([]); // 마지막 메일은 back-reference 로 — 다시 읽지 않는다
|
||||
const memberIds = engine.requests.slice(1).flatMap(idsOf);
|
||||
expect(memberIds).toHaveLength(
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX - 1 + 1,
|
||||
);
|
||||
expect(memberIds.length).toBeLessThanOrEqual(
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
);
|
||||
const [smallRow, bigRow] = page.items;
|
||||
expect(smallRow).toMatchObject({
|
||||
messageCount: 2,
|
||||
messageIds: ['s2', 's1'],
|
||||
});
|
||||
// 최신 500통을 읽었고(모두 받은편지함) 오래된 1,500통은 읽지 않고 센다
|
||||
expect(bigRow.messageCount).toBe(2_000);
|
||||
expect(bigRow.messageIds).toHaveLength(
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
);
|
||||
expect(bigRow.messageIds[0]).toBe('g1999');
|
||||
expect(bigRow.messageIds.length).toBeLessThanOrEqual(
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('한 요청의 시간 (3차 재확인 N1 · N2 — Date.now 를 손으로 돌린다)', () => {
|
||||
const START = Date.UTC(2026, 10, 6, 1, 0, 0);
|
||||
let clock = START;
|
||||
beforeEach(() => {
|
||||
clock = START;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => clock);
|
||||
});
|
||||
|
||||
/** 스레드 하나 — `mailbox(i)` 메일함에 든 메일 `count` 통 (받은 시각 차례) */
|
||||
const thread = (
|
||||
count: number,
|
||||
mailbox: (index: number) => string = () => 'a',
|
||||
): FakeMail[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
mailboxIds: { [mailbox(index)]: true },
|
||||
}));
|
||||
|
||||
it('1,000통 이하 스레드를 시간 안에 다 읽지 못하면 503 MAIL_001 — 요약은 읽지 않는다', async () => {
|
||||
// 찾기 7초 · 첫 묶음은 제한 시간(5초 — 남은 8초에서 요약 몫 3초를 뺀)을 다 쓰고 돌아온다 → 남은 3초는 더 훑지 않는다
|
||||
const engine = fakeEngine(thread(800), (index, options) => {
|
||||
clock += index === 0 ? 7_000 : (options?.timeoutMs ?? 0);
|
||||
});
|
||||
await expect(
|
||||
engine.reader.conversation(ARA, 'm0799', { limit: 100 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
expect(engine.timeouts).toEqual([10_000, 5_000]);
|
||||
expect(engine.requests.some(readsSummary)).toBe(false);
|
||||
});
|
||||
|
||||
it('1,000통이 넘는 스레드에서 한 묶음 뒤 시간이 끝나면 읽은 데까지 답하고 앞으로 간 커서를 준다 — 요약 읽기는 남은 3초를 받는다', async () => {
|
||||
// 앞 세 통만 받은편지함, 나머지는 휴지통에만(지금 메일은 맨 끝) — 첫 묶음으로는 쪽이 차지 않는다
|
||||
const engine = fakeEngine(
|
||||
thread(2_000, (index) => (index < 3 || index === 1_999 ? 'a' : 'b')),
|
||||
(index, options) => {
|
||||
clock += index === 0 ? 2_000 : (options?.timeoutMs ?? 0);
|
||||
},
|
||||
);
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items.map((item) => item.id)).toEqual([
|
||||
'm0000',
|
||||
'm0001',
|
||||
'm0002',
|
||||
]);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({ anchor: 'm0499', position: 500 });
|
||||
// 찾기 10초 · 묶음 10초(남은 13초 − 3초) · 요약 3초(남은 시간을 다)
|
||||
expect(engine.timeouts).toEqual([10_000, 10_000, 3_000]);
|
||||
});
|
||||
|
||||
it('훑는 호출은 마지막 요약 읽기 몫을 빼고 받는다 — 3.5초 남아 시작한 느린 묶음 뒤에도 요약 읽기가 3초를 받는다 (N1)', async () => {
|
||||
const engine = fakeEngine(thread(2_000), (index, options) => {
|
||||
clock += index === 0 ? 11_500 : (options?.timeoutMs ?? 0);
|
||||
});
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items).toHaveLength(100);
|
||||
// 남은 3.5초에서 시작한 묶음은 0.5초만 받고(전에는 3.5초를 받아 요약 읽기가 1ms 였다), 요약 읽기는 3초
|
||||
expect(engine.timeouts).toEqual([10_000, 500, 3_000]);
|
||||
});
|
||||
|
||||
describe('묶은 목록 — 시간이 끝나면 어림 줄로 답한다(503 이 아니다)', () => {
|
||||
const first: JmapInvocation[] = [
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['m3'], position: 0, total: 1 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{ list: [email('m3', '2026-11-03T00:00:00Z', { keywords: {} })] },
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{ list: [{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] }] },
|
||||
't',
|
||||
],
|
||||
...noHeld,
|
||||
];
|
||||
/** 스레드의 다른 메일을 읽지 못한 줄 — 통 수는 읽지 못한 세 통을 거르지 않고 더하고, 나머지는 마지막 메일로만 */
|
||||
const approximate = {
|
||||
id: 't1',
|
||||
messageCount: 4,
|
||||
messageIds: ['m3'],
|
||||
pendingSendIds: [],
|
||||
participants: [{ name: '보낸 m3', email: 'm3@example.com' }],
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
};
|
||||
|
||||
it('첫 요청이 늦어 남은 시간이 요약 몫뿐이면 스레드 메일을 읽지 않는다', async () => {
|
||||
const { reader, recorded } = fakeClient([first], () => {
|
||||
clock += 13_000;
|
||||
});
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].options?.timeoutMs).toBe(10_000);
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.items[0]).toMatchObject(approximate);
|
||||
});
|
||||
|
||||
it('스레드 메일 읽기가 시간 때문에 끊기면 그 메일은 읽지 못한 것으로 — 시간이 남았는데 엔진이 실패하면 그대로 503', async () => {
|
||||
const cut = fakeClient([first], (index, options) => {
|
||||
if (index === 0) {
|
||||
clock += 2_000;
|
||||
return undefined;
|
||||
}
|
||||
// 제한 시간(남은 13초 − 요약 몫 3초)을 다 쓰고 끊겼다
|
||||
clock += options?.timeoutMs ?? 0;
|
||||
return new BusinessException('MAIL_001', {
|
||||
context: { reason: 'timeout' },
|
||||
});
|
||||
});
|
||||
const page = await cut.reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
expect(cut.recorded).toHaveLength(2);
|
||||
expect(cut.recorded[1].options?.timeoutMs).toBe(10_000);
|
||||
expect(page.items[0]).toMatchObject(approximate);
|
||||
|
||||
const broken = fakeClient([first], (index) =>
|
||||
index === 0 ? undefined : new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(
|
||||
broken.reader.list(ARA, { mailboxId: 'a', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진 방식 참여자의 이름도 색인과 같은 상한 · 같은 자르기 (INDEX_FROM_NAME_MAX_CHARS 코드 포인트 — N3)', async () => {
|
||||
const longName = `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀 끝`;
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['x1'], position: 0, total: 1 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('x1', '2026-11-02T00:00:00Z', {
|
||||
threadId: 't2',
|
||||
from: [{ name: longName, email: 'long@example.com' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
['Thread/get', { list: [{ id: 't2', emailIds: ['x1'] }] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
const [participant] = page.items[0].participants;
|
||||
expect([...(participant.name ?? '')]).toHaveLength(
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
);
|
||||
expect(participant.name).toBe(
|
||||
firstSender([{ name: longName, email: 'long@example.com' }]).name,
|
||||
);
|
||||
expect(participant.name?.endsWith('😀')).toBe(true);
|
||||
// 목록의 마지막 메일 요약(보낸 사람)은 자르지 않는다
|
||||
expect(page.items[0].latest.from?.name).toBe(longName);
|
||||
});
|
||||
|
||||
it('읽을 몫 나누기 — 작은 스레드는 다 주고 남는 몫을 큰 스레드끼리 똑같이 (모두 상한 안)', () => {
|
||||
expect(shareScan([3, 500, 1, 500], 600)).toEqual([3, 298, 1, 298]);
|
||||
expect(shareScan([2, 2], 2_000)).toEqual([2, 2]);
|
||||
expect(shareScan([500, 500, 500, 500, 500], 2_000)).toEqual([
|
||||
400, 400, 400, 400, 400,
|
||||
]);
|
||||
expect(shareScan([], 2_000)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
CONVERSATION_ENGINE_BUDGET_MS,
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_RESERVE_MS,
|
||||
CONVERSATION_ENGINE_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import { participantName } from '@/modules/mail-search/index-document';
|
||||
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
|
||||
import type {
|
||||
ConversationListQuery,
|
||||
ConversationSummary,
|
||||
ConversationSummaryPage,
|
||||
MailAddress,
|
||||
MessagePage,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
JMAP_REQUEST_TIMEOUT_MS,
|
||||
JmapClient,
|
||||
type JmapCallOptions,
|
||||
} from './jmap.client';
|
||||
import { decodeCursor, encodeCursor, type CursorScope } from './jmap-cursor';
|
||||
import { expectResult, findResult } from './jmap-results';
|
||||
import {
|
||||
SUMMARY_BODY_PART_PROPERTIES,
|
||||
SUMMARY_PROPERTIES,
|
||||
toAddress,
|
||||
toSummary,
|
||||
} from './jmap.mapper';
|
||||
import type {
|
||||
JmapEmail,
|
||||
JmapGetResult,
|
||||
JmapInvocation,
|
||||
JmapMailbox,
|
||||
JmapQueryResult,
|
||||
} from './jmap.types';
|
||||
import { excludedMailboxIdsOf, INTERNAL_SEND_KEYWORD } from './mail-scope';
|
||||
import {
|
||||
heldSubmissionFilter,
|
||||
pendingSendOf,
|
||||
readHeldSendTimes,
|
||||
submissionCalls,
|
||||
warnIfHeldQueryFull,
|
||||
} from './pending-send';
|
||||
|
||||
/** 한 번에 읽는 대화의 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet 500) 안 */
|
||||
const MEMBER_BATCH_SIZE = 500;
|
||||
/** 기다리는 발송을 묻는 호출 id 앞머리 (pending-send.ts) */
|
||||
const HELD_CALLS = 'p';
|
||||
|
||||
/** 같은 대화에서 거르기에 읽는 값싼 속성 — 요약 속성(미리 보기 · 첨부 부분 …)은 내보낼 메일만 읽는다 */
|
||||
const FILTER_PROPERTIES = ['id', 'mailboxIds', 'keywords', 'receivedAt'];
|
||||
/** 묶은 목록에서 스레드의 메일마다 읽는 속성 — 거르기 + 참여자(보낸 사람) · 첨부 판정(부분은 SUMMARY_BODY_PART_PROPERTIES 만) */
|
||||
const MEMBER_PROPERTIES = [...FILTER_PROPERTIES, 'from', 'attachments'];
|
||||
|
||||
/** 엔진의 스레드 — emailIds 는 받은 시각 오래된 차례(RFC 8621 §3.1 — 같은 시각이면 엔진이 정한 차례 그대로) */
|
||||
interface JmapThread {
|
||||
readonly id: string;
|
||||
readonly emailIds?: string[];
|
||||
}
|
||||
|
||||
/** 시간 때문에 끊긴 훑기로 볼 여유 — 제한 시간이 다 된 호출이 끝난 뒤 남은 시간이 마지막 요약 읽기 몫을 조금 넘을 수 있다 */
|
||||
const CUT_OFF_SLACK_MS = 100;
|
||||
|
||||
/** 엔진 호출 한 번의 제한 시간 — 기본 제한 시간과 `ms` 중 짧은 쪽 (1ms 이상) */
|
||||
function timeoutOf(ms: number): number {
|
||||
return Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 요청의 시간 (CONVERSATION_ENGINE_BUDGET_MS — 메일함 처리의 BulkRun 과 같은 결).
|
||||
* - 훑는 호출(같은 대화의 찾기 · 거르기, 묶은 목록의 스레드 메일)은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS" 중 짧은 쪽 —
|
||||
* 마지막 요약 읽기 몫을 늘 남긴다(3차 재확인 N1 — 3.5초 남아 시작한 느린 묶음이 요약 읽기를 1ms 로 만들지 않게)
|
||||
* - 마지막 읽기(같은 대화의 요약 · 묶은 목록의 첫 요청)만 남은 시간을 다 쓴다
|
||||
* - 남은 시간이 그 몫보다 적으면 더 훑지 않는다. 훑던 호출이 시간 때문에 끊겼으면(cutOff) 거기까지로 답한다
|
||||
* 시계는 Date.now 다 (시험은 spy 로 돌린다)
|
||||
*/
|
||||
class ReadBudget {
|
||||
private readonly deadline = Date.now() + CONVERSATION_ENGINE_BUDGET_MS;
|
||||
|
||||
canScan(): boolean {
|
||||
return this.left() > CONVERSATION_ENGINE_RESERVE_MS;
|
||||
}
|
||||
|
||||
scanOptions(options: JmapCallOptions = {}): JmapCallOptions {
|
||||
return {
|
||||
...options,
|
||||
timeoutMs: timeoutOf(this.left() - CONVERSATION_ENGINE_RESERVE_MS),
|
||||
};
|
||||
}
|
||||
|
||||
finalOptions(options: JmapCallOptions = {}): JmapCallOptions {
|
||||
return { ...options, timeoutMs: timeoutOf(this.left()) };
|
||||
}
|
||||
|
||||
/** 훑던 호출이 시간 때문에 끊겼나 — 엔진 장애(MAIL_001)이고 남은 시간이 마지막 요약 읽기 몫 남짓뿐이다 */
|
||||
cutOff(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof BusinessException &&
|
||||
error.code === 'MAIL_001' &&
|
||||
this.left() <= CONVERSATION_ENGINE_RESERVE_MS + CUT_OFF_SLACK_MS
|
||||
);
|
||||
}
|
||||
|
||||
private left(): number {
|
||||
return this.deadline - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 대화에 드는 메일인가 — 발송 중인 내부 초안이 아니고, 빼는 메일함(휴지통 · 스팸메일함)에만 들어 있지 않다 (`keep` 이면 늘 든다) */
|
||||
function inConversation(
|
||||
email: JmapEmail,
|
||||
excluded: ReadonlySet<string>,
|
||||
keep: (email: JmapEmail) => boolean,
|
||||
): boolean {
|
||||
if (keep(email)) return true;
|
||||
if (email.keywords?.[INTERNAL_SEND_KEYWORD]) return false;
|
||||
const mailboxes = Object.keys(email.mailboxIds ?? {});
|
||||
return mailboxes.length === 0 || mailboxes.some((id) => !excluded.has(id));
|
||||
}
|
||||
|
||||
/** 보낸 사람들 — 주어진 차례(받은 차례), 주소마다 한 번, `max` 명까지 · 이름은 INDEX_FROM_NAME_MAX_CHARS 코드 포인트까지 */
|
||||
function participantsOf(
|
||||
emails: readonly JmapEmail[],
|
||||
max: number,
|
||||
): MailAddress[] {
|
||||
const seen = new Set<string>();
|
||||
const participants: MailAddress[] = [];
|
||||
for (const email of emails) {
|
||||
if (participants.length >= max) break;
|
||||
const from = toAddress(email.from?.[0]);
|
||||
if (!from) continue;
|
||||
const key = from.email.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
// 이름은 색인 방식의 참여자와 같은 상한 · 같은 자르기 (index-document.ts 의 participantName)
|
||||
participants.push({ email: from.email, name: participantName(from.name) });
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 요청에 읽을 몫 — 스레드마다 `needs[i]` 까지, 모두 `budget` 까지. 작은 스레드부터 다 주고 남는 몫을 큰 스레드끼리 똑같이 나눈다
|
||||
* (큰 스레드 하나가 쪽의 다른 줄을 비우지 않게)
|
||||
*/
|
||||
export function shareScan(needs: readonly number[], budget: number): number[] {
|
||||
const shares = needs.map(() => 0);
|
||||
const order = needs
|
||||
.map((need, index) => ({ need: Math.max(0, need), index }))
|
||||
.sort((a, b) => a.need - b.need);
|
||||
let left = Math.max(0, budget);
|
||||
order.forEach(({ need, index }, position) => {
|
||||
const give = Math.min(need, Math.floor(left / (order.length - position)));
|
||||
shares[index] = give;
|
||||
left -= give;
|
||||
});
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진의 스레드로 본 대화 (14단계 — 자체 색인을 다 만들기 전 · 첫 쪽 DB 장애의 대신, 설계 C3 · C4). 스레드는 엔진의 규칙(답장 관계 + 엔진이 정규화한 제목)이다.
|
||||
* 거르기는 값싼 속성만 읽고 요약 속성은 내보낼 메일만 읽는다. 상한 · 시간은 mail-search.constants.ts 의 CONVERSATION_ENGINE_* (3차 리뷰 H1).
|
||||
* - 같은 대화: 그 메일의 스레드(오래된 차례) — 스레드가 CONVERSATION_ENGINE_SCAN_MAX 통 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 쪽이 찰 때까지만
|
||||
* (한 요청 그 상한까지 · 시간 안 — 쪽이 limit 보다 적어도 이어 갈 자리가 있으면 hasNext, total 은 null). 쪽은 스레드 안 위치(anchor · position) 커서
|
||||
* - 묶은 목록: `Email/query` 의 collapseThreads(그 메일함 안 스레드마다 마지막 메일 — 요약 속성은 이것만) → 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통
|
||||
* (한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통 — shareScan)으로 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일.
|
||||
* **상한에 걸린 줄**(읽지 못한 오래된 메일이 있는 스레드 — 몫 · 시간): 통 수는 읽은 메일 가운데 대화에 드는 것 + 읽지 못한 메일 수(거르지 않고 더한다),
|
||||
* 참여자 · messageIds · 읽음 · 중요 · 첨부 · pendingSendIds 는 읽은 메일(최신 쪽)로만 정한다
|
||||
* 커서는 jmap-cursor.ts 의 모양(1판)에 범위를 따로 둔다 — 목록 · 검색의 커서와 섞이지 않게
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapConversations {
|
||||
private readonly logger = new Logger(JmapConversations.name);
|
||||
|
||||
constructor(private readonly jmap: JmapClient) {}
|
||||
|
||||
async conversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage> {
|
||||
const scope: CursorScope = { conditions: ['conversation', emailId] };
|
||||
// 커서는 엔진에 묻기 전에 본다 — 다른 대화의 커서면 VAL_001
|
||||
const resume = page.cursor ? decodeCursor(page.cursor, scope) : null;
|
||||
const budget = new ReadBudget();
|
||||
const lookup = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: [emailId], properties: ['id', 'threadId'] },
|
||||
'e',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: 'e',
|
||||
name: 'Email/get',
|
||||
path: '/list/*/threadId',
|
||||
},
|
||||
},
|
||||
't',
|
||||
],
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
budget.scanOptions({ invalidRequestCode: 'RES_001' }),
|
||||
);
|
||||
const { list: found = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
lookup,
|
||||
'e',
|
||||
'Email/get',
|
||||
);
|
||||
if (found.length === 0) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'conversation-message-not-found' },
|
||||
});
|
||||
}
|
||||
const thread = expectResult<JmapGetResult<JmapThread>>(
|
||||
lookup,
|
||||
't',
|
||||
'Thread/get',
|
||||
).list?.[0];
|
||||
const excluded = new Set(
|
||||
excludedMailboxIdsOf(this.mailboxesOf(lookup, user)),
|
||||
);
|
||||
const order = thread?.emailIds?.length ? thread.emailIds : [emailId];
|
||||
|
||||
// 이어 갈 자리 — 스레드 안 위치. 마지막으로 본 메일이 그사이 빠졌으면 위치로
|
||||
let start = 0;
|
||||
if (resume) {
|
||||
const anchorAt = order.indexOf(resume.anchor);
|
||||
start =
|
||||
anchorAt >= 0 ? anchorAt + 1 : Math.min(resume.position, order.length);
|
||||
}
|
||||
// 상한 이하면 모두 읽어 센다(total), 넘으면 이어 갈 자리부터 쪽이 찰 때까지만
|
||||
const countAll = order.length <= CONVERSATION_ENGINE_SCAN_MAX;
|
||||
const scanEnd = countAll
|
||||
? order.length
|
||||
: Math.min(order.length, start + CONVERSATION_ENGINE_SCAN_MAX);
|
||||
const keep = (email: JmapEmail) => email.id === emailId;
|
||||
/** 대화에 드는 메일의 스레드 안 위치 */
|
||||
const kept: number[] = [];
|
||||
let scanned = countAll ? 0 : start;
|
||||
let shownCount = 0;
|
||||
while (scanned < scanEnd && (countAll || shownCount <= page.limit)) {
|
||||
if (!budget.canScan()) break;
|
||||
const batch = order.slice(
|
||||
scanned,
|
||||
Math.min(scanEnd, scanned + MEMBER_BATCH_SIZE),
|
||||
);
|
||||
let byId: Map<string, JmapEmail>;
|
||||
try {
|
||||
byId = await this.readEmails(user, batch, FILTER_PROPERTIES, budget);
|
||||
} catch (error) {
|
||||
// 시간 때문에 끊긴 묶음 — 읽은 데까지로 답한다(아래에서 다 읽어야 하는 작은 스레드 · 아무것도 못 읽음은 MAIL_001)
|
||||
if (budget.cutOff(error)) break;
|
||||
throw error;
|
||||
}
|
||||
batch.forEach((id, offset) => {
|
||||
const email = byId.get(id);
|
||||
if (!email || !inConversation(email, excluded, keep)) return;
|
||||
kept.push(scanned + offset);
|
||||
if (scanned + offset >= start) shownCount += 1;
|
||||
});
|
||||
scanned += batch.length;
|
||||
}
|
||||
const readAll = countAll && scanned >= order.length;
|
||||
if (countAll && !readAll) {
|
||||
// 작은 스레드를 시간 안에 다 읽지 못했다 — 엔진이 느리다
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'conversation-budget', scanned },
|
||||
});
|
||||
}
|
||||
if (!countAll && scanned === start && start < order.length) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'conversation-budget', scanned },
|
||||
});
|
||||
}
|
||||
|
||||
const candidates = kept.filter((index) => index >= start);
|
||||
const shown = candidates.slice(0, page.limit);
|
||||
let next: number | null = null;
|
||||
if (candidates.length > page.limit) {
|
||||
next = shown[shown.length - 1] + 1;
|
||||
} else if (scanned < order.length && !readAll) {
|
||||
next = scanned;
|
||||
}
|
||||
const items = await this.summaries(
|
||||
user,
|
||||
shown.map((index) => order[index]),
|
||||
budget,
|
||||
);
|
||||
return {
|
||||
items,
|
||||
hasNext: next !== null,
|
||||
nextCursor:
|
||||
next !== null
|
||||
? encodeCursor({ anchor: order[next - 1], position: next }, scope)
|
||||
: null,
|
||||
total: readAll ? kept.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage> {
|
||||
const scope: CursorScope = {
|
||||
mailboxId: query.mailboxId,
|
||||
conditions: ['conversations'],
|
||||
};
|
||||
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
|
||||
const budget = new ReadBudget();
|
||||
const now = Date.now();
|
||||
const ask = (start: { anchor: string } | { position: number }) =>
|
||||
this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: {
|
||||
inMailbox: query.mailboxId,
|
||||
notKeyword: INTERNAL_SEND_KEYWORD,
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
collapseThreads: true,
|
||||
...('anchor' in start
|
||||
? { anchor: start.anchor, anchorOffset: 1 }
|
||||
: { position: start.position }),
|
||||
limit: query.limit,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: 'g',
|
||||
name: 'Email/get',
|
||||
path: '/list/*/threadId',
|
||||
},
|
||||
},
|
||||
't',
|
||||
],
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
],
|
||||
budget.finalOptions({ invalidRequestCode: 'RES_001' }),
|
||||
);
|
||||
let responses = await ask(
|
||||
resume ? { anchor: resume.anchor } : { position: 0 },
|
||||
);
|
||||
// 마지막으로 본 메일이 그사이 지워지거나 옮겨졌으면 위치로 이어 읽는다
|
||||
const first = findResult(responses, 'q', 'Email/query');
|
||||
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
|
||||
responses = await ask({ position: resume.position });
|
||||
}
|
||||
|
||||
// 메일함 목록은 꼭 읽는다 — 엔진 장애(메서드 오류)는 503 MAIL_001, 없는 메일함은 404 RES_001 (3차 리뷰 M1)
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailboxes.some((mailbox) => mailbox.id === query.mailboxId)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
const excluded = new Set(excludedMailboxIdsOf(mailboxes));
|
||||
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
|
||||
const { list: latestEmails = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const { list: threads = [] } = expectResult<JmapGetResult<JmapThread>>(
|
||||
responses,
|
||||
't',
|
||||
'Thread/get',
|
||||
);
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
|
||||
const latestById = new Map(latestEmails.map((email) => [email.id, email]));
|
||||
const threadById = new Map(threads.map((thread) => [thread.id, thread]));
|
||||
const ids = result.ids ?? [];
|
||||
const rows = ids.flatMap((id) => {
|
||||
const latest = latestById.get(id);
|
||||
if (!latest) return [];
|
||||
const members = latest.threadId
|
||||
? threadById.get(latest.threadId)?.emailIds
|
||||
: undefined;
|
||||
return [{ latest, order: members?.length ? members : [latest.id] }];
|
||||
});
|
||||
// 스레드마다 최신 쪽의 몫 — 한 요청 상한 안에서 작은 스레드부터
|
||||
const shares = shareScan(
|
||||
rows.map((row) =>
|
||||
Math.min(row.order.length, CONVERSATION_ENGINE_THREAD_SCAN_MAX),
|
||||
),
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
);
|
||||
const windows = rows.map(
|
||||
(row, index) =>
|
||||
new Set(row.order.slice(row.order.length - shares[index])),
|
||||
);
|
||||
const toRead = [
|
||||
...new Set(windows.flatMap((window) => [...window])),
|
||||
].filter((id) => !latestById.has(id));
|
||||
const { emails: members, attempted } = await this.readMembers(
|
||||
user,
|
||||
toRead,
|
||||
budget,
|
||||
);
|
||||
|
||||
const items = rows.map(({ latest, order }, index): ConversationSummary => {
|
||||
const window = windows[index];
|
||||
/** 읽은 메일 — 몫 안에서 엔진에 물은 것(그사이 지운 것은 빠진다)과 마지막 메일 */
|
||||
const wasRead = (id: string) =>
|
||||
id === latest.id || (window.has(id) && attempted.has(id));
|
||||
const readEmails = order.flatMap((id) => {
|
||||
if (!wasRead(id)) return [];
|
||||
const email = id === latest.id ? latest : members.get(id);
|
||||
return email ? [email] : [];
|
||||
});
|
||||
if (!order.includes(latest.id)) readEmails.push(latest);
|
||||
const notRead = order.filter((id) => !wasRead(id)).length;
|
||||
const inMailbox = (email: JmapEmail) =>
|
||||
Boolean(email.mailboxIds?.[query.mailboxId]) &&
|
||||
!email.keywords?.[INTERNAL_SEND_KEYWORD];
|
||||
const kept = readEmails.filter((email) =>
|
||||
inConversation(email, excluded, inMailbox),
|
||||
);
|
||||
const here = kept.filter(inMailbox);
|
||||
const messageIds = (
|
||||
here.length > 0 ? here.map((email) => email.id).reverse() : [latest.id]
|
||||
).slice(0, CONVERSATION_MESSAGE_IDS_MAX);
|
||||
const shownHere = here.length > 0 ? here : [latest];
|
||||
return {
|
||||
id: latest.threadId ?? latest.id,
|
||||
latest: toSummary(latest, pendingSendOf(latest, held)),
|
||||
participants: participantsOf(kept, CONVERSATION_PARTICIPANTS_MAX),
|
||||
messageCount: Math.max(kept.length + notRead, 1),
|
||||
messageIds,
|
||||
pendingSendIds: messageIds.filter((id) => held.has(id)),
|
||||
isRead: shownHere.every((email) => email.keywords?.$seen === true),
|
||||
isFlagged: shownHere.some((email) => email.keywords?.$flagged === true),
|
||||
hasAttachment: shownHere.some((email) =>
|
||||
hasRealAttachment(email.attachments ?? []),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const start = result.position ?? 0;
|
||||
const total = typeof result.total === 'number' ? result.total : null;
|
||||
const hasNext =
|
||||
total !== null ? start + ids.length < total : ids.length === query.limit;
|
||||
const lastId = ids.at(-1);
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && lastId
|
||||
? encodeCursor(
|
||||
{ anchor: lastId, position: start + ids.length },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** 메일함 목록(역할) — 같은 대화에서만: 읽지 못하면 빈 목록(빼는 메일함 없이 — 대화가 넓어질 뿐, 경고를 남긴다) */
|
||||
private mailboxesOf(
|
||||
responses: JmapInvocation[],
|
||||
user: string,
|
||||
): JmapMailbox[] {
|
||||
const found = findResult(responses, 'm', 'Mailbox/get');
|
||||
if (!found.ok) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 대화의 메일함 역할을 읽지 못해 휴지통 · 스팸메일함을 빼지 못했습니다: user=${maskEmail(user)}, type=${found.error.type}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return (found.args as JmapGetResult<JmapMailbox>).list ?? [];
|
||||
}
|
||||
|
||||
/** 메일 여러 통을 값싼 속성으로 — 훑는 호출(마지막 요약 읽기 몫을 남긴 제한 시간), 엔진 장애는 그대로 던진다 */
|
||||
private async readEmails(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
properties: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<Map<string, JmapEmail>> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [...ids],
|
||||
properties: [...properties],
|
||||
...(properties.includes('attachments')
|
||||
? { bodyProperties: SUMMARY_BODY_PART_PROPERTIES }
|
||||
: {}),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
budget.scanOptions(),
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
return new Map(list.map((email) => [email.id, email]));
|
||||
}
|
||||
|
||||
/** 묶은 목록의 스레드 메일 — 500통씩, 시간이 남은 동안만(물은 id 를 함께 준다 — 묻지 못한 것은 상한에 걸린 줄의 읽지 못한 메일이다) */
|
||||
private async readMembers(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<{ emails: Map<string, JmapEmail>; attempted: Set<string> }> {
|
||||
const emails = new Map<string, JmapEmail>();
|
||||
const attempted = new Set<string>();
|
||||
for (const batch of chunks(ids, MEMBER_BATCH_SIZE)) {
|
||||
if (!budget.canScan()) break;
|
||||
let read: Map<string, JmapEmail>;
|
||||
try {
|
||||
read = await this.readEmails(user, batch, MEMBER_PROPERTIES, budget);
|
||||
} catch (error) {
|
||||
// 시간 때문에 끊긴 묶음 — 그 메일은 읽지 못한 것으로(어림 줄 — 503 이 아니다)
|
||||
if (budget.cutOff(error)) break;
|
||||
throw error;
|
||||
}
|
||||
for (const id of batch) attempted.add(id);
|
||||
for (const [id, email] of read) emails.set(id, email);
|
||||
}
|
||||
return { emails, attempted };
|
||||
}
|
||||
|
||||
/** 내보낼 메일의 요약 — 마지막 읽기(남은 시간을 다 쓴다). 요약 속성은 이 메일들만, 같은 요청에서 기다리는 발송도 (주어진 차례 · 없는 메일은 빠진다) */
|
||||
private async summaries(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<MessagePage['items']> {
|
||||
if (ids.length === 0) return [];
|
||||
const now = Date.now();
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [...ids],
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
],
|
||||
budget.finalOptions(),
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
return ids.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,56 @@ describe('메일 목록 커서', () => {
|
||||
expect(codeOf(() => decodeCursor(cursor, inbox))).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('조건 검색(14단계)의 조건도 범위다 — 조건이 다르면 다른 목록, 조건이 없으면 지문은 예전과 같다', () => {
|
||||
const position = { anchor: 'eaaaaab', position: 20 };
|
||||
const conditions = ['이도윤', '', '', '', '', '', false];
|
||||
const withConditions = encodeCursor(position, { text: '회의', conditions });
|
||||
|
||||
expect(
|
||||
decodeCursor(withConditions, {
|
||||
text: '회의',
|
||||
conditions: [...conditions],
|
||||
}),
|
||||
).toEqual(position);
|
||||
for (const scope of [
|
||||
{ text: '회의' },
|
||||
{ text: '회의', conditions: ['김아라', '', '', '', '', '', false] },
|
||||
{ text: '회의', conditions: ['이도윤', '', '', '', '', '', true] },
|
||||
]) {
|
||||
expect(codeOf(() => decodeCursor(withConditions, scope))).toBe('VAL_001');
|
||||
}
|
||||
// 조건이 없으면 예전 지문 — 3단계의 커서가 그대로 쓰인다
|
||||
const plain = encodeCursor(position, { text: '회의' });
|
||||
expect(decodeCursor(plain, { text: '회의' })).toEqual(position);
|
||||
});
|
||||
|
||||
it('조건의 지문은 JSON 으로 잇는다 — 글 칸에 줄바꿈을 넣어 다른 칸 · 검색어를 흉내 내도 다른 목록이다', () => {
|
||||
const position = { anchor: 'eaaaaab', position: 20 };
|
||||
const NL = String.fromCharCode(10);
|
||||
const fromTwoLines = encodeCursor(position, {
|
||||
text: '회의',
|
||||
conditions: [`a${NL}b`, '', '', '', '', '', false],
|
||||
});
|
||||
expect(
|
||||
codeOf(() =>
|
||||
decodeCursor(fromTwoLines, {
|
||||
text: '회의',
|
||||
conditions: ['a', 'b', '', '', '', '', false],
|
||||
}),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
// 검색어 끝에 줄바꿈 + 조건처럼 보이는 글을 넣어도 조건이 있는 검색과 섞이지 않는다
|
||||
const textOnly = encodeCursor(position, {
|
||||
text: `회의${NL}${JSON.stringify(['a', '', '', '', '', '', false])}`,
|
||||
});
|
||||
expect(
|
||||
codeOf(() =>
|
||||
decodeCursor(textOnly, {
|
||||
text: '회의',
|
||||
conditions: ['a', '', '', '', '', '', false],
|
||||
}),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface CursorScope {
|
||||
/** 메일함 — 없으면 휴지통·스팸메일함을 뺀 모든 메일함 */
|
||||
readonly mailboxId?: string;
|
||||
readonly text?: string;
|
||||
/**
|
||||
* 조건 검색의 조건(14단계 — 정해진 차례의 값들). 없으면(조건 없는 검색 · 목록) 지문은 예전과 같다 — 배포 전에 받은 커서도 그대로 쓴다.
|
||||
* 있으면 지문은 [메일함, 검색어, 조건] 의 JSON 이다 — 글 칸에 줄바꿈이 들어도 칸의 경계가 섞이지 않는다
|
||||
*/
|
||||
readonly conditions?: readonly (string | boolean)[];
|
||||
}
|
||||
|
||||
export interface CursorPosition {
|
||||
@@ -47,8 +52,15 @@ interface EncodedCursor {
|
||||
}
|
||||
|
||||
function scopeHash(scope: CursorScope): string {
|
||||
const mailbox = scope.mailboxId ?? ALL_MAILBOXES_SCOPE;
|
||||
const text = scope.text ?? '';
|
||||
// 조건이 없으면 예전 모양("<메일함>\n<검색어>") — 메일함 id 에는 줄바꿈이 없어 섞이지 않는다. 조건이 있으면 JSON(늘 "[" 로 시작 — 메일함 id 와 겹치지 않는다)
|
||||
const material =
|
||||
scope.conditions === undefined
|
||||
? `${mailbox}\n${text}`
|
||||
: JSON.stringify([mailbox, text, scope.conditions]);
|
||||
return createHash('sha256')
|
||||
.update(`${scope.mailboxId ?? ALL_MAILBOXES_SCOPE}\n${scope.text ?? ''}`)
|
||||
.update(material)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
MAX_ENVELOPE_RESUBMITS,
|
||||
MAX_SEARCH_WORDS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { SendNotStartedException } from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from './jmap.client';
|
||||
import type { JmapClient, JmapSession } from './jmap.client';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { JmapConversations } from './jmap-conversations';
|
||||
import { JmapFilterRunner } from './jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
|
||||
@@ -38,7 +40,8 @@ import {
|
||||
parseLargeFileIds,
|
||||
} from './jmap-mail.provider';
|
||||
import { MAILBOX_PROPERTIES } from './jmap.mapper';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
import type { JmapEmailFilter, JmapInvocation } from './jmap.types';
|
||||
import { particleForms } from './search-terms';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
@@ -194,6 +197,7 @@ function fakeClient(
|
||||
overrides.settings ?? settings,
|
||||
new JmapFilterScriptStore(client as unknown as JmapClient),
|
||||
new JmapFilterRunner(client as unknown as JmapClient),
|
||||
new JmapConversations(client as unknown as JmapClient),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -871,6 +875,298 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('조건 검색 · 색인 검색의 엔진 쪽 (14단계)', () => {
|
||||
const searchResponses = (
|
||||
ids: string[],
|
||||
total: number,
|
||||
): JmapInvocation[] => [
|
||||
['Email/query', { ids, position: 0, total, queryState: 's' }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: ids.map((id) => summaryEmail(id, '2026-10-26T09:00:00Z')),
|
||||
notFound: [],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
];
|
||||
const mailboxRoles: JmapInvocation[] = [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ list: stalwartMailboxes.map(({ id, role }) => ({ id, role })) },
|
||||
'm',
|
||||
],
|
||||
];
|
||||
|
||||
it('조건은 칸마다 엔진 필터로 — 받는 사람은 to · cc 가운데 하나, 기간은 받은 시각(after 이상 · before 미만), 첨부는 hasAttachment', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses(['m1'], 1),
|
||||
]);
|
||||
|
||||
const page = await provider.listMessages(ARA, {
|
||||
limit: 20,
|
||||
conditions: {
|
||||
from: '이도윤',
|
||||
to: 'ara',
|
||||
subject: '"계약 갱신"',
|
||||
body: '견적',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
before: '2026-11-30T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { filter } = argsOf(recorded[1]) as {
|
||||
filter: { operator: string; conditions: unknown[] };
|
||||
};
|
||||
expect(filter.operator).toBe('AND');
|
||||
expect(filter.conditions[0]).toEqual({
|
||||
inMailboxOtherThan: ['b', 'c'],
|
||||
notKeyword: '$ara-sending',
|
||||
});
|
||||
expect(filter.conditions.slice(1)).toEqual([
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: particleForms('이도윤').map((from) => ({ from })),
|
||||
},
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: particleForms('ara').flatMap((form) => [
|
||||
{ to: form },
|
||||
{ cc: form },
|
||||
]),
|
||||
},
|
||||
{ subject: '"계약 갱신"' },
|
||||
{
|
||||
operator: 'OR',
|
||||
conditions: particleForms('견적').map((body) => ({ body })),
|
||||
},
|
||||
{ after: '2026-10-31T15:00:00Z' },
|
||||
{ before: '2026-11-30T15:00:00Z' },
|
||||
{ hasAttachment: true },
|
||||
]);
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1']);
|
||||
});
|
||||
|
||||
it('검색어와 조건은 함께 AND — 커서 지문에 조건이 들어가 조건이 다른 검색의 커서는 엔진에 묻기 전에 VAL_001', async () => {
|
||||
const conditions = { from: '이도윤', hasAttachment: true as const };
|
||||
const first = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses(['m1', 'm2'], 3),
|
||||
]);
|
||||
const page = await first.provider.listMessages(ARA, {
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
conditions,
|
||||
});
|
||||
const { filter } = argsOf(first.recorded[1]) as {
|
||||
filter: { conditions: unknown[] };
|
||||
};
|
||||
// 범위 · 검색어 · 보낸 사람 · 첨부
|
||||
expect(filter.conditions).toHaveLength(4);
|
||||
const cursor = page.nextCursor as string;
|
||||
expect(cursor).not.toBeNull();
|
||||
|
||||
for (const query of [
|
||||
{ limit: 2, text: '회의', cursor },
|
||||
{ limit: 2, text: '회의', cursor, conditions: { from: '이도윤' } },
|
||||
{
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
cursor,
|
||||
conditions: { ...conditions, before: '2026-11-30T15:00:00.000Z' },
|
||||
},
|
||||
]) {
|
||||
const other = fakeClient([]);
|
||||
expect(await codeOf(other.provider.listMessages(ARA, query))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(other.client.call).not.toHaveBeenCalled();
|
||||
}
|
||||
const next = fakeClient([mailboxRoles, searchResponses(['m3'], 3)]);
|
||||
await next.provider.listMessages(ARA, {
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
cursor,
|
||||
conditions,
|
||||
});
|
||||
expect(argsOf(next.recorded[1])).toMatchObject({ anchor: 'm2' });
|
||||
});
|
||||
|
||||
it('조건만 있는 검색(검색어 없음)도 모든 메일함에서 · 조건 없는 검색의 커서 지문은 예전과 같다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses([], 0),
|
||||
]);
|
||||
await provider.listMessages(ARA, {
|
||||
limit: 20,
|
||||
conditions: { hasAttachment: true },
|
||||
});
|
||||
const { filter } = argsOf(recorded[1]) as {
|
||||
filter: { conditions: unknown[] };
|
||||
};
|
||||
expect(filter.conditions).toEqual([
|
||||
{ inMailboxOtherThan: ['b', 'c'], notKeyword: '$ara-sending' },
|
||||
{ hasAttachment: true },
|
||||
]);
|
||||
|
||||
const plainCursor = encodeCursor(
|
||||
{ anchor: 'm2', position: 2 },
|
||||
{ text: '회의' },
|
||||
);
|
||||
const plain = fakeClient([mailboxRoles, searchResponses([], 0)]);
|
||||
await expect(
|
||||
plain.provider.listMessages(ARA, {
|
||||
limit: 2,
|
||||
text: '회의',
|
||||
cursor: plainCursor,
|
||||
conditions: {},
|
||||
}),
|
||||
).resolves.toMatchObject({ items: [] });
|
||||
});
|
||||
|
||||
it(`엔진 방식의 가장 큰 필터를 고정한다 — 합친 낱말 ${MAX_SEARCH_WORDS}개가 모두 받는 사람(to · cc 둘로 묻는다)이고 영문 · 숫자로 끝나면(형태가 가장 많다) 잎 948개 (리뷰 M2)`, async () => {
|
||||
/** 필터의 잎(연산자가 아닌 조건) 수 */
|
||||
const leaves = (filter: JmapEmailFilter): number =>
|
||||
'operator' in filter
|
||||
? filter.conditions.reduce((sum, child) => sum + leaves(child), 0)
|
||||
: 1;
|
||||
const words = Array.from(
|
||||
{ length: MAX_SEARCH_WORDS },
|
||||
(_, index) => `word${index}`,
|
||||
).join(' ');
|
||||
const filterOf = async (query: {
|
||||
text?: string;
|
||||
conditions?: Record<string, unknown>;
|
||||
}) => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxRoles,
|
||||
searchResponses([], 0),
|
||||
]);
|
||||
await provider.listMessages(ARA, { limit: 20, ...query });
|
||||
return (argsOf(recorded[1]) as { filter: JmapEmailFilter }).filter;
|
||||
};
|
||||
const forms = particleForms('word0').length;
|
||||
expect(forms).toBe(59);
|
||||
|
||||
const worst = await filterOf({
|
||||
conditions: {
|
||||
to: words,
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
before: '2026-11-30T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
},
|
||||
});
|
||||
// 낱말 8 × 형태 59 × 칸 2(to · cc) + 범위 1 + 기간 2 + 첨부 1
|
||||
expect(leaves(worst)).toBe(MAX_SEARCH_WORDS * forms * 2 + 4);
|
||||
expect(leaves(worst)).toBe(948);
|
||||
// 같은 낱말을 검색어로 두면 칸이 하나 — 3단계에서 잰 472 + 범위
|
||||
expect(leaves(await filterOf({ text: words }))).toBe(
|
||||
MAX_SEARCH_WORDS * forms + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('글 조건에 찾을 낱말이 하나도 없으면(문장부호뿐) 엔진에 묻지 않고 빈 결과', async () => {
|
||||
for (const conditions of [{ from: '…' }, { body: '""' }, { to: '-' }]) {
|
||||
const { provider, client } = fakeClient([]);
|
||||
await expect(
|
||||
provider.listMessages(ARA, { limit: 20, conditions }),
|
||||
).resolves.toEqual({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('색인 검색의 범위 (searchScope) — 모든 메일함이면 휴지통 · 스팸메일함을 빼는 메일함으로, 내부 초안 키워드는 늘 뺀다', async () => {
|
||||
const { provider, recorded } = fakeClient([mailboxRoles]);
|
||||
|
||||
await expect(provider.searchScope(ARA)).resolves.toEqual({
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
});
|
||||
expect(argsOf(recorded[0])).toEqual({
|
||||
accountId: 'd',
|
||||
ids: null,
|
||||
properties: ['id', 'role'],
|
||||
});
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'MAIL_001' });
|
||||
});
|
||||
|
||||
it('색인 검색의 범위 — 메일함 범위면 그 메일함이 있는지만 본다(없으면 404 RES_001 · 엔진이 해석하지 못하는 id 도 RES_001)', async () => {
|
||||
const found = fakeClient([
|
||||
[['Mailbox/get', { list: [{ id: 'a', role: 'inbox' }] }, 'm']],
|
||||
]);
|
||||
await expect(found.provider.searchScope(ARA, 'a')).resolves.toEqual({
|
||||
mailboxId: 'a',
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
});
|
||||
expect(argsOf(found.recorded[0])).toMatchObject({ ids: ['a'] });
|
||||
expect(found.recorded[0].options).toEqual({
|
||||
invalidRequestCode: 'RES_001',
|
||||
});
|
||||
|
||||
const missing = fakeClient([
|
||||
[['Mailbox/get', { list: [], notFound: ['zz'] }, 'm']],
|
||||
]);
|
||||
expect(await codeOf(missing.provider.searchScope(ARA, 'zz'))).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('찾은 쪽의 요약 (getSummaries) — 목록과 같은 속성 · 기다리는 발송을 한 요청에, 준 차례대로 · 없는 메일 · 형식이 틀린 id 는 빠진다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
summaryEmail('m2', '2026-10-26T08:00:00Z'),
|
||||
summaryEmail('m1', '2026-10-26T09:00:00Z'),
|
||||
],
|
||||
notFound: ['m3'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...heldGuard(),
|
||||
],
|
||||
]);
|
||||
|
||||
const summaries = await provider.getSummaries(ARA, [
|
||||
'm1',
|
||||
'm3',
|
||||
'../x',
|
||||
'm2',
|
||||
'm1',
|
||||
]);
|
||||
|
||||
expect(summaries.map((summary) => summary.id)).toEqual(['m1', 'm2']);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
subject: '제목 m1',
|
||||
pendingSend: null,
|
||||
});
|
||||
expect(recorded[0].calls.map(([method]) => method)).toEqual([
|
||||
'Email/get',
|
||||
'EmailSubmission/query',
|
||||
'EmailSubmission/get',
|
||||
]);
|
||||
expect(argsOf(recorded[0])).toMatchObject({ ids: ['m1', 'm3', 'm2'] });
|
||||
});
|
||||
|
||||
it('요약할 id 가 없으면 엔진에 묻지 않는다', async () => {
|
||||
const { provider, client } = fakeClient([]);
|
||||
await expect(provider.getSummaries(ARA, [])).resolves.toEqual([]);
|
||||
await expect(provider.getSummaries(ARA, ['../x'])).resolves.toEqual([]);
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 상세 (getMessage)', () => {
|
||||
const detailEmail = {
|
||||
...summaryEmail('m1', '2026-10-26T05:12:00Z', { hasAttachment: true }),
|
||||
|
||||
@@ -16,9 +16,15 @@ import {
|
||||
type MailProvider,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
SearchConditions,
|
||||
SearchScope,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
ConversationListQuery,
|
||||
ConversationSummaryPage,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
@@ -62,6 +68,7 @@ import type {
|
||||
SendHold,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
SummariesWithHeld,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from '@/modules/mail/mail.types';
|
||||
@@ -91,6 +98,7 @@ import {
|
||||
submissionEnvelope,
|
||||
} from './envelope';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { JmapConversations } from './jmap-conversations';
|
||||
import { JmapFilterRunner } from './jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import {
|
||||
@@ -126,6 +134,7 @@ import {
|
||||
selectAccountQuota,
|
||||
toAddresses,
|
||||
toDetailSource,
|
||||
toJmapUtcDate,
|
||||
toMailbox,
|
||||
toSummary,
|
||||
toUsage,
|
||||
@@ -148,14 +157,13 @@ import type {
|
||||
JmapSetResult,
|
||||
} from './jmap.types';
|
||||
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
|
||||
import { INTERNAL_SEND_KEYWORD, excludedMailboxIdsOf } from './mail-scope';
|
||||
import {
|
||||
CANCELED_SEND_KEYWORD,
|
||||
HELD_SUBMISSIONS_MAX,
|
||||
SCHEDULED_SEND_KEYWORD,
|
||||
heldAmong,
|
||||
heldEmailsFilter,
|
||||
heldOverLimit,
|
||||
heldQueryFull,
|
||||
heldSendTimes,
|
||||
heldSubmissionFilter,
|
||||
holdParameters,
|
||||
@@ -164,11 +172,17 @@ import {
|
||||
readHeldEmails,
|
||||
readHeldSendTimes,
|
||||
submissionCalls,
|
||||
warnIfHeldQueryFull,
|
||||
submittedPendingSend,
|
||||
type HeldEmails,
|
||||
type HeldLookup,
|
||||
} from './pending-send';
|
||||
import { searchTextConditions, withSearchConditions } from './search-terms';
|
||||
import {
|
||||
searchFieldConditions,
|
||||
searchTextConditions,
|
||||
withSearchConditions,
|
||||
type SearchFieldProperty,
|
||||
} from './search-terms';
|
||||
|
||||
const QUOTA_CAPABILITY = 'urn:ietf:params:jmap:quota';
|
||||
/** Blob/get (RFC 9404) — 첨부 합계 크기를 발송 전에 잰다 */
|
||||
@@ -182,15 +196,6 @@ const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
|
||||
const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
|
||||
const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
|
||||
|
||||
/**
|
||||
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
|
||||
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 —
|
||||
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
|
||||
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
|
||||
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
|
||||
*/
|
||||
const INTERNAL_SEND_KEYWORD = '$ara-sending';
|
||||
|
||||
/**
|
||||
* 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장").
|
||||
* 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다.
|
||||
@@ -273,8 +278,60 @@ const BULK_BATCH_SIZE = 500;
|
||||
const SOURCE_TYPE = 'message/rfc822';
|
||||
const SOURCE_DOWNLOAD_NAME = 'message.eml';
|
||||
|
||||
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
|
||||
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
|
||||
/** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */
|
||||
const CONDITION_PROPERTIES: readonly (readonly [
|
||||
keyof Pick<SearchConditions, 'from' | 'to' | 'subject' | 'body'>,
|
||||
readonly SearchFieldProperty[],
|
||||
])[] = [
|
||||
['from', ['from']],
|
||||
['to', ['to', 'cc']],
|
||||
['subject', ['subject']],
|
||||
['body', ['body']],
|
||||
];
|
||||
|
||||
/** 조건 → 커서 지문에 넣는 값들 (정해진 차례 — jmap-cursor.ts 가 JSON 으로 잇는다) — 조건이 없으면 undefined (지문이 예전과 같다) */
|
||||
function conditionsKey(
|
||||
conditions: SearchConditions | undefined,
|
||||
): (string | boolean)[] | undefined {
|
||||
if (!conditions) return undefined;
|
||||
const parts = [
|
||||
conditions.from ?? '',
|
||||
conditions.to ?? '',
|
||||
conditions.subject ?? '',
|
||||
conditions.body ?? '',
|
||||
conditions.after ?? '',
|
||||
conditions.before ?? '',
|
||||
conditions.hasAttachment === true,
|
||||
];
|
||||
return parts.some((part) => part !== '' && part !== false)
|
||||
? parts
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 → 엔진 필터 조건들 (AND 로 묶어 쓴다) — 글 칸은 검색어와 같은 낱말 · 구절 · 조사 형태, 기간은 받은 시각(after 이상 · before 미만), 첨부는 엔진의 판정.
|
||||
* 글을 준 칸에 찾을 낱말이 하나도 없으면 null — 아무것도 맞지 않는다(묻지 않고 빈 결과)
|
||||
*/
|
||||
function conditionFilters(
|
||||
conditions: SearchConditions | undefined,
|
||||
): JmapEmailFilter[] | null {
|
||||
if (!conditions) return [];
|
||||
const filters: JmapEmailFilter[] = [];
|
||||
for (const [name, properties] of CONDITION_PROPERTIES) {
|
||||
const value = conditions[name];
|
||||
if (!value) continue;
|
||||
const found = searchFieldConditions(value, properties);
|
||||
if (found.length === 0) return null;
|
||||
filters.push(...found);
|
||||
}
|
||||
if (conditions.after)
|
||||
filters.push({ after: toJmapUtcDate(conditions.after) });
|
||||
if (conditions.before) {
|
||||
filters.push({ before: toJmapUtcDate(conditions.before) });
|
||||
}
|
||||
if (conditions.hasAttachment === true) filters.push({ hasAttachment: true });
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청 하나가 엔진을 기다리는 전체 시간 — 클라이언트의 발송 시간 한도(60초)보다 짧게 둔다
|
||||
@@ -807,6 +864,8 @@ export class JmapMailProvider implements MailProvider {
|
||||
private readonly filterScripts: JmapFilterScriptStore,
|
||||
/** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */
|
||||
private readonly filterRuns: JmapFilterRunner,
|
||||
/** 엔진의 스레드로 본 대화 (jmap-conversations.ts — 14단계, 자체 색인을 다 만들기 전) */
|
||||
private readonly conversations: JmapConversations,
|
||||
) {}
|
||||
|
||||
verifyCredentials(email: string, password: string): Promise<CredentialCheck> {
|
||||
@@ -1419,15 +1478,23 @@ export class JmapMailProvider implements MailProvider {
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
): Promise<MessagePage> {
|
||||
const scope = { mailboxId: query.mailboxId, text: query.text };
|
||||
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어의 커서면 VAL_001
|
||||
const scope = {
|
||||
mailboxId: query.mailboxId,
|
||||
text: query.text,
|
||||
conditions: conditionsKey(query.conditions),
|
||||
};
|
||||
// 커서는 엔진에 묻기 전에 본다 — 다른 메일함·검색어·조건의 커서면 VAL_001
|
||||
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
|
||||
|
||||
// 검색어에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
|
||||
// 검색어 · 조건의 글에 찾을 낱말(문자·숫자)이 하나도 없으면(`-` · `…` 등) 엔진에 묻지 않고 빈 결과 — 범위 전체를 돌려주지 않는다.
|
||||
// 메일함 범위여도 그 메일함을 확인하지 않는다 (계약 2절 "검색")
|
||||
const textConditions =
|
||||
query.text !== undefined ? searchTextConditions(query.text) : [];
|
||||
if (query.text !== undefined && textConditions.length === 0) {
|
||||
const fieldConditions = conditionFilters(query.conditions);
|
||||
if (
|
||||
(query.text !== undefined && textConditions.length === 0) ||
|
||||
fieldConditions === null
|
||||
) {
|
||||
return { items: [], hasNext: false, nextCursor: null, total: 0 };
|
||||
}
|
||||
|
||||
@@ -1439,7 +1506,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
: await this.searchableMailboxes(user);
|
||||
const filter = withSearchConditions(
|
||||
{ ...range, notKeyword: INTERNAL_SEND_KEYWORD },
|
||||
textConditions,
|
||||
[...textConditions, ...fieldConditions],
|
||||
);
|
||||
|
||||
// 기다리는 발송(보내기 취소 · 예약 — 9단계)은 같은 요청에서 보낼 시각이 지금 뒤인 제출로 묻는다 (pending-send.ts)
|
||||
@@ -1489,7 +1556,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
const start = result.position ?? 0;
|
||||
const total = typeof result.total === 'number' ? result.total : null;
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
this.warnIfHeldQueryFull(responses, user);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
|
||||
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
@@ -1516,6 +1583,160 @@ export class JmapMailProvider implements MailProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 자체 색인으로 찾을 범위 (14단계) — 메일함 범위면 그 메일함이 있는지(없으면 RES_001 — 엔진이 해석하지 못하는 id 도), 모든 메일함이면 빼는 메일함(휴지통 · 스팸메일함).
|
||||
* 발송 중인 내부 초안의 키워드는 늘 뺀다 (목록과 같다). 엔진 요청 한 번
|
||||
*/
|
||||
async searchScope(user: string, mailboxId?: string): Promise<SearchScope> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{
|
||||
accountId,
|
||||
ids: mailboxId === undefined ? null : [mailboxId],
|
||||
properties: ['id', 'role'],
|
||||
},
|
||||
'm',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: mailboxId === undefined ? 'MAIL_001' : 'RES_001' },
|
||||
);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (mailboxId !== undefined) {
|
||||
if (!mailboxes.some((mailbox) => mailbox.id === mailboxId)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
mailboxId,
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: [INTERNAL_SEND_KEYWORD],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
|
||||
excludedKeywords: [INTERNAL_SEND_KEYWORD],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다(대화의 통 수 · 같은 대화).
|
||||
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001 — 엔진이 해석하지 못하는 id 도). 엔진 요청 한 번
|
||||
*/
|
||||
async conversationScope(
|
||||
user: string,
|
||||
mailboxId?: string,
|
||||
): Promise<SearchScope> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'MAIL_001' },
|
||||
);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (
|
||||
mailboxId !== undefined &&
|
||||
!mailboxes.some((mailbox) => mailbox.id === mailboxId)
|
||||
) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
mailboxId: mailboxId ?? null,
|
||||
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
|
||||
excludedKeywords: [INTERNAL_SEND_KEYWORD],
|
||||
};
|
||||
}
|
||||
|
||||
getConversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage> {
|
||||
return this.conversations.conversation(user, emailId, page);
|
||||
}
|
||||
|
||||
listConversations(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage> {
|
||||
return this.conversations.list(user, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 여러 통 (14단계 — 색인으로 찾은 쪽) — 목록과 같은 속성 · 기다리는 발송. `ids` 차례대로, 없는 메일은 빠진다.
|
||||
* 엔진이 해석하지 못하는 id 는 받지 않는다(색인의 id 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다)
|
||||
*/
|
||||
async getSummaries(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageSummary[]> {
|
||||
if (!ids.some((id) => MAIL_ID_PATTERN.test(id))) return [];
|
||||
return (await this.getSummariesWithHeld(user, ids)).summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식) — 같은 요청에서 계정의 기다리는 제출을 한 번 읽는다(보이기 경로 —
|
||||
* readHeldSendTimes, 읽지 못하면 빈 표). 요약이 없어도(ids 가 비거나 모두 형식이 틀림) 기다리는 제출은 읽는다
|
||||
*/
|
||||
async getSummariesWithHeld(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<SummariesWithHeld> {
|
||||
const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id));
|
||||
const now = Date.now();
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
...(valid.length > 0
|
||||
? ([
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: valid,
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
] satisfies JmapInvocation[])
|
||||
: []),
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
]);
|
||||
const list =
|
||||
valid.length > 0
|
||||
? (expectResult<JmapGetResult<JmapEmail>>(responses, 'g', 'Email/get')
|
||||
.list ?? [])
|
||||
: [];
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
return {
|
||||
summaries: valid.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
|
||||
}),
|
||||
heldIds: new Set(held.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 — 같은 요청에서 메일함 역할(스팸메일함에 들었는지 — 외부 이미지 설정)과 그 메일의 제출(기다리는 발송 — 9단계)을 함께 묻는다.
|
||||
* 메일함 · 제출을 읽지 못하면 스팸메일함이 아니고 기다리는 발송이 없는 것으로 본다 — 상세를 실패로 만들지 않는다
|
||||
@@ -2605,7 +2826,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
this.warnIfHeldQueryFull(responses, user);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const times = heldSendTimes(submissions, now);
|
||||
return emails
|
||||
.flatMap((email) => {
|
||||
@@ -3615,11 +3836,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const excluded = mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id);
|
||||
const excluded = excludedMailboxIdsOf(mailboxes);
|
||||
return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {};
|
||||
}
|
||||
|
||||
@@ -3878,17 +4095,6 @@ export class JmapMailProvider implements MailProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull)
|
||||
* — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX)
|
||||
*/
|
||||
private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void {
|
||||
if (!heldQueryFull(responses, HELD_CALLS)) return;
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */
|
||||
private async submissionsOf(
|
||||
user: string,
|
||||
|
||||
@@ -221,8 +221,18 @@ export interface JmapEmailFilterCondition {
|
||||
notKeyword?: string;
|
||||
/** 받은 시각이 이보다 앞선(미만) 메일 — UTCDate. 엔진은 받은 시각을 초 단위로 둔다 (2026-10-29 확인) */
|
||||
before?: string;
|
||||
/** 받은 시각이 이 이후(이상)인 메일 — UTCDate (14단계 조건 검색의 기간) */
|
||||
after?: string;
|
||||
/** 보낸 사람·받는 사람·참조·제목·본문 전문 검색 — 큰따옴표로 감싼 값은 구절로 찾는다 */
|
||||
text?: string;
|
||||
/** 칸마다의 전문 검색 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — 낱말 통째 일치는 text 와 같다 */
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
/** 엔진의 첨부 판정 (본문 그림만 있어도 true 일 수 있다 — 색인 방식과 조금 다르다) */
|
||||
hasAttachment?: boolean;
|
||||
}
|
||||
|
||||
/** FilterOperator (RFC 8620 §5.5) — 조건들을 AND · OR · NOT 으로 묶는다 */
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { JmapMailbox } from './jmap.types';
|
||||
|
||||
/**
|
||||
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
|
||||
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록 · 검색 · 대화는 이 키워드가 있는 메일을 보이지 않는다 —
|
||||
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
|
||||
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
|
||||
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
|
||||
*/
|
||||
export const INTERNAL_SEND_KEYWORD = '$ara-sending';
|
||||
|
||||
/** 메일함을 정하지 않은 검색 · 같은 대화 · 대화의 통 수에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
|
||||
export const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'trash',
|
||||
'junk',
|
||||
]);
|
||||
|
||||
/** 빼는 역할(휴지통 · 스팸메일함)의 메일함 id */
|
||||
export function excludedMailboxIdsOf(
|
||||
mailboxes: readonly Pick<JmapMailbox, 'id' | 'role'>[],
|
||||
): string[] {
|
||||
return mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { PendingSend, SendHold } from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { toJmapUtcDate, toUtcIso } from './jmap.mapper';
|
||||
import { expectResult, findResult } from './jmap-results';
|
||||
import type {
|
||||
@@ -212,6 +216,22 @@ export function heldQueryFull(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이기 경로(목록 · 예약 목록 · 대화)의 계정 전체 제출 조회가 다 주지 못했으면(heldQueryFull) 경고만 남긴다 — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다.
|
||||
* 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX). 로그는 코드 · 추적 ID · 가린 주소만
|
||||
*/
|
||||
export function warnIfHeldQueryFull(
|
||||
logger: Logger,
|
||||
responses: JmapInvocation[],
|
||||
prefix: string,
|
||||
user: string,
|
||||
): void {
|
||||
if (!heldQueryFull(responses, prefix)) return;
|
||||
logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */
|
||||
export interface HeldEmails {
|
||||
/** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SEARCH_PARTICLES,
|
||||
parseSearchTerms,
|
||||
particleForms,
|
||||
searchFieldConditions,
|
||||
searchTextConditions,
|
||||
withSearchConditions,
|
||||
} from './search-terms';
|
||||
@@ -365,4 +366,57 @@ describe('검색어 → 엔진 조건 (search-terms)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('칸 조건 (searchFieldConditions — 14단계 조건 검색의 엔진 방식)', () => {
|
||||
it('칸 하나 — 낱말은 그 칸의 형태 OR, 구절은 그 칸의 따옴표 구절', () => {
|
||||
const [word, phrase] = searchFieldConditions('계약 "갱신 일정"', [
|
||||
'subject',
|
||||
]);
|
||||
expect(word).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: particleForms('계약').map((subject) => ({ subject })),
|
||||
});
|
||||
expect(phrase).toEqual({ subject: '"갱신 일정"' });
|
||||
});
|
||||
|
||||
it('칸이 여럿(받는 사람 = to · cc)이면 형태마다 두 칸의 OR — 낱말끼리는 AND(목록의 차례)', () => {
|
||||
const conditions = searchFieldConditions('ara 東京', ['to', 'cc']);
|
||||
expect(conditions).toHaveLength(2);
|
||||
expect(conditions[0]).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: particleForms('ara').flatMap((form) => [
|
||||
{ to: form },
|
||||
{ cc: form },
|
||||
]),
|
||||
});
|
||||
// 형태가 하나뿐이어도 칸이 둘이면 OR
|
||||
expect(conditions[1]).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: [{ to: '東京' }, { cc: '東京' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('검색어 조건(searchTextConditions)은 text 칸 하나의 칸 조건과 같다 · 찾을 낱말이 없으면 빈 목록', () => {
|
||||
expect(searchTextConditions('서버 "이전 계획"')).toEqual(
|
||||
searchFieldConditions('서버 "이전 계획"', ['text']),
|
||||
);
|
||||
expect(searchFieldConditions('… -', ['body'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('검색 글의 NUL 은 U+FFFD 로 1:1 바꾼다 — 색인의 저장과 같고 엔진 방식도 같은 낱말로 묻는다 (리뷰 L2)', () => {
|
||||
const NUL = String.fromCharCode(0);
|
||||
const FFFD = String.fromCharCode(0xfffd);
|
||||
expect(parseSearchTerms(`계${NUL}약 "a${NUL}b"`)).toEqual([
|
||||
{ kind: 'word', text: `계${FFFD}약` },
|
||||
{ kind: 'phrase', text: `a${FFFD}b` },
|
||||
]);
|
||||
// 엔진에 보내는 조건(JSON)에 NUL 이 없다 — 이스케이프한 꼴도 없다
|
||||
const sent = JSON.stringify(searchTextConditions(`계${NUL}약`));
|
||||
expect(sent.includes(NUL)).toBe(false);
|
||||
expect(sent).not.toContain(`${String.fromCharCode(92)}u0000`);
|
||||
expect(sent).toContain(`계${FFFD}약`);
|
||||
// NUL 만이면 찾을 글자가 없다 — 빈 목록
|
||||
expect(parseSearchTerms(NUL.repeat(3))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SEARCH_WORD_SEPARATOR } from '@/modules/mail/mail.constants';
|
||||
import { parseSearchTerms } from '@/shared/utils/search-query';
|
||||
import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
|
||||
|
||||
/**
|
||||
@@ -14,6 +14,9 @@ import type { JmapEmailFilter, JmapEmailFilterCondition } from './jmap.types';
|
||||
* 제목 맨 앞의 말머리('[공지]' · 'Re:' — 엔진이 제목을 정규화하며 색인에서 뺀다)는 찾지 못한다.
|
||||
*/
|
||||
|
||||
/** 검색어 나누기는 엔진과 상관없어 shared/utils/search-query.ts 로 옮겼다(14단계 — 색인 찾기와 함께 쓴다) */
|
||||
export { parseSearchTerms, type SearchTerm } from '@/shared/utils/search-query';
|
||||
|
||||
/**
|
||||
* 낱말 뒤에 붙여 함께 찾는 조사 — 서버 상수는 이 표 하나다 (계약 2절).
|
||||
* 한글로 끝나는 낱말 하나가 형태 44개(낱말 그대로 + 28 + 15), 영문·숫자로 끝나면 59개가 된다
|
||||
@@ -89,6 +92,9 @@ export const SEARCH_PARTICLES = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** 큰따옴표 — 구절 조건(`{ text: '"…"' }`)을 감싼다 */
|
||||
const QUOTE = '"';
|
||||
|
||||
/** 한글 음절(가~힣) — 받침은 (코드 − 첫 음절) % 28 (0 = 받침 없음) */
|
||||
const HANGUL_SYLLABLE_FIRST = 0xac00;
|
||||
const HANGUL_SYLLABLE_LAST = 0xd7a3;
|
||||
@@ -102,29 +108,6 @@ const EURO = '으로';
|
||||
/** 영문·숫자로 끝나는 낱말 — 읽는 법(받침)을 알 수 없어 두 가지 조사를 다 붙인다 (ERP가 · ERP이) */
|
||||
const ASCII_ALNUM_END = /[A-Za-z0-9]$/;
|
||||
|
||||
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
|
||||
const QUOTE = '"';
|
||||
|
||||
/**
|
||||
* 찾을 수 있는 글자 — 문자(\p{L}: 한글·자모·영문 등)나 숫자(\p{N}).
|
||||
* 이것이 하나도 없는 낱말·구절(`-` · `…` · 그림 문자뿐)은 버린다 — 엔진은 문장부호를 낱말로 색인하지 않는다
|
||||
*/
|
||||
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u;
|
||||
|
||||
/**
|
||||
* 낱말 앞뒤에 붙은, 문자·숫자가 아닌 글자 — 엔진은 문장부호에서 낱말을 나누므로 떼고 가운데 낱말로 찾는다
|
||||
* ('회의,' · '(회의)' · '회의.' · '“회의”' → '회의' — 그래야 받침을 읽어 '회의를' 도 찾는다). 낱말 가운데의 문장부호('서버-이전')는 그대로 둔다.
|
||||
* 뒤쪽은 "문자·숫자·결합 문자가 아닌 글자 + 그 뒤의 결합 문자" 를 뗀다 — 글자에 붙은 결합 문자(태국어 성조 등)는 남기고,
|
||||
* 그림 문자와 그 이체 선택자(❤️ = U+2764 U+FE0F)는 함께 뗀다.
|
||||
*/
|
||||
const WORD_EDGE_PUNCTUATION =
|
||||
/^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu;
|
||||
|
||||
/** 낱말 앞뒤의 문장부호·기호를 뗀 가운데 낱말 — 문자·숫자가 없으면 빈 문자열 */
|
||||
function coreWord(word: string): string {
|
||||
return word.replace(WORD_EDGE_PUNCTUATION, '');
|
||||
}
|
||||
|
||||
/** 낱말 끝의 종류 — 어떤 조사를 붙일지 정한다 */
|
||||
type WordEnding =
|
||||
/** 받침 없는 한글 */
|
||||
@@ -186,61 +169,46 @@ export function particleForms(word: string): string[] {
|
||||
]);
|
||||
}
|
||||
|
||||
/** 검색어를 나눈 조각 — 낱말(조사를 붙여 찾는다) · 구절(큰따옴표 안, 그대로 찾는다) */
|
||||
export interface SearchTerm {
|
||||
readonly kind: 'word' | 'phrase';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
function splitWords(text: string): string[] {
|
||||
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 낱말·구절로 나눈다 — 나온 순서대로, 같은 낱말·같은 구절은 한 번만.
|
||||
* - 큰따옴표 한 쌍 안은 구절이다 (안의 여러 칸 공백은 한 칸으로)
|
||||
* - 짝이 없는 마지막 큰따옴표는 없는 것으로 본다 — 그 뒤는 따옴표 밖처럼 낱말로 나눈다
|
||||
* - 낱말은 띄어쓰기(공백 문자)로 나눈다 — DTO 가 세는 "낱말 수"와 같은 기준 (SEARCH_WORD_SEPARATOR)
|
||||
* - 낱말 앞뒤의 문장부호·기호는 떼고 가운데 낱말로 찾는다 ('회의,' → '회의', WORD_EDGE_PUNCTUATION). 구절은 그대로 둔다
|
||||
* - 문자·숫자가 하나도 없는 낱말·구절(비어 있음 · `-` · `…` 등)은 버린다 (SEARCHABLE_CHARACTER) —
|
||||
* 남는 것이 없으면 빈 목록이고, 목록 조회는 엔진에 묻지 않고 빈 결과를 돌려준다 (범위 전체를 돌려주지 않는다)
|
||||
* - 유니코드 NFC(완성형)로 정규화한다 — 자모를 풀어 쓴(NFD) 한글을 붙여 넣어도 받침을 읽는다
|
||||
*/
|
||||
export function parseSearchTerms(query: string): SearchTerm[] {
|
||||
const segments = query.normalize('NFC').split(QUOTE);
|
||||
const terms: SearchTerm[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: SearchTerm['kind'], text: string) => {
|
||||
const key = `${kind}\n${text}`;
|
||||
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
terms.push({ kind, text });
|
||||
};
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
|
||||
const isPhrase = index % 2 === 1 && index < segments.length - 1;
|
||||
if (isPhrase) {
|
||||
add('phrase', splitWords(segment).join(' '));
|
||||
return;
|
||||
}
|
||||
for (const word of splitWords(segment)) add('word', coreWord(word));
|
||||
});
|
||||
return terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어의 text 조건들 — 모두 맞아야 한다(AND 로 묶어 쓴다).
|
||||
* 구절은 `{ text: '"…"' }` 하나, 낱말은 형태들의 OR (형태가 하나뿐이면 그 조건 하나).
|
||||
* 찾을 낱말이 하나도 없으면(문장부호뿐 · 빈 따옴표 등) 빈 목록 — 호출한 쪽은 아무것도 찾지 않는다.
|
||||
*/
|
||||
export function searchTextConditions(query: string): JmapEmailFilter[] {
|
||||
return searchFieldConditions(query, ['text']);
|
||||
}
|
||||
|
||||
/** 글로 찾는 엔진 필터 칸 — text(모든 칸) · 보낸 사람 · 받는 사람 · 참조 · 제목 · 본문 */
|
||||
export type SearchFieldProperty =
|
||||
| 'text'
|
||||
| 'from'
|
||||
| 'to'
|
||||
| 'cc'
|
||||
| 'subject'
|
||||
| 'body';
|
||||
|
||||
/**
|
||||
* 칸 조건 (14단계 조건 검색 — 색인 준비 전의 엔진 방식) — searchTextConditions 와 같은 낱말 · 구절 · 조사 형태를 그 칸에서 찾는다.
|
||||
* 칸이 여럿이면(받는 사람 = to · cc) 그 가운데 하나에 있으면 된다 — 낱말마다 (형태 × 칸)의 OR, 낱말끼리는 AND.
|
||||
* 찾을 낱말이 하나도 없으면 빈 목록
|
||||
*/
|
||||
export function searchFieldConditions(
|
||||
query: string,
|
||||
properties: readonly SearchFieldProperty[],
|
||||
): JmapEmailFilter[] {
|
||||
return parseSearchTerms(query).map((term): JmapEmailFilter => {
|
||||
if (term.kind === 'phrase') return { text: `${QUOTE}${term.text}${QUOTE}` };
|
||||
const forms = particleForms(term.text);
|
||||
return forms.length === 1
|
||||
? { text: forms[0] }
|
||||
: { operator: 'OR', conditions: forms.map((text) => ({ text })) };
|
||||
const forms =
|
||||
term.kind === 'phrase'
|
||||
? [`${QUOTE}${term.text}${QUOTE}`]
|
||||
: particleForms(term.text);
|
||||
const conditions = forms.flatMap((form) =>
|
||||
properties.map(
|
||||
(property): JmapEmailFilterCondition => ({ [property]: form }),
|
||||
),
|
||||
);
|
||||
return conditions.length === 1
|
||||
? conditions[0]
|
||||
: { operator: 'OR', conditions };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -56,8 +56,8 @@ export const MAX_SEARCH_LENGTH = 200;
|
||||
/** 검색어의 낱말(띄어쓰기로 나눈 것) 최대 개수 — 낱말마다 조사를 붙인 형태 수십 개로 찾으므로 엔진 비용을 묶어 둔다 */
|
||||
export const MAX_SEARCH_WORDS = 8;
|
||||
|
||||
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수와 엔진 조건(jmap/search-terms.ts)이 같이 쓴다 */
|
||||
export const SEARCH_WORD_SEPARATOR = /\s+/u;
|
||||
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백·줄바꿈 포함). DTO 의 낱말 수 · 엔진 조건 · 색인 찾기가 같이 쓴다 (shared/utils/search-query.ts) */
|
||||
export { SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-query';
|
||||
|
||||
/**
|
||||
* 여러 통을 한꺼번에 다루는 요청(PATCH /api/messages · POST /api/message-deletions)의 id 최대 개수 —
|
||||
|
||||
@@ -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';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
DnsMailImageResolver,
|
||||
NodeMailImageTransport,
|
||||
} from './images/node-image-transport';
|
||||
import { JmapConversations } from './jmap/jmap-conversations';
|
||||
import { JmapFilterRunner } from './jmap/jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap/jmap-filter-script';
|
||||
import { JmapMailProvider } from './jmap/jmap-mail.provider';
|
||||
@@ -55,6 +57,9 @@ import {
|
||||
MailVacationController,
|
||||
} from './mailbox.controller';
|
||||
import { MessageDeletionController } from './message-deletion.controller';
|
||||
import { MessageListService } from './message-list.service';
|
||||
import { ConversationController } from './conversation.controller';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import { MessageController } from './message.controller';
|
||||
import { RecipientSuggestionController } from './recipient-suggestion.controller';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
@@ -127,6 +132,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
AuthSessionModule,
|
||||
// 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다
|
||||
SystemMailModule,
|
||||
// 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만)
|
||||
MailSearchModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
@@ -138,6 +145,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailFilterRunController,
|
||||
MailBlockController,
|
||||
MessageController,
|
||||
// 같은 대화 · 대화로 묶은 목록 (14단계)
|
||||
ConversationController,
|
||||
MessageDeletionController,
|
||||
ScheduledMessageController,
|
||||
MailAttachmentController,
|
||||
@@ -162,8 +171,12 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
JmapFilterScriptStore,
|
||||
// 이미 받은 메일에 필터 적용(받은편지함을 차례로 판정 · 표시 · 옮기기 — 7단계) — 어댑터 안에서만 쓴다
|
||||
JmapFilterRunner,
|
||||
JmapConversations,
|
||||
{ provide: MAIL_PROVIDER, useClass: JmapMailProvider },
|
||||
MailService,
|
||||
// 메일 목록 · 검색 (14단계 — 색인 방식 · 엔진 방식을 고르고 강조를 단다)
|
||||
MessageListService,
|
||||
MailConversationService,
|
||||
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
|
||||
MailEventsService,
|
||||
// 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
ConversationListQuery,
|
||||
ConversationSummaryPage,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
@@ -40,6 +43,7 @@ import type {
|
||||
SavedDraft,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
SummariesWithHeld,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
@@ -254,6 +258,51 @@ export interface MailProvider {
|
||||
*/
|
||||
listMessages(user: string, query: MessageListQuery): Promise<MessagePage>;
|
||||
|
||||
/**
|
||||
* 자체 색인으로 찾을 범위 (14단계 — 색인 방식 검색) — 메일함 하나(없으면 RES_001), 또는 휴지통 · 스팸메일함만 뺀 모든 메일함과
|
||||
* 목록에서 빼는 키워드(발송 중인 내부 초안). 엔진의 메일함 역할 · 키워드 이름은 어댑터만 안다
|
||||
*/
|
||||
searchScope(user: string, mailboxId?: string): Promise<SearchScope>;
|
||||
|
||||
/**
|
||||
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다.
|
||||
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001)
|
||||
*/
|
||||
conversationScope(user: string, mailboxId?: string): Promise<SearchScope>;
|
||||
|
||||
/**
|
||||
* 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때)
|
||||
*/
|
||||
getSummaries(user: string, ids: readonly string[]): Promise<MessageSummary[]>;
|
||||
|
||||
/**
|
||||
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식: 줄의 messageIds 가운데 붙잡힌 메일 pendingSendIds).
|
||||
* getSummaries 와 같은 요청 하나 — 기다리는 제출은 한 번만 읽는다
|
||||
*/
|
||||
getSummariesWithHeld(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<SummariesWithHeld>;
|
||||
|
||||
/**
|
||||
* 같은 대화 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되
|
||||
* 지금 보는 메일(`emailId`)은 넣는다. 그 메일이 없으면 RES_001. 커서는 이 메일의 대화에서만 쓴다(다르면 VAL_001)
|
||||
*/
|
||||
getConversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage>;
|
||||
|
||||
/**
|
||||
* 대화로 묶은 목록 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 그 메일함 안 마지막 받은 시각 최신부터. 없는 메일함은 RES_001.
|
||||
* 커서는 그 메일함의 묶은 목록에서만 쓴다(다르면 VAL_001)
|
||||
*/
|
||||
listConversations(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage>;
|
||||
|
||||
/**
|
||||
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
|
||||
*/
|
||||
|
||||
@@ -99,6 +99,12 @@ function fakeProvider(): ProviderMocks {
|
||||
getMailState: mockOf<'getMailState'>(),
|
||||
watchMail: mockOf<'watchMail'>(),
|
||||
listMessages: mockOf<'listMessages'>(),
|
||||
searchScope: mockOf<'searchScope'>(),
|
||||
conversationScope: mockOf<'conversationScope'>(),
|
||||
getSummaries: mockOf<'getSummaries'>(),
|
||||
getSummariesWithHeld: mockOf<'getSummariesWithHeld'>(),
|
||||
getConversation: mockOf<'getConversation'>(),
|
||||
listConversations: mockOf<'listConversations'>(),
|
||||
getMessage: mockOf<'getMessage'>(),
|
||||
updateMessage: mockOf<'updateMessage'>(),
|
||||
updateMessages: mockOf<'updateMessages'>(),
|
||||
@@ -281,9 +287,11 @@ interface NinthStepMocks {
|
||||
function fakeNinthStep(): NinthStepMocks {
|
||||
return {
|
||||
userSettings: {
|
||||
get: jest
|
||||
.fn<Promise<MailUserSettings>, [string]>()
|
||||
.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
|
||||
get: jest.fn<Promise<MailUserSettings>, [string]>().mockResolvedValue({
|
||||
undoSendSeconds: 0,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
}),
|
||||
},
|
||||
contacts: {
|
||||
hasEmail: jest
|
||||
@@ -452,59 +460,7 @@ describe('MailService', () => {
|
||||
expect(await codeOf(() => service.getQuota(user))).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('검색어 앞뒤 공백을 지우고, 비면 검색하지 않는다', async () => {
|
||||
provider.listMessages.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
await service.listMessages(user, { mailboxId: 'a', limit: 20, q: ' ' });
|
||||
await service.listMessages(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
q: ' 이전 ',
|
||||
});
|
||||
|
||||
expect(provider.listMessages.mock.calls[0][1]).toMatchObject({
|
||||
text: undefined,
|
||||
});
|
||||
expect(provider.listMessages.mock.calls[1][1]).toMatchObject({
|
||||
text: '이전',
|
||||
});
|
||||
});
|
||||
|
||||
describe('검색 · 새 메일 확인 · 여러 통 (3단계)', () => {
|
||||
const emptyPage = {
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
it('메일함 없이 검색어만 있으면 모든 메일함 검색으로 넘긴다 (휴지통·스팸메일함 제외는 어댑터)', async () => {
|
||||
provider.listMessages.mockResolvedValue(emptyPage);
|
||||
|
||||
await service.listMessages(user, { limit: 20, q: ' 회의 ' });
|
||||
|
||||
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '회의',
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함도 검색어도 없으면(공백뿐인 검색어 포함) 엔진에 묻지 않고 VAL_001', async () => {
|
||||
for (const query of [{ limit: 20 }, { limit: 20, q: ' ' }]) {
|
||||
expect(await codeOf(() => service.listMessages(user, query))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
}
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('새 메일 확인 · 여러 통 (3단계 — 목록 · 검색은 message-list.service.spec.ts)', () => {
|
||||
it('새 메일 확인은 어댑터의 상태 값을 그대로 — 요청마다 로그를 남기지 않는다', async () => {
|
||||
provider.getMailState.mockResolvedValue({ state: 'c2Iy.c2Fq' });
|
||||
|
||||
@@ -2866,6 +2822,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto());
|
||||
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
|
||||
@@ -2876,6 +2833,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 0,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto());
|
||||
expect(sentMessage()).not.toHaveProperty('hold');
|
||||
@@ -2901,6 +2859,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
const first = sentMessage();
|
||||
@@ -2983,6 +2942,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 10,
|
||||
externalImages,
|
||||
groupByConversation: false,
|
||||
});
|
||||
/** 본문의 중계 주소 → 표를 푼 원래 주소 */
|
||||
const proxied = (html: string | null) =>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
SHOW_IMAGES,
|
||||
type MessageDetailQueryDto,
|
||||
} from './dto/message-detail-query.dto';
|
||||
import type { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import type {
|
||||
ComposeContentDto,
|
||||
DraftBodyDto,
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
selectInlineImages,
|
||||
toDataUri,
|
||||
} from './html/inline-images';
|
||||
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
@@ -118,6 +118,7 @@ import type {
|
||||
SentMessage,
|
||||
} from './mail.types';
|
||||
import { presentMailbox, presentMailboxes } from './mailbox.presenter';
|
||||
import { highlightDetail } from './message-highlight';
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import {
|
||||
idempotentMessageId,
|
||||
@@ -660,34 +661,6 @@ export class MailService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 목록 · 검색 — mailboxId 와 검색어(q) 중 하나는 있어야 한다 (둘 다 없으면 VAL_001).
|
||||
* mailboxId 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (계약 2절 "검색"). 검색어는 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
async listMessages(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
const text = query.q?.trim() || undefined;
|
||||
if (query.mailboxId === undefined && text === undefined) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'mailbox-or-search-required' },
|
||||
});
|
||||
}
|
||||
const page = await this.provider.listMessages(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
text,
|
||||
});
|
||||
|
||||
return toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 — 조회만 한다(읽음 처리 없음). HTML 은 정화한 뒤에만 내보낸다.
|
||||
* 정화가 이벤트 루프를 오래 붙잡을 HTML(너무 길거나 깊게 중첩된 것)은 정화하지 않고 htmlBody 를 비운다 —
|
||||
@@ -705,6 +678,26 @@ export class MailService {
|
||||
id: string,
|
||||
query: MessageDetailQueryDto = {},
|
||||
): Promise<MessageDetail> {
|
||||
// 검색 강조(14단계) — 같은 검색 쿼리로 열었으면 정화한 뒤의 본문 · 제목 · 첨부 이름에 맞은 곳을 단다 (검색 글은 로그에 남기지 않는다).
|
||||
// 보낸 사람 · 받는 사람 조건은 강조하지 않지만 넘긴다 — 본문에 강조할 말(한 글자 낱말)을 목록과 같게 고른다
|
||||
return highlightDetail(
|
||||
await this.readMessage(user, id, query),
|
||||
highlightTermsOf({
|
||||
text: query.q,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
subject: query.subject,
|
||||
body: query.body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** getMessage 의 본체 — 강조 전의 상세 */
|
||||
private async readMessage(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
query: MessageDetailQueryDto,
|
||||
): Promise<Omit<MessageDetail, 'searchHighlight'>> {
|
||||
const {
|
||||
rawHtmlBody,
|
||||
isRawHtmlBodyTruncated,
|
||||
@@ -724,7 +717,9 @@ export class MailService {
|
||||
}));
|
||||
|
||||
/** HTML 없이 텍스트 본문을 보여 줄 때 (HTML 이 없거나, 있었지만 정화 한도를 넘어 비웠을 때) */
|
||||
const withoutHtml = (isHtmlBodyOmitted: boolean): MessageDetail => ({
|
||||
const withoutHtml = (
|
||||
isHtmlBodyOmitted: boolean,
|
||||
): Omit<MessageDetail, 'searchHighlight'> => ({
|
||||
...detail,
|
||||
attachments: markInline(NO_INLINE_ATTACHMENTS),
|
||||
htmlBody: null,
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import type {
|
||||
MessageDetailHighlight,
|
||||
MessageListHighlight,
|
||||
SearchConditions,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
|
||||
/**
|
||||
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
|
||||
@@ -49,6 +54,11 @@ export interface PendingSend {
|
||||
readonly kind: PendingSendKind;
|
||||
}
|
||||
|
||||
/** 목록 항목 (GET /api/messages — 14단계) — 요약 + 검색 강조. 색인으로 찾았을 때만 강조가 있고, 목록 · 엔진 방식 검색이면 null */
|
||||
export interface MessageListItem extends MessageSummary {
|
||||
readonly searchHighlight: MessageListHighlight | null;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
readonly id: string;
|
||||
readonly threadId: string;
|
||||
@@ -137,6 +147,11 @@ export interface MessageDetail extends MessageSummary {
|
||||
* 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다
|
||||
*/
|
||||
readonly largeFiles: MessageLargeFile[];
|
||||
/**
|
||||
* 검색 강조 (14단계 — 같은 검색 쿼리로 열었을 때) — 제목 · 첨부 이름(맞은 것만) · 글 본문의 맞은 곳. htmlBody 는 글 조각에 `<mark>` 를 넣어 준다.
|
||||
* 검색 쿼리가 없으면 null
|
||||
*/
|
||||
readonly searchHighlight: MessageDetailHighlight | null;
|
||||
}
|
||||
|
||||
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
|
||||
@@ -165,6 +180,7 @@ export interface MessageDetailSource extends Omit<
|
||||
| 'isBodyTruncated'
|
||||
| 'attachments'
|
||||
| 'largeFiles'
|
||||
| 'searchHighlight'
|
||||
> {
|
||||
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
|
||||
readonly attachments: MailAttachment[];
|
||||
@@ -189,6 +205,8 @@ export interface MessageListQuery {
|
||||
readonly cursor?: string;
|
||||
/** 자유 텍스트 검색어 (앞뒤 공백을 뺀 값) */
|
||||
readonly text?: string;
|
||||
/** 조건 검색의 조건 (14단계 — 엔진 방식은 JMAP 필터로 · 낱말 통째 일치) */
|
||||
readonly conditions?: SearchConditions;
|
||||
}
|
||||
|
||||
export interface MessagePage {
|
||||
@@ -198,6 +216,55 @@ export interface MessagePage {
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화 하나 (14단계 — GET /api/conversations). 고르기 · 읽음 · 중요 · 이동 · 삭제는 `messageIds`(그 메일함 안)에 여러 통 API 로 한다
|
||||
*/
|
||||
export interface ConversationSummary {
|
||||
/** 대화 — 불투명 (색인이면 대화 키, 엔진이면 스레드 id) */
|
||||
readonly id: string;
|
||||
/** 그 메일함 안 마지막 메일 */
|
||||
readonly latest: MessageSummary;
|
||||
/**
|
||||
* 보낸 사람들 — 받은 차례, 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지 (대화 전체 — 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만)
|
||||
*/
|
||||
readonly participants: MailAddress[];
|
||||
/**
|
||||
* 대화 전체의 메일 수 — 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다
|
||||
*/
|
||||
readonly messageCount: number;
|
||||
/** 그 메일함 안 그 대화의 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 (엔진 방식에서 상한을 넘은 스레드는 읽은 최신 메일에서만) */
|
||||
readonly messageIds: string[];
|
||||
/** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일) — 없으면 빈 배열. 화면은 여러 통 처리에서 뺀다 */
|
||||
readonly pendingSendIds: string[];
|
||||
/** 그 메일함 안 메일을 모두 읽었다 */
|
||||
readonly isRead: boolean;
|
||||
/** 그 메일함 안 메일 가운데 하나라도 중요 표시 */
|
||||
readonly isFlagged: boolean;
|
||||
/** 그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 첨부 표시와 같은 판정) */
|
||||
readonly hasAttachment: boolean;
|
||||
}
|
||||
|
||||
/** 요약 여러 통과 계정의 붙잡힌 메일 (14단계 3차 — 묶은 목록의 색인 방식, 기다리는 제출은 한 번만 읽는다) */
|
||||
export interface SummariesWithHeld {
|
||||
readonly summaries: MessageSummary[];
|
||||
/** 지금 붙잡힌 메일 id — 보이기 경로의 판정(보낼 시각이 아직 · pendingSend 와 같다), 계정 전체에서 보낼 시각이 이른 것부터 HELD_SUBMISSIONS_MAX 개까지 */
|
||||
readonly heldIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface ConversationSummaryPage {
|
||||
readonly items: ConversationSummary[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 묶은 목록 쿼리 (엔진 방식) */
|
||||
export interface ConversationListQuery {
|
||||
readonly mailboxId: string;
|
||||
readonly limit: number;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export interface MessageUpdate {
|
||||
readonly isRead?: boolean;
|
||||
readonly isFlagged?: boolean;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { highlightTermsOf } from '@/modules/mail-search/search-plan';
|
||||
import type { MessageDetail } from './mail.types';
|
||||
import { highlightDetail } from './message-highlight';
|
||||
|
||||
type Detail = Omit<MessageDetail, 'searchHighlight'>;
|
||||
|
||||
function detail(overrides: Partial<Detail> = {}): Detail {
|
||||
return {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
subject: '[공지] 계약 갱신 안내',
|
||||
preview: '계약 갱신 일정',
|
||||
receivedAt: '2026-11-02T01:00:00.000Z',
|
||||
sentAt: '2026-11-02T01:00:00.000Z',
|
||||
messageId: 'x@example.com',
|
||||
references: [],
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
size: 2048,
|
||||
pendingSend: null,
|
||||
htmlBody: '<p>이번 달 <b>계약</b> 갱신 일정입니다.</p>',
|
||||
textBody: '이번 달 계약 갱신 일정입니다.',
|
||||
hasBlockedImages: false,
|
||||
imagesShown: false,
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: false,
|
||||
attachments: [
|
||||
{
|
||||
id: 'p1',
|
||||
name: '계약서_초안.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 100,
|
||||
isInline: false,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: '견적.xlsx',
|
||||
type: 'application/vnd.ms-excel',
|
||||
size: 100,
|
||||
isInline: false,
|
||||
},
|
||||
],
|
||||
largeFiles: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const slices = (text: string, ranges: readonly (readonly [number, number])[]) =>
|
||||
ranges.map(([start, end]) => text.slice(start, end));
|
||||
|
||||
describe('읽기 화면의 검색 강조 (message-highlight — 14단계 설계 C2)', () => {
|
||||
it('검색 쿼리가 없으면 searchHighlight 는 null 이고 나머지는 그대로', () => {
|
||||
const source = detail();
|
||||
expect(highlightDetail(source, highlightTermsOf({}))).toEqual({
|
||||
...source,
|
||||
searchHighlight: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('검색어는 제목 · 본문(textBody 위치 · htmlBody 의 <mark>) · 맞은 첨부 이름에', () => {
|
||||
const result = highlightDetail(
|
||||
detail(),
|
||||
highlightTermsOf({ text: '계약' }),
|
||||
);
|
||||
|
||||
expect(slices(result.subject, result.searchHighlight!.subject)).toEqual([
|
||||
'계약',
|
||||
]);
|
||||
expect(result.searchHighlight!.attachments).toEqual({ p1: [[0, 2]] });
|
||||
expect(slices(result.textBody!, result.searchHighlight!.textBody)).toEqual([
|
||||
'계약',
|
||||
]);
|
||||
expect(result.htmlBody).toBe(
|
||||
'<p>이번 달 <b><mark>계약</mark></b> 갱신 일정입니다.</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('제목 조건은 제목에만, 내용 조건은 본문에만 — 첨부 이름은 검색어로만', () => {
|
||||
const subjectOnly = highlightDetail(
|
||||
detail(),
|
||||
highlightTermsOf({ subject: '갱신' }),
|
||||
);
|
||||
expect(subjectOnly.searchHighlight).toEqual({
|
||||
subject: [[8, 10]],
|
||||
attachments: {},
|
||||
textBody: [],
|
||||
});
|
||||
expect(subjectOnly.htmlBody).toBe(detail().htmlBody);
|
||||
|
||||
const bodyOnly = highlightDetail(
|
||||
detail(),
|
||||
highlightTermsOf({ body: '일정' }),
|
||||
);
|
||||
expect(bodyOnly.searchHighlight!.subject).toEqual([]);
|
||||
expect(bodyOnly.searchHighlight!.attachments).toEqual({});
|
||||
expect(
|
||||
slices(bodyOnly.textBody!, bodyOnly.searchHighlight!.textBody),
|
||||
).toEqual(['일정']);
|
||||
expect(bodyOnly.htmlBody).toContain('<mark>일정</mark>');
|
||||
});
|
||||
|
||||
it('한 글자 낱말뿐이면 본문은 강조하지 않는다 — 본문을 찾지 않았다, 제목 · 첨부 이름은 그대로 (2026-11-06 실사용 시험)', () => {
|
||||
const source = detail({
|
||||
subject: '견적 안내',
|
||||
htmlBody: '<p>이번 달 견적 안내</p>',
|
||||
textBody: '이번 달 견적 안내',
|
||||
});
|
||||
// '견' 은 제목 · 첨부 이름(견적.xlsx) · 본문에, '달' 은 본문에만 있다
|
||||
const single = highlightDetail(source, highlightTermsOf({ text: '달 견' }));
|
||||
expect(single.searchHighlight).toEqual({
|
||||
subject: [[0, 1]],
|
||||
attachments: { p2: [[0, 1]] },
|
||||
textBody: [],
|
||||
});
|
||||
expect(single.htmlBody).toBe('<p>이번 달 견적 안내</p>');
|
||||
|
||||
// 두 글자 이상 낱말과 섞이면 그 낱말만 본문에 — 한 글자 낱말은 제목 · 첨부 이름에만
|
||||
const mixed = highlightDetail(
|
||||
source,
|
||||
highlightTermsOf({ text: '달 견적' }),
|
||||
);
|
||||
expect(slices(mixed.textBody!, mixed.searchHighlight!.textBody)).toEqual([
|
||||
'견적',
|
||||
]);
|
||||
expect(mixed.htmlBody).toBe('<p>이번 달 <mark>견적</mark> 안내</p>');
|
||||
expect(mixed.searchHighlight!.subject).toEqual([[0, 2]]);
|
||||
expect(mixed.searchHighlight!.attachments).toEqual({ p2: [[0, 2]] });
|
||||
});
|
||||
|
||||
it('강조가 있으면 제목 · 첨부 이름 · textBody 를 NFC 로 싣는다 — 위치의 기준', () => {
|
||||
const nfd = (text: string) => text.normalize('NFD');
|
||||
const result = highlightDetail(
|
||||
detail({
|
||||
subject: nfd('계약 안내'),
|
||||
textBody: nfd('계약 내용'),
|
||||
attachments: [
|
||||
{
|
||||
id: 'p1',
|
||||
name: nfd('계약서.pdf'),
|
||||
type: 'application/pdf',
|
||||
size: 1,
|
||||
isInline: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
highlightTermsOf({ text: '계약' }),
|
||||
);
|
||||
|
||||
expect(result.subject).toBe('계약 안내');
|
||||
expect(result.textBody).toBe('계약 내용');
|
||||
expect(result.attachments[0].name).toBe('계약서.pdf');
|
||||
expect(result.searchHighlight).toEqual({
|
||||
subject: [[0, 2]],
|
||||
attachments: { p1: [[0, 2]] },
|
||||
textBody: [[0, 2]],
|
||||
});
|
||||
});
|
||||
|
||||
it('본문이 없거나 HTML 을 비웠으면(isHtmlBodyOmitted) 있는 쪽에만', () => {
|
||||
const result = highlightDetail(
|
||||
detail({ htmlBody: null, isHtmlBodyOmitted: true, textBody: null }),
|
||||
highlightTermsOf({ text: '계약' }),
|
||||
);
|
||||
expect(result.htmlBody).toBeNull();
|
||||
expect(result.textBody).toBeNull();
|
||||
expect(result.searchHighlight!.textBody).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
MessageDetailHighlight,
|
||||
SearchRange,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
import type { HighlightTerms } from '@/modules/mail-search/search-plan';
|
||||
import { matchRanges } from '@/shared/utils/search-text';
|
||||
import { markHtmlText } from './html/html-mark';
|
||||
import type { MessageDetail } from './mail.types';
|
||||
|
||||
/**
|
||||
* 읽기 화면의 검색 강조 (순수 함수 — 14단계 설계 C2) — 검색에서 연 메일을 같은 검색 쿼리로 받으면 맞은 곳을 알려 준다.
|
||||
* - 제목: 검색어 · 제목 조건의 말 / 본문(textBody 위치 · htmlBody 의 `<mark>`): 검색어 · 내용 조건의 말 / 첨부 이름: 검색어의 말(맞은 첨부만)
|
||||
* - 위치는 NFC 로 맞춘 글 기준이라 강조가 있으면 subject · 첨부 이름 · textBody 를 NFC 로 바꿔 싣는다(화면에는 같게 보인다)
|
||||
* - htmlBody 는 **정화한 뒤** 의 HTML 에만 `<mark>` 를 넣는다(html/html-mark.ts — 태그 · 속성은 건드리지 않는다)
|
||||
* 강조할 말이 없으면 searchHighlight 는 null 이고 나머지는 그대로다
|
||||
*/
|
||||
export function highlightDetail(
|
||||
detail: Omit<MessageDetail, 'searchHighlight'>,
|
||||
terms: HighlightTerms | null,
|
||||
): MessageDetail {
|
||||
if (terms === null) return { ...detail, searchHighlight: null };
|
||||
const subject = detail.subject.normalize('NFC');
|
||||
const attachments = detail.attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
name: attachment.name.normalize('NFC'),
|
||||
}));
|
||||
const attachmentRanges: Record<string, SearchRange[]> = {};
|
||||
if (terms.attachment.length > 0) {
|
||||
for (const attachment of attachments) {
|
||||
const ranges = matchRanges(attachment.name, terms.attachment);
|
||||
if (ranges.length > 0) {
|
||||
attachmentRanges[attachment.id] = ranges.map(
|
||||
([start, end]) => [start, end] as const,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const textBody = detail.textBody?.normalize('NFC') ?? null;
|
||||
const highlight: MessageDetailHighlight = {
|
||||
subject: matchRanges(subject, terms.subject).map(
|
||||
([start, end]) => [start, end] as const,
|
||||
),
|
||||
attachments: attachmentRanges,
|
||||
textBody:
|
||||
textBody !== null && terms.body.length > 0
|
||||
? matchRanges(textBody, terms.body).map(
|
||||
([start, end]) => [start, end] as const,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
return {
|
||||
...detail,
|
||||
subject,
|
||||
attachments,
|
||||
textBody,
|
||||
htmlBody:
|
||||
detail.htmlBody === null || terms.body.length === 0
|
||||
? detail.htmlBody
|
||||
: markHtmlText(detail.htmlBody, terms.body).html,
|
||||
searchHighlight: highlight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import type {
|
||||
IndexHighlightText,
|
||||
IndexSearchPage,
|
||||
MailSearchQueryService,
|
||||
} from '@/modules/mail-search/mail-search-query.service';
|
||||
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import { encodeIndexCursor } from '@/modules/mail-search/search-cursor';
|
||||
import { encodeCursor } from './jmap/jmap-cursor';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MessagePage, MessageSummary } from './mail.types';
|
||||
import { MessageListService } from './message-list.service';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
const ALL_SCOPE: SearchScope = {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['trash', 'junk'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
extra: Partial<MessageSummary> = {},
|
||||
): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: user.email }],
|
||||
subject: `${id} 계약 갱신`,
|
||||
preview: '계약 갱신 일정을 알려 드립니다',
|
||||
receivedAt: '2026-11-02T01:00:00.000Z',
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1200,
|
||||
pendingSend: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyEnginePage: MessagePage = {
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
function setup(options: { ready?: boolean } = {}) {
|
||||
const provider = {
|
||||
listMessages: jest.fn().mockResolvedValue(emptyEnginePage),
|
||||
searchScope: jest.fn().mockResolvedValue(ALL_SCOPE),
|
||||
getSummaries: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const searchIndex = {
|
||||
isSearchReady: jest.fn().mockResolvedValue(options.ready ?? true),
|
||||
};
|
||||
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
|
||||
const indexPage: IndexSearchPage = {
|
||||
hits: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
};
|
||||
const searchQuery = {
|
||||
search: jest.fn().mockResolvedValue(indexPage),
|
||||
highlightTexts: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Map<string, IndexHighlightText>()),
|
||||
};
|
||||
const service = new MessageListService(
|
||||
provider as unknown as MailProvider,
|
||||
searchIndex as unknown as MailSearchIndexService,
|
||||
searchSync as unknown as MailSearchSyncService,
|
||||
searchQuery as unknown as MailSearchQueryService,
|
||||
);
|
||||
return { service, provider, searchIndex, searchSync, searchQuery };
|
||||
}
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageListService (GET /api/messages — 목록 · 검색, 14단계)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('메일함도 검색도 없으면(공백뿐인 검색어 포함) 엔진 · 색인에 묻지 않고 VAL_001', async () => {
|
||||
const { service, provider, searchIndex } = setup();
|
||||
for (const query of [
|
||||
{ limit: 20 },
|
||||
{ limit: 20, q: ' ' },
|
||||
{ limit: 20, from: ' ', subject: '' },
|
||||
]) {
|
||||
expect(await codeOf(() => service.list(user, query))).toBe('VAL_001');
|
||||
}
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('검색이 아닌 목록은 늘 엔진 — 색인 상태를 묻지 않고, 항목의 searchHighlight 는 null', async () => {
|
||||
const { service, provider, searchIndex } = setup();
|
||||
provider.listMessages.mockResolvedValue({
|
||||
items: [summary('m1')],
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 9,
|
||||
});
|
||||
|
||||
const page = await service.list(user, {
|
||||
mailboxId: 'inbox',
|
||||
limit: 20,
|
||||
q: ' ',
|
||||
});
|
||||
|
||||
expect(provider.listMessages).toHaveBeenCalledWith(user.email, {
|
||||
mailboxId: 'inbox',
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: undefined,
|
||||
conditions: undefined,
|
||||
});
|
||||
expect(searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
expect(page.items).toEqual([{ ...summary('m1'), searchHighlight: null }]);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('색인이 준비되지 않았으면 엔진 방식 — 검색어 앞뒤 공백을 지우고, 준 조건만 넘긴다', async () => {
|
||||
const { service, provider, searchQuery } = setup({ ready: false });
|
||||
|
||||
await service.list(user, { limit: 20, q: ' 이전 ' });
|
||||
await service.list(user, {
|
||||
limit: 20,
|
||||
from: ' 이도윤 ',
|
||||
hasAttachment: 'true',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(provider.listMessages).toHaveBeenNthCalledWith(1, user.email, {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: '이전',
|
||||
conditions: undefined,
|
||||
});
|
||||
expect(provider.listMessages).toHaveBeenNthCalledWith(2, user.email, {
|
||||
mailboxId: undefined,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
text: undefined,
|
||||
conditions: {
|
||||
from: '이도윤',
|
||||
after: '2026-10-31T15:00:00.000Z',
|
||||
hasAttachment: true,
|
||||
},
|
||||
});
|
||||
expect(searchQuery.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('색인이 준비됐으면 색인 방식 — 바로 전 맞추기 → 엔진의 범위 → 색인 찾기 → 찾은 쪽의 요약은 엔진에서, 찾은 차례대로', async () => {
|
||||
const { service, provider, searchSync, searchQuery } = setup();
|
||||
const order: string[] = [];
|
||||
searchSync.syncBeforeRead.mockImplementation(() => {
|
||||
order.push('sync');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
provider.searchScope.mockImplementation(() => {
|
||||
order.push('scope');
|
||||
return Promise.resolve(ALL_SCOPE);
|
||||
});
|
||||
searchQuery.search.mockImplementation(() => {
|
||||
order.push('search');
|
||||
return Promise.resolve({
|
||||
hits: [
|
||||
{ id: 'm2', receivedAt: new Date('2026-11-02T02:00:00Z') },
|
||||
{ id: 'm1', receivedAt: new Date('2026-11-02T01:00:00Z') },
|
||||
],
|
||||
hasNext: true,
|
||||
nextCursor: 'index-next',
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
// 엔진은 차례를 보장하지 않는다
|
||||
provider.getSummaries.mockResolvedValue([summary('m1'), summary('m2')]);
|
||||
|
||||
const page = await service.list(user, { limit: 2, q: '계약' });
|
||||
|
||||
expect(order).toEqual(['sync', 'scope', 'search']);
|
||||
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
|
||||
expect(provider.searchScope).toHaveBeenCalledWith(user.email, undefined);
|
||||
const [, scope, criteria, plan, pageOptions] = searchQuery.search.mock
|
||||
.calls[0] as unknown[];
|
||||
expect(scope).toBe(ALL_SCOPE);
|
||||
expect(criteria).toMatchObject({ text: '계약', hasAttachment: false });
|
||||
expect(plan).toMatchObject({ grams: ['계약'] });
|
||||
expect(pageOptions).toEqual({ limit: 2, cursor: undefined });
|
||||
expect(provider.getSummaries).toHaveBeenCalledWith(user.email, [
|
||||
'm2',
|
||||
'm1',
|
||||
]);
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m2', 'm1']);
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: true,
|
||||
nextCursor: 'index-next',
|
||||
total: 42,
|
||||
});
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('색인 방식의 강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기(색인의 본문), 본문이 없으면 엔진의 미리 보기', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockResolvedValue({
|
||||
hits: [
|
||||
{ id: 'm1', receivedAt: new Date() },
|
||||
{ id: 'm2', receivedAt: new Date() },
|
||||
],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
provider.getSummaries.mockResolvedValue([
|
||||
summary('m1', { subject: '계약 갱신 안내' }),
|
||||
summary('m2', { subject: '안내', preview: '갱신 계약서 초안' }),
|
||||
]);
|
||||
searchQuery.highlightTexts.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'm1',
|
||||
{
|
||||
subject: '계약 갱신 안내',
|
||||
bodyText: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
|
||||
preview: '안녕하세요.',
|
||||
otherFields: ['', '', '계약 갱신 안내', ''],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const page = await service.list(user, { limit: 20, q: '계약' });
|
||||
|
||||
expect(page.items[0].searchHighlight).toEqual({
|
||||
subject: [[0, 2]],
|
||||
preview: {
|
||||
text: '안녕하세요. 이번 달 계약 갱신 일정입니다.',
|
||||
ranges: [[12, 14]],
|
||||
},
|
||||
});
|
||||
expect(page.items[1].searchHighlight).toEqual({
|
||||
subject: [],
|
||||
preview: { text: '갱신 계약서 초안', ranges: [[3, 5]] },
|
||||
});
|
||||
});
|
||||
|
||||
it('색인 방식의 미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 색인의 다른 칸(제목)에서도 맞으면 인용 앞의 글 (2026-11-06 사용자 관점 점검)', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockResolvedValue({
|
||||
hits: [{ id: 'm1', receivedAt: new Date() }],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
provider.getSummaries.mockResolvedValue([
|
||||
summary('m1', {
|
||||
subject: 'RE: 해오름 견적',
|
||||
preview: '네, 확인했습니다. > 견적서를 보내 드립니다.',
|
||||
}),
|
||||
]);
|
||||
searchQuery.highlightTexts.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'm1',
|
||||
{
|
||||
subject: 'RE: 해오름 견적',
|
||||
bodyText: '네, 확인했습니다.\n\n> 견적서를 보내 드립니다.',
|
||||
preview: '네, 확인했습니다.',
|
||||
otherFields: ['', '', 're: 해오름 견적', ''],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const page = await service.list(user, { limit: 20, q: '견적' });
|
||||
|
||||
expect(page.items[0].searchHighlight).toEqual({
|
||||
subject: [[8, 10]],
|
||||
preview: { text: '네, 확인했습니다.…', ranges: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진이 범위 밖에 둔 메일(그사이 휴지통으로 옮김) · 엔진에 없는 메일은 뺀다 — 메일함 범위면 그 메일함에 든 것만', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockResolvedValue({
|
||||
hits: ['m1', 'm2', 'm3'].map((id) => ({ id, receivedAt: new Date() })),
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 3,
|
||||
});
|
||||
provider.getSummaries.mockResolvedValue([
|
||||
summary('m1', { mailboxIds: ['trash'] }),
|
||||
summary('m2', { mailboxIds: ['trash', 'project'] }),
|
||||
]);
|
||||
|
||||
const all = await service.list(user, { limit: 20, q: '계약' });
|
||||
expect(all.items.map((item) => item.id)).toEqual(['m2']);
|
||||
|
||||
provider.searchScope.mockResolvedValue({
|
||||
mailboxId: 'project',
|
||||
excludedMailboxIds: [],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
});
|
||||
provider.getSummaries.mockResolvedValue([
|
||||
summary('m1', { mailboxIds: ['inbox'] }),
|
||||
summary('m2', { mailboxIds: ['project'] }),
|
||||
]);
|
||||
const inProject = await service.list(user, {
|
||||
mailboxId: 'project',
|
||||
limit: 20,
|
||||
q: '계약',
|
||||
});
|
||||
expect(provider.searchScope).toHaveBeenLastCalledWith(
|
||||
user.email,
|
||||
'project',
|
||||
);
|
||||
expect(inProject.items.map((item) => item.id)).toEqual(['m2']);
|
||||
});
|
||||
|
||||
it('찾은 것이 없으면 엔진에 요약을 묻지 않는다', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
const page = await service.list(user, { limit: 20, subject: '없는말' });
|
||||
expect(provider.getSummaries).not.toHaveBeenCalled();
|
||||
expect(searchQuery.highlightTexts).not.toHaveBeenCalled();
|
||||
expect(page).toEqual({
|
||||
items: [],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 색인으로(준비 전이어도), 엔진 커서는 엔진으로(준비됐어도). 색인 상태를 묻지 않는다', async () => {
|
||||
const indexCursor = encodeIndexCursor(
|
||||
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
|
||||
'scope',
|
||||
);
|
||||
const engineCursor = encodeCursor(
|
||||
{ anchor: 'm9', position: 20 },
|
||||
{ text: '계약' },
|
||||
);
|
||||
|
||||
const notReady = setup({ ready: false });
|
||||
await notReady.service.list(user, {
|
||||
limit: 20,
|
||||
q: '계약',
|
||||
cursor: indexCursor,
|
||||
});
|
||||
expect(notReady.searchQuery.search).toHaveBeenCalledTimes(1);
|
||||
expect(notReady.provider.listMessages).not.toHaveBeenCalled();
|
||||
|
||||
const ready = setup({ ready: true });
|
||||
await ready.service.list(user, {
|
||||
limit: 20,
|
||||
q: '계약',
|
||||
cursor: engineCursor,
|
||||
});
|
||||
expect(ready.provider.listMessages).toHaveBeenCalledTimes(1);
|
||||
expect(ready.searchQuery.search).not.toHaveBeenCalled();
|
||||
|
||||
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
expect(ready.searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('첫 쪽의 색인 찾기가 실패하면(DB 장애) 엔진 방식으로 찾는다 — 경고에 검색 글 · 주소 원문을 남기지 않는다', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockRejectedValue(
|
||||
Object.assign(new Error('connection terminated'), { code: '57P01' }),
|
||||
);
|
||||
provider.listMessages.mockResolvedValue({
|
||||
items: [summary('m1')],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const page = await service.list(user, { limit: 20, q: '비밀 계약' });
|
||||
|
||||
expect(page.items.map((item) => item.searchHighlight)).toEqual([null]);
|
||||
expect(provider.listMessages).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = warnSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('[SYS_001]');
|
||||
expect(line).toContain('Error/57P01');
|
||||
expect(line).not.toContain('비밀');
|
||||
expect(line).not.toContain('계약');
|
||||
expect(line).not.toContain(user.email);
|
||||
});
|
||||
|
||||
it('코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001)는 그대로 — 엔진 방식으로 다시 찾지 않는다', async () => {
|
||||
for (const code of ['RES_001', 'MAIL_001'] as const) {
|
||||
const { service, provider } = setup();
|
||||
provider.searchScope.mockRejectedValue(new BusinessException(code));
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.list(user, { mailboxId: 'zz', limit: 20, q: '계약' }),
|
||||
),
|
||||
).toBe(code);
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('다음 쪽(색인 커서)의 색인 찾기가 실패하면 그대로 실패한다 — 엔진 커서와 섞지 않는다', async () => {
|
||||
const { service, provider, searchQuery } = setup();
|
||||
searchQuery.search.mockRejectedValue(new Error('db down'));
|
||||
const cursor = encodeIndexCursor(
|
||||
{ receivedAt: new Date('2026-11-02T01:00:00Z'), emailId: 'm9' },
|
||||
'scope',
|
||||
);
|
||||
expect(
|
||||
await codeOf(() => service.list(user, { limit: 20, q: '계약', cursor })),
|
||||
).toBe('NOT_BUSINESS');
|
||||
expect(provider.listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import {
|
||||
MailSearchQueryService,
|
||||
type IndexHighlightText,
|
||||
} from '@/modules/mail-search/mail-search-query.service';
|
||||
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import type {
|
||||
SearchConditions,
|
||||
SearchCriteria,
|
||||
SearchScope,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
import { cursorMethod } from '@/modules/mail-search/search-cursor';
|
||||
import {
|
||||
highlightedSubject,
|
||||
listHighlight,
|
||||
} from '@/modules/mail-search/search-highlight';
|
||||
import {
|
||||
isSearchCriteria,
|
||||
planSearch,
|
||||
} from '@/modules/mail-search/search-plan';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
HAS_ATTACHMENT_ONLY,
|
||||
type MessageListQueryDto,
|
||||
} from './dto/message-list-query.dto';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import type { MessageListItem, MessageSummary } from './mail.types';
|
||||
|
||||
/** 앞뒤 공백을 지운 글 — 비면 없는 것 (DTO 가 이미 지우지만 HTTP 를 거치지 않는 호출도 같게) */
|
||||
function trimmed(value: string | undefined): string | undefined {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** 검색 쿼리 → 검색 조건 */
|
||||
export function criteriaOf(query: MessageListQueryDto): SearchCriteria {
|
||||
return {
|
||||
text: trimmed(query.q),
|
||||
from: trimmed(query.from),
|
||||
to: trimmed(query.to),
|
||||
subject: trimmed(query.subject),
|
||||
body: trimmed(query.body),
|
||||
after: query.after,
|
||||
before: query.before,
|
||||
hasAttachment: query.hasAttachment === HAS_ATTACHMENT_ONLY,
|
||||
};
|
||||
}
|
||||
|
||||
/** 검색어 밖의 조건 — 하나도 없으면 undefined (엔진 방식 검색의 필터 · 커서 지문이 예전과 같다) */
|
||||
function conditionsOf(criteria: SearchCriteria): SearchConditions | undefined {
|
||||
const conditions: SearchConditions = {
|
||||
...(criteria.from ? { from: criteria.from } : {}),
|
||||
...(criteria.to ? { to: criteria.to } : {}),
|
||||
...(criteria.subject ? { subject: criteria.subject } : {}),
|
||||
...(criteria.body ? { body: criteria.body } : {}),
|
||||
...(criteria.after ? { after: criteria.after } : {}),
|
||||
...(criteria.before ? { before: criteria.before } : {}),
|
||||
...(criteria.hasAttachment === true ? { hasAttachment: true } : {}),
|
||||
};
|
||||
return Object.keys(conditions).length > 0 ? conditions : undefined;
|
||||
}
|
||||
|
||||
/** 엔진이 지금 그 메일을 범위 안에 두나 — 색인이 몇 초 늦을 수 있어 보이기 전에 엔진의 메일함으로 한 번 더 본다 */
|
||||
function inScope(summary: MessageSummary, scope: SearchScope): boolean {
|
||||
if (scope.mailboxId !== null) {
|
||||
return summary.mailboxIds.includes(scope.mailboxId);
|
||||
}
|
||||
const excluded = new Set(scope.excludedMailboxIds);
|
||||
return summary.mailboxIds.some((mailboxId) => !excluded.has(mailboxId));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 목록 · 검색 (GET /api/messages — 계약 2절 "검색", 14단계 설계 C1).
|
||||
* - mailboxId 와 검색(검색어 q · 조건 하나라도) 가운데 하나는 있어야 한다 — 둘 다 없으면 VAL_001. 검색 글 · 조건은 어떤 로그에도 남기지 않는다
|
||||
* - 검색 방식: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로), 없으면 색인이 준비됐을 때(ready · 지금 판) 색인,
|
||||
* 아니면 엔진(낱말 통째 일치 · 조건은 JMAP 필터)
|
||||
* - 색인 방식: 바로 전 맞추기(PRE_READ_SYNC_WAIT_MS 안 — 넘으면 지금 색인으로) → 엔진에 범위(메일함 · 빼는 메일함) → 색인 찾기 → 찾은 쪽의 요약은 엔진에서
|
||||
* (목록과 같은 모양 · 지금 상태) → 엔진이 범위 밖으로 둔 메일(그사이 옮김)은 뺀다 → 제목 · 미리 보기 강조. 첫 쪽의 색인 찾기가 DB 장애로 실패하면 엔진 방식으로 찾는다
|
||||
* 목록 항목은 늘 searchHighlight 를 싣는다 — 색인 방식 검색에서만 값이 있고 그 밖에는 null
|
||||
*/
|
||||
@Injectable()
|
||||
export class MessageListService {
|
||||
private readonly logger = new Logger(MessageListService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
private readonly searchIndex: MailSearchIndexService,
|
||||
private readonly searchSync: MailSearchSyncService,
|
||||
private readonly searchQuery: MailSearchQueryService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
const criteria = criteriaOf(query);
|
||||
const searching = isSearchCriteria(criteria);
|
||||
if (query.mailboxId === undefined && !searching) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'mailbox-or-search-required' },
|
||||
});
|
||||
}
|
||||
if (!searching) return this.fromEngine(user, query, criteria);
|
||||
if (query.cursor) {
|
||||
return cursorMethod(query.cursor) === 'index'
|
||||
? this.fromIndex(user, query, criteria)
|
||||
: this.fromEngine(user, query, criteria);
|
||||
}
|
||||
if (!(await this.searchIndex.isSearchReady(user.email))) {
|
||||
return this.fromEngine(user, query, criteria);
|
||||
}
|
||||
try {
|
||||
return await this.fromIndex(user, query, criteria);
|
||||
} catch (error) {
|
||||
// 코드가 있는 실패(없는 메일함 RES_001 · 엔진 장애 MAIL_001 등)는 그대로 — 색인(DB)이 실패했으면 엔진으로 찾는다
|
||||
if (error instanceof BusinessException) throw error;
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 검색 색인으로 찾지 못해 엔진 방식으로 찾습니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
|
||||
);
|
||||
return this.fromEngine(user, query, criteria);
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진 방식 — 목록 · 색인 준비 전의 검색 (강조 없음) */
|
||||
private async fromEngine(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
criteria: SearchCriteria,
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
const page = await this.provider.listMessages(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
text: criteria.text,
|
||||
conditions: conditionsOf(criteria),
|
||||
});
|
||||
return toPaginatedResult(
|
||||
page.items.map((item) => ({ ...item, searchHighlight: null })),
|
||||
{
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 색인 방식 — 부분 일치 · 조건 · 강조 */
|
||||
private async fromIndex(
|
||||
user: AuthenticatedUser,
|
||||
query: MessageListQueryDto,
|
||||
criteria: SearchCriteria,
|
||||
): Promise<PaginatedResult<MessageListItem>> {
|
||||
// 방금 온 메일도 찾게 — 기다리는 시간을 넘으면 지금 색인으로 답한다 (맞추기는 뒤에서 이어 한다)
|
||||
await this.searchSync.syncBeforeRead(user.email);
|
||||
const scope = await this.provider.searchScope(user.email, query.mailboxId);
|
||||
const plan = planSearch(criteria);
|
||||
const found = await this.searchQuery.search(
|
||||
user.email,
|
||||
scope,
|
||||
criteria,
|
||||
plan,
|
||||
{ limit: query.limit, cursor: query.cursor },
|
||||
);
|
||||
const ids = found.hits.map((hit) => hit.id);
|
||||
const [summaries, texts] =
|
||||
ids.length > 0
|
||||
? await Promise.all([
|
||||
this.provider.getSummaries(user.email, ids),
|
||||
this.searchQuery.highlightTexts(user.email, ids),
|
||||
])
|
||||
: [[], new Map<string, IndexHighlightText>()];
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const items = ids.flatMap((id): MessageListItem[] => {
|
||||
const summary = byId.get(id);
|
||||
if (!summary || !inScope(summary, scope)) return [];
|
||||
const subject = highlightedSubject(summary.subject);
|
||||
const text = texts.get(id);
|
||||
return [
|
||||
{
|
||||
...summary,
|
||||
subject,
|
||||
searchHighlight: listHighlight(
|
||||
{
|
||||
subject,
|
||||
preview: summary.preview,
|
||||
bodyText: text?.bodyText ?? null,
|
||||
otherFields: text?.otherFields ?? null,
|
||||
},
|
||||
plan.highlight,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
return toPaginatedResult(items, {
|
||||
hasNext: found.hasNext,
|
||||
nextCursor: found.nextCursor,
|
||||
total: found.total,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { MailLargeFilesService } from './mail-large-files.service';
|
||||
import { MailService } from './mail.service';
|
||||
import { MessageListService } from './message-list.service';
|
||||
import type { AttachmentContent } from './mail.types';
|
||||
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
|
||||
|
||||
@@ -94,24 +95,27 @@ export class MessageController {
|
||||
constructor(
|
||||
private readonly mail: MailService,
|
||||
private readonly largeFiles: MailLargeFilesService,
|
||||
private readonly messageList: MessageListService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '메일 목록 · 검색 (커서 페이지네이션, 받은 시각 내림차순)',
|
||||
description:
|
||||
'mailboxId 와 q 중 하나는 있어야 한다. q 만 있으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
|
||||
'엔진의 전문 검색은 낱말이 통째로 같을 때만 맞으므로, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다 ' +
|
||||
'(낱말 안은 OR · 낱말끼리는 AND, 영문·숫자로 끝나면 두 가지를 다, 큰따옴표 구절에는 붙이지 않는다). ' +
|
||||
'낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). 문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). ' +
|
||||
'붙여 쓴 말의 일부 · 앞부분만 친 말 · 제목 맨 앞의 말머리([공지] · Re:)는 찾지 못한다. 발송 중인 내부 초안은 빠진다.',
|
||||
'mailboxId 와 검색(q 나 조건 from · to · subject · body · after · before · hasAttachment 하나라도) 가운데 하나는 있어야 한다. 메일함 없이 검색하면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다. ' +
|
||||
'검색 방식(14단계): 그 직원의 색인이 준비됐으면(GET /api/search-index 의 ready) 자체 색인으로 부분 일치(붙여 쓴 말 · 앞부분 · 제목 맨 앞의 말머리까지, 영문 대소문자 · 전각 없이, 줄바꿈으로 갈린 구절도) — 항목에 searchHighlight 가 있다. ' +
|
||||
'한 글자 낱말뿐이면 제목 · 보낸 사람 · 받는 사람 · 참조 · 첨부 이름에서만 찾는다(본문은 훑지 않는다 — 두 글자 넘는 낱말과 함께면 본문도). ' +
|
||||
'색인 방식은 한 요청이 확인하는 메일 수에 상한이 있어, 쪽이 limit 보다 적거나 비어도 hasNext · nextCursor 가 있으면 이어 찾는다. total 은 첫 쪽에서만 1,000 까지(넘거나 다 세지 못하면 null). ' +
|
||||
'준비 중이면 엔진 검색 — 낱말이 통째로 같을 때만 맞고, 한글로 끝나는 낱말은 흔한 조사를 받침에 맞게 붙인 형태도 함께 찾는다(조건 칸도 같다), searchHighlight 는 null. ' +
|
||||
'커서에는 방식이 실려 쪽을 넘기는 중에 준비가 끝나도 처음 방식대로 이어 간다. 낱말 앞뒤의 문장부호는 떼고 찾는다(회의, → 회의). ' +
|
||||
'문자·숫자가 없는 낱말·구절(- · … 등)은 빼고 찾으며, 글을 준 칸에 찾을 낱말이 하나도 없으면 빈 목록이다(범위 전체를 돌려주지 않는다). 발송 중인 내부 초안은 빠진다.',
|
||||
})
|
||||
@ApiStandardResponse(MessagePageDto, {
|
||||
description: '메일 목록 (total 포함)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`mailboxId 와 q 가 둘 다 없음 · q 가 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다른 메일함·검색어의 커서 · limit 범위 초과`,
|
||||
`mailboxId 와 검색이 둘 다 없음 · 검색 글 칸(q · from · to · subject · body)이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과 · after · before 가 ISO 8601 UTC 가 아님 · hasAttachment 가 true 가 아님 · 다른 메일함·검색어·조건의 커서 · limit 범위 초과`,
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
@@ -122,7 +126,7 @@ export class MessageController {
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: MessageListQueryDto,
|
||||
) {
|
||||
return this.mail.listMessages(user, query);
|
||||
return this.messageList.list(user, query);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@@ -162,10 +166,15 @@ export class MessageController {
|
||||
'정화 한도(너무 길거나 깊게 중첩된 HTML)를 넘으면 htmlBody 는 null, isHtmlBodyOmitted 는 true 다 — textBody 를 보여 준다. ' +
|
||||
`보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도(본문 한 부분 UTF-8 ${MAX_BODY_VALUE_BYTES.toLocaleString('ko-KR')}바이트)에서 잘렸으면 isBodyTruncated 가 true 다. ` +
|
||||
'images=show 면 외부 이미지(<img> 의 http · https · // 주소, 한 통에 100개 · 주소 2,048자까지)를 중계 주소(/api/mail-images/{token} — 1시간)로 바꿔 넣는다(imagesShown: true · hasBlockedImages: false). ' +
|
||||
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계).',
|
||||
'없으면 그 직원의 외부 이미지 설정을 따른다 — 스팸메일함의 메일은 설정과 상관없이 지운다. srcset · 배경 그림(url())은 늘 지운다. pendingSend 는 아직 보내지 않은 내 메일의 발송이다(9단계). ' +
|
||||
'검색 쿼리(q · subject · body — 14단계)를 함께 보내면 searchHighlight 에 제목 · 첨부 이름 · textBody 의 맞은 곳을 싣고, htmlBody 의 맞은 글 조각에 <mark> 를 넣는다(목록과 같은 접기 · 부분 일치, 태그를 건너는 말은 강조하지 않는다). ' +
|
||||
'검색 쿼리의 검사는 목록과 같다 — from · to 는 받기만 하고 강조하지 않는다.',
|
||||
})
|
||||
@ApiStandardResponse(MessageDetailDto, { description: '메일 상세' })
|
||||
@ApiErrorResponse('VAL_001', '모르는 images 값 · 모르는 쿼리')
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`모르는 images 값 · 모르는 쿼리(cursor · limit · after · before · hasAttachment 포함) · 검색 글 칸이 ${MAX_SEARCH_LENGTH}자 초과 또는 낱말 ${MAX_SEARCH_WORDS}개 초과 · 다섯 칸을 합친 낱말이 ${MAX_SEARCH_WORDS}개 초과`,
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
get(
|
||||
|
||||
@@ -52,6 +52,8 @@ const ROW_LABELS: Record<keyof AddressRows, string> = {
|
||||
directory: '사내 목록 기록',
|
||||
files: '대용량 파일',
|
||||
shareLinks: '대용량 첨부 링크',
|
||||
searchStates: '검색 색인 상태',
|
||||
searchMessages: '검색 색인(메일)',
|
||||
};
|
||||
|
||||
/** 남은 줄을 보이는 글 */
|
||||
|
||||
@@ -55,6 +55,8 @@ const NO_ROWS: AddressRows = {
|
||||
directory: 0,
|
||||
files: 0,
|
||||
shareLinks: 0,
|
||||
searchStates: 0,
|
||||
searchMessages: 0,
|
||||
};
|
||||
|
||||
function employee(overrides: Partial<EngineEmployee> = {}): EngineEmployee {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from '@/modules/file-storage/file-blob-store';
|
||||
import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity';
|
||||
import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity';
|
||||
import { MailSearchMessageEntity } from '@/modules/mail-search/mail-search-message.entity';
|
||||
import { MailSearchStateEntity } from '@/modules/mail-search/mail-search-state.entity';
|
||||
import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity';
|
||||
import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage';
|
||||
@@ -45,6 +47,9 @@ export interface AddressRows {
|
||||
readonly directory: number;
|
||||
readonly files: number;
|
||||
readonly shareLinks: number;
|
||||
/** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */
|
||||
readonly searchStates: number;
|
||||
readonly searchMessages: number;
|
||||
}
|
||||
|
||||
/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */
|
||||
@@ -246,7 +251,7 @@ export class OpsDataService {
|
||||
|
||||
/**
|
||||
* 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다
|
||||
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기).
|
||||
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기).
|
||||
* 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다
|
||||
*/
|
||||
async purgeAddress(email: string): Promise<AddressPurge> {
|
||||
@@ -369,7 +374,9 @@ export class OpsDataService {
|
||||
| typeof MailUserSettingsEntity
|
||||
| typeof MailDirectoryEntryEntity
|
||||
| typeof StoredFileEntity
|
||||
| typeof ShareLinkEntity,
|
||||
| typeof ShareLinkEntity
|
||||
| typeof MailSearchStateEntity
|
||||
| typeof MailSearchMessageEntity,
|
||||
where: Record<string, string>,
|
||||
): Promise<number> => {
|
||||
if (mode === 'count') return manager.countBy(entity, where);
|
||||
@@ -393,6 +400,10 @@ export class OpsDataService {
|
||||
files: await run(StoredFileEntity, { ownerEmail: email }),
|
||||
// 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다
|
||||
shareLinks: await run(ShareLinkEntity, { ownerEmail: email }),
|
||||
// 검색 색인(14단계) — 상태 줄을 먼저 지운다: 서버의 색인 쓰기는 상태 줄을 잠그고 하므로(없으면 쓰지 않는다) 그사이 끝난 쓰기가
|
||||
// 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다)
|
||||
searchStates: await run(MailSearchStateEntity, { userEmail: email }),
|
||||
searchMessages: await run(MailSearchMessageEntity, { userEmail: email }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user