feat(backend): 대화 묶기를 더한다 — 대화 키 · 같은 대화 · 대화로 묶은 목록 · 설정

14단계 3차다. 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 보이고, 목록을 대화로
묶어 볼 수 있게 한다. 색인이 준비된 직원은 우리 대화 키로, 채우는 중인 직원은 엔진의
스레드로 묶는다(응답의 basis — 첫 쪽에서 정하고 커서가 잇는다).

- 대화 키: 색인 쓰기 때 넣은 메일의 부모 · 자식(References · In-Reply-To) 가운데 말머리
  (회신 · 답장 · 전달 포함)를 뗀 제목이 같은 것을 가장 작은 키로 합친다. 늦게 온 원본도
  잇고, 다시 넣어도 키는 풀리지 않는다. 색인 판 4
- GET /api/messages/{id}/conversation — 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과
  발송 중인 내부 초안은 빼고 지금 메일은 늘 넣는다
- GET /api/conversations?mailboxId= — 그 메일함의 대화 한 줄씩(참여자 10명 · 통 수 ·
  messageIds 최신 500 · pendingSendIds). 직원 주소 btree 로 먼저 좁힌다(10명 흉내 큰 메일함
  첫 쪽 81ms)
- 설정 groupByConversation(처음 끔) — 서버는 저장만 하고 묶을지는 화면이 정한다
- 마이그레이션 AddMailSearchSender — 참여자는 따로 둔 첫 보낸 사람 칸에서만 읽는다

리뷰 반영: 엔진 방식에 개수 · 시간 상한(같은 대화 한 요청 1,000통 · 묶은 목록 스레드마다
500 · 한 요청 2,000 · 15초, 요약 속성은 내보낼 메일만), Mailbox/get 장애는 503,
꾸민 커서는 엔진 · DB 보다 먼저 400, 같은 대화 한 문장, 이름 안의 <주소>로 참여자를
꾸미지 못하게, 로그 추적 ID. 검색 표본 표 머리 주석(frontend 로 옮겼다)도 함께 고친다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 05:39:10 +09:00
parent 53a8d7176d
commit 425cbc0e29
39 changed files with 4612 additions and 82 deletions
@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자).
* - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 —
* 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람이 그 주소로 보이고, 400자에서 잘린 줄은 빠졌다
* - 둘 다 null 을 받는다(주소가 없는 보낸 사람 · 지금 있는 줄). 지금 있는 줄은 색인 판 4(INDEX_FORMAT)로 다시 채우며 채운다
* 기존 칸 · 행은 지우지 않는다. (자동 생성: npm run migration:generate — 엔티티는 modules/mail-search/mail-search-message.entity.ts)
*/
export class AddMailSearchSender1793898213073 implements MigrationInterface {
name = 'AddMailSearchSender1793898213073';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_messages" ADD "from_email" character varying(254)`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_messages" ADD "from_name" character varying(100)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`,
);
await queryRunner.query(
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`,
);
}
}
@@ -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,206 @@
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")` 로 적는다 —
* `mailbox_ids` GIN 은 회사 전체 posting 이라(메일함 id 가 계정마다 같은 꼴) 쓰지 않게(리뷰 — 그 직원의 줄에서만 거른다)
*/
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';
}
@@ -1,5 +1,6 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
firstSender,
isIndexableId,
referencesOf,
storableText,
@@ -10,8 +11,10 @@ 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 {
@@ -125,6 +128,51 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
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({
@@ -7,10 +7,12 @@ import {
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';
@@ -22,7 +24,8 @@ import {
* - 접기 글(fold_*)은 foldText — 원문과 위치가 같다. 받는 사람 칸은 받는 사람과 참조를 줄바꿈으로 이어 접는다
* - 묶음(grams)은 다섯 접기 칸에서 따로 뽑아 합친다(칸 경계를 넘지 않는다, 겹치지 않게)
* - 답장 관계(refs): In-Reply-To 다음 References 를 가까운 것(뒤)부터, 겹치지 않게 앞 INDEX_REFS_MAX 개. 너무 긴 id 는 뺀다
* - 대화 키는 14단계 1차에서는 그 메일 id 다 (잇기는 3차 — 설계 B4)
* - 첫 보낸 사람은 주소 · 이름을 따로도 둔다(from_email · from_name — 묶은 목록의 참여자, 3차 리뷰 L3). 주소가 없으면 null — 이름 안의 `<주소>` 꼴은 주소로 읽지 않는다
* - 대화 키는 넣을 때 그 메일 id 다 — 색인에 쓸 때 이어진 메일의 키와 합친다(3차 — 설계 B4, mail-search-index.repository.ts 의 linkThreads)
*/
/** 색인 한 줄의 값 — 표의 칸과 같다 (user_email · indexed_at 은 쓰는 쪽이 붙인다) */
@@ -37,6 +40,8 @@ export interface IndexRowValues {
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;
@@ -103,6 +108,39 @@ function addressLine(address: IndexAddress): string {
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)
@@ -149,6 +187,7 @@ export function isIndexableId(id: string): boolean {
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
@@ -187,6 +226,8 @@ export function toIndexRow(document: IndexDocument): IndexRowValues {
subject,
subjectBase: subjectBase(subject),
fromText,
fromEmail: sender.email,
fromName: sender.name,
toText,
ccText,
attachmentNames,
@@ -199,6 +240,7 @@ export function toIndexRow(document: IndexDocument): IndexRowValues {
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 명까지)
* 모든 쿼리는 그 직원 주소로 먼저 좁힌다 — 메일함 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,
};
});
}
}
@@ -11,6 +11,7 @@ import {
SEARCH_MESSAGES_TABLE,
SEARCH_STATES_TABLE,
} from './mail-search.constants';
import { mergeThreadKeys } from './thread-keys';
import type {
SearchIndexStatus,
SearchPausedReason,
@@ -48,7 +49,10 @@ export interface SearchIndexWriteResult {
readonly updatedIds: ReadonlySet<string>;
}
/** 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id) 밖의 모두 */
/**
* 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id)와 대화 키(thread_key) 밖의 모두.
* 대화 키는 이미 있는 줄이면 그대로 둔다 — 합친 대화를 다시 넣기(겹친 채우기 등)가 풀지 않게 (새 줄은 그 메일의 id 로 넣고 linkThreads 가 잇는다)
*/
const UPSERT_COLUMNS = [
'mailbox_ids',
'keywords',
@@ -59,6 +63,8 @@ const UPSERT_COLUMNS = [
'subject',
'subject_base',
'from_text',
'from_email',
'from_name',
'to_text',
'cc_text',
'attachment_names',
@@ -71,7 +77,6 @@ const UPSERT_COLUMNS = [
'fold_attachment',
'fold_body',
'grams',
'thread_key',
'engine_thread_id',
'indexed_at',
];
@@ -292,7 +297,13 @@ export class MailSearchIndexRepository {
[userEmail, deletes],
);
}
await this.upsertRows(manager, userEmail, change.upserts ?? [], now);
const upserted = await this.upsertRows(
manager,
userEmail,
change.upserts ?? [],
now,
);
await this.linkThreads(manager, userEmail, upserted);
const updatedIds = await this.updateMutable(
manager,
userEmail,
@@ -309,14 +320,14 @@ export class MailSearchIndexRepository {
});
}
/** 넣기(있으면 모든 칸을 바꾸기) — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
/** 넣기(있으면 대화 키 밖의 모든 칸을 바꾸기) → 넣은 id — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
private async upsertRows(
manager: EntityManager,
userEmail: string,
rows: readonly IndexRowValues[],
now: Date,
): Promise<void> {
if (rows.length === 0) return;
): Promise<string[]> {
if (rows.length === 0) return [];
const latest = new Map(rows.map((row) => [row.emailId, row]));
await manager
.createQueryBuilder()
@@ -331,6 +342,44 @@ export class MailSearchIndexRepository {
)
.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 */
@@ -1,6 +1,7 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
INDEX_FROM_NAME_MAX_CHARS,
SEARCH_EMAIL_COLUMN_LENGTH,
SEARCH_ID_COLUMN_LENGTH,
SEARCH_MESSAGES_TABLE,
@@ -78,10 +79,31 @@ export class MailSearchMessageEntity {
@Column({ name: 'subject_base', type: 'text' })
subjectBase!: string;
/** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) */
/** "이름 <주소>" 를 줄바꿈으로 이은 원문(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;
@@ -124,7 +146,10 @@ export class MailSearchMessageEntity {
@Column({ type: 'text', array: true })
grams!: string[];
/** 우리 대화 키 — 14단계 1차에서는 그 메일 id (잇기는 3차) */
/**
* 우리 대화 키 (14단계 3차 — 설계 B4) — 넣을 때는 그 메일 id, 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 대화의 가장 작은 키로 합친다
* (mail-search-index.repository.ts 의 linkThreads). 다시 넣어도 이미 있는 줄의 키는 그대로다
*/
@Column({
name: 'thread_key',
type: 'varchar',
@@ -132,7 +157,7 @@ export class MailSearchMessageEntity {
})
threadKey!: string;
/** 엔진의 threadId — 색인을 다 만들기 전 대화 · 묶기의 대신 */
/** 엔진의 threadId (참고용 — 색인을 다 만들기 전의 대화 · 묶기는 엔진에 바로 묻는다) */
@Column({
name: 'engine_thread_id',
type: 'varchar',
@@ -25,6 +25,11 @@ 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;
@@ -110,8 +115,10 @@ 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 = 2;
export const INDEX_FORMAT = 4;
/**
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다:
@@ -139,3 +146,31 @@ export const SINGLE_CHAR_TERMS_SKIP_BODY = true;
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';
@@ -3,6 +3,7 @@ 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';
@@ -36,11 +37,13 @@ import { SearchIndexController } from './search-index.controller';
MailSearchBuildService,
MailSearchIndexService,
MailSearchQueryService,
MailConversationQueryService,
],
exports: [
MailSearchIndexService,
MailSearchSyncService,
MailSearchQueryService,
MailConversationQueryService,
],
})
export class MailSearchModule {}
@@ -68,3 +68,12 @@ export interface MessageDetailHighlight {
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,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,
});
}
}
@@ -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,12 +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,
@@ -586,6 +592,121 @@ export class MessagePageDto {
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 — 대화의 메일 수. 색인 방식은 첫 쪽에서만, 엔진 방식은 스레드를 모두 읽었을 때(상한 이하)만 쪽마다 — 넘으면 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[] = [
'not-found',
'refused',
@@ -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))] : [];
});
}
}
@@ -30,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';
@@ -196,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),
),
};
}
@@ -23,6 +23,8 @@ import type {
import type {
AttachmentContent,
BlockedSender,
ConversationListQuery,
ConversationSummaryPage,
Correspondence,
CredentialCheck,
DraftContent,
@@ -66,6 +68,7 @@ import type {
SendHold,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
TwoFactorLoginResult,
UploadedFile,
} from '@/modules/mail/mail.types';
@@ -95,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 {
@@ -153,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,
@@ -169,6 +172,7 @@ import {
readHeldEmails,
readHeldSendTimes,
submissionCalls,
warnIfHeldQueryFull,
submittedPendingSend,
type HeldEmails,
type HeldLookup,
@@ -192,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절 "임시 저장").
* 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다.
@@ -283,9 +278,6 @@ 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'>,
@@ -872,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> {
@@ -1562,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]));
@@ -1628,15 +1622,65 @@ export class JmapMailProvider implements MailProvider {
}
return {
mailboxId: null,
excludedMailboxIds: mailboxes
.filter((mailbox) =>
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
)
.map((mailbox) => mailbox.id),
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 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다)
@@ -1645,34 +1689,52 @@ export class JmapMailProvider implements MailProvider {
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));
if (valid.length === 0) return [];
const now = Date.now();
const responses = await this.jmap.call(user, (accountId) => [
[
'Email/get',
{
accountId,
ids: valid,
properties: SUMMARY_PROPERTIES,
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
},
'g',
],
...(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 = [] } = expectResult<JmapGetResult<JmapEmail>>(
responses,
'g',
'Email/get',
);
const list =
valid.length > 0
? (expectResult<JmapGetResult<JmapEmail>>(responses, 'g', 'Email/get')
.list ?? [])
: [];
const held = readHeldSendTimes(responses, HELD_CALLS, now);
this.warnIfHeldQueryFull(responses, user);
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
const byId = new Map(list.map((email) => [email.id, email]));
return valid.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
});
return {
summaries: valid.flatMap((id) => {
const email = byId.get(id);
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
}),
heldIds: new Set(held.keys()),
};
}
/**
@@ -2764,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) => {
@@ -3774,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 } : {};
}
@@ -4037,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,
@@ -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) */
+7
View File
@@ -28,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';
@@ -57,6 +58,8 @@ import {
} 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';
@@ -142,6 +145,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
MailFilterRunController,
MailBlockController,
MessageController,
// 같은 대화 · 대화로 묶은 목록 (14단계)
ConversationController,
MessageDeletionController,
ScheduledMessageController,
MailAttachmentController,
@@ -166,10 +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)
+37
View File
@@ -3,6 +3,8 @@ import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import type {
AttachmentContent,
BlockedSender,
ConversationListQuery,
ConversationSummaryPage,
Correspondence,
CredentialCheck,
DraftContent,
@@ -41,6 +43,7 @@ import type {
SavedDraft,
SentMessage,
SentMessageRecord,
SummariesWithHeld,
TwoFactorLoginResult,
UploadedFile,
} from './mail.types';
@@ -261,11 +264,45 @@ export interface MailProvider {
*/
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)를 함께 준다
*/
+13 -3
View File
@@ -100,7 +100,11 @@ function fakeProvider(): ProviderMocks {
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'>(),
@@ -283,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
@@ -2816,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 });
@@ -2826,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');
@@ -2851,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();
@@ -2933,6 +2942,7 @@ describe('MailService', () => {
ninth.userSettings.get.mockResolvedValue({
undoSendSeconds: 10,
externalImages,
groupByConversation: false,
});
/** 본문의 중계 주소 → 표를 푼 원래 주소 */
const proxied = (html: string | null) =>
+49
View File
@@ -216,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;