From 425cbc0e29a1544b1069eb87b861216a74814a27 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 05:39:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=8C=80=ED=99=94=20=EB=AC=B6?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=20=ED=82=A4=20=C2=B7=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=20=C2=B7=20=EB=8C=80=ED=99=94=EB=A1=9C=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=80=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../1793898213073-AddMailSearchSender.ts | 30 + .../mail-search/conversation-cursor.spec.ts | 107 +++ .../mail-search/conversation-cursor.ts | 122 +++ .../mail-search/conversation-sql.spec.ts | 154 +++ .../modules/mail-search/conversation-sql.ts | 206 ++++ .../mail-search/index-document.spec.ts | 48 + .../src/modules/mail-search/index-document.ts | 44 +- .../mail-conversation-query.service.ts | 248 +++++ .../mail-search-index.repository.ts | 61 +- .../mail-search/mail-search-message.entity.ts | 31 +- .../mail-search/mail-search.constants.ts | 37 +- .../modules/mail-search/mail-search.module.ts | 3 + .../modules/mail-search/mail-search.types.ts | 9 + .../modules/mail-search/thread-keys.spec.ts | 59 ++ .../src/modules/mail-search/thread-keys.ts | 46 + .../dto/mail-user-settings.dto.spec.ts | 11 + .../dto/mail-user-settings.dto.ts | 12 +- .../mail-user-settings.controller.ts | 5 +- .../mail-user-settings.repository.ts | 6 + .../mail-user-settings.service.spec.ts | 55 +- .../mail-user-settings.service.ts | 12 +- .../mail-user-settings.types.ts | 5 +- .../modules/mail/conversation.controller.ts | 89 ++ .../modules/mail/conversation.service.spec.ts | 342 +++++++ .../src/modules/mail/conversation.service.ts | 249 +++++ .../mail/dto/conversation-query.dto.ts | 55 ++ .../src/modules/mail/dto/mail-response.dto.ts | 121 +++ .../mail/jmap/jmap-conversations.spec.ts | 909 ++++++++++++++++++ .../modules/mail/jmap/jmap-conversations.ts | 622 ++++++++++++ .../mail/jmap/jmap-mail.provider.spec.ts | 2 + .../modules/mail/jmap/jmap-mail.provider.ts | 163 ++-- backend/src/modules/mail/jmap/mail-scope.ts | 27 + backend/src/modules/mail/jmap/pending-send.ts | 20 + backend/src/modules/mail/mail.module.ts | 7 + backend/src/modules/mail/mail.provider.ts | 37 + backend/src/modules/mail/mail.service.spec.ts | 16 +- backend/src/modules/mail/mail.types.ts | 49 + backend/test/app.e2e-spec.ts | 673 ++++++++++++- backend/test/search-text-samples.ts | 2 +- 39 files changed, 4612 insertions(+), 82 deletions(-) create mode 100644 backend/src/migrations/1793898213073-AddMailSearchSender.ts create mode 100644 backend/src/modules/mail-search/conversation-cursor.spec.ts create mode 100644 backend/src/modules/mail-search/conversation-cursor.ts create mode 100644 backend/src/modules/mail-search/conversation-sql.spec.ts create mode 100644 backend/src/modules/mail-search/conversation-sql.ts create mode 100644 backend/src/modules/mail-search/mail-conversation-query.service.ts create mode 100644 backend/src/modules/mail-search/thread-keys.spec.ts create mode 100644 backend/src/modules/mail-search/thread-keys.ts create mode 100644 backend/src/modules/mail/conversation.controller.ts create mode 100644 backend/src/modules/mail/conversation.service.spec.ts create mode 100644 backend/src/modules/mail/conversation.service.ts create mode 100644 backend/src/modules/mail/dto/conversation-query.dto.ts create mode 100644 backend/src/modules/mail/jmap/jmap-conversations.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-conversations.ts create mode 100644 backend/src/modules/mail/jmap/mail-scope.ts diff --git a/backend/src/migrations/1793898213073-AddMailSearchSender.ts b/backend/src/migrations/1793898213073-AddMailSearchSender.ts new file mode 100644 index 0000000..c57769a --- /dev/null +++ b/backend/src/migrations/1793898213073-AddMailSearchSender.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자). + * - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 — + * 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 ` 꼴인 보낸 사람이 그 주소로 보이고, 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 { + 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 { + await queryRunner.query( + `ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`, + ); + await queryRunner.query( + `ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`, + ); + } +} diff --git a/backend/src/modules/mail-search/conversation-cursor.spec.ts b/backend/src/modules/mail-search/conversation-cursor.spec.ts new file mode 100644 index 0000000..8b39a5a --- /dev/null +++ b/backend/src/modules/mail-search/conversation-cursor.spec.ts @@ -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; + const forge = (patch: Record) => + 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'); + } + }); +}); diff --git a/backend/src/modules/mail-search/conversation-cursor.ts b/backend/src/modules/mail-search/conversation-cursor.ts new file mode 100644 index 0000000..ed95100 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-cursor.ts @@ -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> = { + 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).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; + 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 }; +} diff --git a/backend/src/modules/mail-search/conversation-sql.spec.ts b/backend/src/modules/mail-search/conversation-sql.spec.ts new file mode 100644 index 0000000..5eb9645 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-sql.spec.ts @@ -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"'); + }); +}); diff --git a/backend/src/modules/mail-search/conversation-sql.ts b/backend/src/modules/mail-search/conversation-sql.ts new file mode 100644 index 0000000..c26cc78 --- /dev/null +++ b/backend/src/modules/mail-search/conversation-sql.ts @@ -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'; +} diff --git a/backend/src/modules/mail-search/index-document.spec.ts b/backend/src/modules/mail-search/index-document.spec.ts index 67929f5..6bba378 100644 --- a/backend/src/modules/mail-search/index-document.spec.ts +++ b/backend/src/modules/mail-search/index-document.spec.ts @@ -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 { @@ -125,6 +128,51 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => { expect(row.mailboxIds).toEqual(['inbox']); }); + it('첫 보낸 사람 칸(참여자 — 리뷰 L3) — 주소 · 이름을 따로, 이름 안의 `<주소>` 꼴은 주소가 되지 않는다', () => { + // 주소 없이 이름만 `대표 ` — 보낸 사람 글(조건 검색)에는 남지만 참여자 칸은 비운다 + const nameOnly = toIndexRow( + documentOf({ from: [{ name: '대표 ', email: null }] }), + ); + expect(nameOnly.fromText).toBe('대표 '); + expect(nameOnly.fromEmail).toBeNull(); + expect(nameOnly.fromName).toBeNull(); + + // 이름이 `<다른 주소>` 를 품어도 칸의 주소는 엔진이 준 주소다 + const disguised = toIndexRow( + documentOf({ + from: [ + { name: ' 대표 ', email: ' real@example.net ' }, + { name: '둘째', email: 'second@example.com' }, + ], + }), + ); + expect(disguised.fromEmail).toBe('real@example.net'); + expect(disguised.fromName).toBe('대표 '); + + 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({ diff --git a/backend/src/modules/mail-search/index-document.ts b/backend/src/modules/mail-search/index-document.ts index 96c88f4..167a6ae 100644 --- a/backend/src/modules/mail-search/index-document.ts +++ b/backend/src/modules/mail-search/index-document.ts @@ -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, }; diff --git a/backend/src/modules/mail-search/mail-conversation-query.service.ts b/backend/src/modules/mail-search/mail-conversation-query.service.ts new file mode 100644 index 0000000..b164b3c --- /dev/null +++ b/backend/src/modules/mail-search/mail-conversation-query.service.ts @@ -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 { + const query = conversationPageSql( + normalize(user), + emailId, + exclusions, + page.after, + page.limit + 1, + ); + const rows = await this.dataSource.query( + 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 { + 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( + 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( + detailQuery.sql, + detailQuery.params, + ); + const participantQuery = conversationParticipantsSql( + key, + mailboxId, + keys, + exclusions, + CONVERSATION_PARTICIPANTS_MAX, + ); + participantRows = await manager.query( + participantQuery.sql, + participantQuery.params, + ); + } + const details = new Map(detailRows.map((row) => [row.thread_key, row])); + const participants = new Map(); + 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, + }; + }); + } +} diff --git a/backend/src/modules/mail-search/mail-search-index.repository.ts b/backend/src/modules/mail-search/mail-search-index.repository.ts index ded96a1..0a326e1 100644 --- a/backend/src/modules/mail-search/mail-search-index.repository.ts +++ b/backend/src/modules/mail-search/mail-search-index.repository.ts @@ -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; } -/** 넣기에서 바꿀 칸 (열 이름) — 키(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 { - if (rows.length === 0) return; + ): Promise { + 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 { + 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 */ diff --git a/backend/src/modules/mail-search/mail-search-message.entity.ts b/backend/src/modules/mail-search/mail-search-message.entity.ts index 7325133..7deb1d0 100644 --- a/backend/src/modules/mail-search/mail-search-message.entity.ts +++ b/backend/src/modules/mail-search/mail-search-message.entity.ts @@ -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', diff --git a/backend/src/modules/mail-search/mail-search.constants.ts b/backend/src/modules/mail-search/mail-search.constants.ts index 68ea781..1c6f1c3 100644 --- a/backend/src/modules/mail-search/mail-search.constants.ts +++ b/backend/src/modules/mail-search/mail-search.constants.ts @@ -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'; diff --git a/backend/src/modules/mail-search/mail-search.module.ts b/backend/src/modules/mail-search/mail-search.module.ts index 5487d08..259d1df 100644 --- a/backend/src/modules/mail-search/mail-search.module.ts +++ b/backend/src/modules/mail-search/mail-search.module.ts @@ -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 {} diff --git a/backend/src/modules/mail-search/mail-search.types.ts b/backend/src/modules/mail-search/mail-search.types.ts index 2cceb08..f5f52ea 100644 --- a/backend/src/modules/mail-search/mail-search.types.ts +++ b/backend/src/modules/mail-search/mail-search.types.ts @@ -68,3 +68,12 @@ export interface MessageDetailHighlight { readonly attachments: Record; readonly textBody: SearchRange[]; } + +/** 대화의 참여자 — 보낸 사람 (메일 모듈의 MailAddress 와 같은 모양) */ +export interface ConversationParticipant { + readonly name: string | null; + readonly email: string; +} + +/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목 — 14단계 B4) · 엔진의 스레드(색인을 다 만들기 전) */ +export type ConversationBasis = 'index' | 'engine'; diff --git a/backend/src/modules/mail-search/thread-keys.spec.ts b/backend/src/modules/mail-search/thread-keys.spec.ts new file mode 100644 index 0000000..40988f2 --- /dev/null +++ b/backend/src/modules/mail-search/thread-keys.spec.ts @@ -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'])); + }); +}); diff --git a/backend/src/modules/mail-search/thread-keys.ts b/backend/src/modules/mail-search/thread-keys.ts new file mode 100644 index 0000000..28eab96 --- /dev/null +++ b/backend/src/modules/mail-search/thread-keys.ts @@ -0,0 +1,46 @@ +/** + * 대화 키 합치기 (순수 함수 — 14단계 설계 B4). + * 색인 줄마다 대화 키(thread_key)가 있다 — 처음에는 그 메일의 id 다. 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 + * 두 키의 줄을 모두 **가장 작은 키**로 바꾼다. 이미 합친 대화는 한 키를 나눠 쓰므로, 새로 들어온 메일과 바로 이어진 메일의 키만 합치면 + * 대화 전체가 한 키가 된다(늦게 온 원본이 둘로 갈려 있던 답장들을 잇는 것도 같다). + * + * `links` 는 [키, 키] 쌍들 — 이어진 두 메일의 지금 키. 돌려주는 것은 바꿀 키 → 새 키(가장 작은 키)이다. 바꿀 것이 없으면 빈 Map. + * 쌍 수에 거의 비례한다(경로 줄이기 · 크기로 잇기). + */ +export function mergeThreadKeys( + links: readonly (readonly [string, string])[], +): Map { + const parent = new Map(); + 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(); + for (const key of parent.keys()) { + const root = find(key); + if (root !== key) renames.set(key, root); + } + return renames; +} diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts index e52d968..7770471 100644 --- a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.spec.ts @@ -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', + ]); + } + }); }); diff --git a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts index 73f5850..3ca5bda 100644 --- a/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts +++ b/backend/src/modules/mail-user-settings/dto/mail-user-settings.dto.ts @@ -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; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts index 98dc12c..2eb1377 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts @@ -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, }); } } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts index b0e40ec..986f46c 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.repository.ts @@ -12,6 +12,7 @@ import type { const COLUMNS: Readonly> = { 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; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts index 463a047..65f7dd7 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.spec.ts @@ -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, + }); + }); }); diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts index bd13302..76fb919 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.service.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.service.ts @@ -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 { 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; } diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts index 90e6fde..aa328c5 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.types.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.types.ts @@ -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; -/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */ +/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기 · 대화로 묶지 않음) */ export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({ undoSendSeconds: 10, externalImages: 'ask', + groupByConversation: false, }); /** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */ diff --git a/backend/src/modules/mail/conversation.controller.ts b/backend/src/modules/mail/conversation.controller.ts new file mode 100644 index 0000000..5070aa9 --- /dev/null +++ b/backend/src/modules/mail/conversation.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/mail/conversation.service.spec.ts b/backend/src/modules/mail/conversation.service.spec.ts new file mode 100644 index 0000000..6d30600 --- /dev/null +++ b/backend/src/modules/mail/conversation.service.spec.ts @@ -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) => + 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' } }, + ); + }); +}); diff --git a/backend/src/modules/mail/conversation.service.ts b/backend/src/modules/mail/conversation.service.ts new file mode 100644 index 0000000..d899a96 --- /dev/null +++ b/backend/src/modules/mail/conversation.service.ts @@ -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 = PaginatedResult & { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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() }; + 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)}`, + ); + } +} diff --git a/backend/src/modules/mail/dto/conversation-query.dto.ts b/backend/src/modules/mail/dto/conversation-query.dto.ts new file mode 100644 index 0000000..2c4fe17 --- /dev/null +++ b/backend/src/modules/mail/dto/conversation-query.dto.ts @@ -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; +} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 8f78063..b6139bc 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -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', diff --git a/backend/src/modules/mail/jmap/jmap-conversations.spec.ts b/backend/src/modules/mail/jmap/jmap-conversations.spec.ts new file mode 100644 index 0000000..fa08632 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-conversations.spec.ts @@ -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 = {}, +) { + 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 { + 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; + readonly keywords?: Record; +} + +/** + * 메일을 들고 요청마다 답하는 엔진 대역 (큰 스레드 — 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(); + 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>(); + const resolve = (args: Record): 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; + 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(); + 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([]); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-conversations.ts b/backend/src/modules/mail/jmap/jmap-conversations.ts new file mode 100644 index 0000000..f27fbad --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-conversations.ts @@ -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(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, + 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(); + 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 { + 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>( + lookup, + 'e', + 'Email/get', + ); + if (found.length === 0) { + throw new BusinessException('RES_001', { + context: { reason: 'conversation-message-not-found' }, + }); + } + const thread = expectResult>( + 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; + 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 { + 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>( + 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(responses, 'q', 'Email/query'); + const { list: latestEmails = [] } = expectResult>( + responses, + 'g', + 'Email/get', + ); + const { list: threads = [] } = expectResult>( + 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).list ?? []; + } + + /** 메일 여러 통을 값싼 속성으로 — 훑는 호출(마지막 요약 읽기 몫을 남긴 제한 시간), 엔진 장애는 그대로 던진다 */ + private async readEmails( + user: string, + ids: readonly string[], + properties: readonly string[], + budget: ReadBudget, + ): Promise> { + 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>( + 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; attempted: Set }> { + const emails = new Map(); + const attempted = new Set(); + for (const batch of chunks(ids, MEMBER_BATCH_SIZE)) { + if (!budget.canScan()) break; + let read: Map; + 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 { + 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>( + 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))] : []; + }); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index ebae44f..6578061 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -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), ), }; } diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index d06668d..1079181 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -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 = new Set(['trash', 'junk']); - /** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */ const CONDITION_PROPERTIES: readonly (readonly [ keyof Pick, @@ -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 { @@ -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 { + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Mailbox/get', + { accountId, ids: null, properties: ['id', 'role'] }, + 'm', + ], + ], + { invalidRequestCode: 'MAIL_001' }, + ); + const { list: mailboxes = [] } = expectResult>( + 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 { + return this.conversations.conversation(user, emailId, page); + } + + listConversations( + user: string, + query: ConversationListQuery, + ): Promise { + 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 { + 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 { 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>( - responses, - 'g', - 'Email/get', - ); + const list = + valid.length > 0 + ? (expectResult>(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, diff --git a/backend/src/modules/mail/jmap/mail-scope.ts b/backend/src/modules/mail/jmap/mail-scope.ts new file mode 100644 index 0000000..cdf9662 --- /dev/null +++ b/backend/src/modules/mail/jmap/mail-scope.ts @@ -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 = new Set([ + 'trash', + 'junk', +]); + +/** 빼는 역할(휴지통 · 스팸메일함)의 메일함 id */ +export function excludedMailboxIdsOf( + mailboxes: readonly Pick[], +): string[] { + return mailboxes + .filter((mailbox) => + SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''), + ) + .map((mailbox) => mailbox.id); +} diff --git a/backend/src/modules/mail/jmap/pending-send.ts b/backend/src/modules/mail/jmap/pending-send.ts index 5024eff..33e1d01 100644 --- a/backend/src/modules/mail/jmap/pending-send.ts +++ b/backend/src/modules/mail/jmap/pending-send.ts @@ -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) */ diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e281c6b..d97c3ae 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -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) diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 568d0b0..76cc46a 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -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; + /** + * 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다. + * `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001) + */ + conversationScope(user: string, mailboxId?: string): Promise; + /** * 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때) */ getSummaries(user: string, ids: readonly string[]): Promise; + /** + * 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식: 줄의 messageIds 가운데 붙잡힌 메일 pendingSendIds). + * getSummaries 와 같은 요청 하나 — 기다리는 제출은 한 번만 읽는다 + */ + getSummariesWithHeld( + user: string, + ids: readonly string[], + ): Promise; + + /** + * 같은 대화 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 + * 지금 보는 메일(`emailId`)은 넣는다. 그 메일이 없으면 RES_001. 커서는 이 메일의 대화에서만 쓴다(다르면 VAL_001) + */ + getConversation( + user: string, + emailId: string, + page: { readonly limit: number; readonly cursor?: string }, + ): Promise; + + /** + * 대화로 묶은 목록 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 그 메일함 안 마지막 받은 시각 최신부터. 없는 메일함은 RES_001. + * 커서는 그 메일함의 묶은 목록에서만 쓴다(다르면 VAL_001) + */ + listConversations( + user: string, + query: ConversationListQuery, + ): Promise; + /** * 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다 */ diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 5742d35..f55a7f1 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -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, [string]>() - .mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }), + get: jest.fn, [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) => diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 0b80610..3ba5a07 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -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; +} + +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; diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index b17c8dd..9156366 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -221,6 +221,8 @@ const MAIL_STATE = 'c2Iy.c2Fq'; /** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */ const MISSING_MAILBOX = 'nomailbox'; +/** 대역 엔진에 없는 메일 (14단계 3차 — 같은 대화의 404) */ +const MISSING_MESSAGE = 'nosuchmail'; /** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */ const GONE_MESSAGE = 'gonemessage'; @@ -748,6 +750,19 @@ describe('응답 계약 (e2e)', () => { const listQueries: { user: string; query: MessageListQuery }[] = []; /** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */ const summaryRequests: { user: string; ids: readonly string[] }[] = []; + /** 14단계 3차 — 대역 엔진이 받은 대화 요청 (엔진의 스레드 — 색인 전) */ + const engineConversations: { + user: string; + emailId: string; + page: { limit: number; cursor?: string }; + }[] = []; + const engineConversationLists: { + user: string; + query: { mailboxId: string; limit: number; cursor?: string }; + }[] = []; + /** 14단계 3차 리뷰 L6 — 대역 엔진의 붙잡힌 메일(보내기 취소 · 예약을 기다리는) id 와 그것을 함께 읽은 요약 요청 */ + const fakeHeldIds = new Set(); + const heldSummaryRequests: { user: string; ids: readonly string[] }[] = []; /** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */ const bulkUpdates: { user: string; @@ -1296,6 +1311,59 @@ describe('응답 계약 (e2e)', () => { }, ); }, + // 14단계 3차 — 대화의 범위(늘 휴지통을 뺀다) · 엔진의 스레드로 본 대화(색인 전 — 받은 것만 적는다) + conversationScope: ( + _user: string, + mailboxId?: string, + ): Promise => + mailboxId === MISSING_MAILBOX + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ) + : Promise.resolve({ + mailboxId: mailboxId ?? null, + excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX], + excludedKeywords: ['$ara-sending'], + }), + getConversation: ( + user: string, + emailId: string, + page: { limit: number; cursor?: string }, + ) => { + engineConversations.push({ user, emailId, page }); + return emailId === MISSING_MESSAGE + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-message-not-found' }, + }), + ) + : Promise.resolve({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + }, + listConversations: ( + user: string, + query: { mailboxId: string; limit: number; cursor?: string }, + ) => { + engineConversationLists.push({ user, query }); + return query.mailboxId === MISSING_MAILBOX + ? Promise.reject( + new BusinessException('RES_001', { + context: { reason: 'e2e-mailbox-not-found' }, + }), + ) + : Promise.resolve({ + items: [], + hasNext: false, + nextCursor: null, + total: 0, + }); + }, getSummaries: ( user: string, ids: readonly string[], @@ -1310,6 +1378,18 @@ describe('응답 계약 (e2e)', () => { }), ); }, + getSummariesWithHeld: (user: string, ids: readonly string[]) => { + heldSummaryRequests.push({ user, ids }); + const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? []; + const byId = new Map(emails.map((email) => [email.id, email])); + return Promise.resolve({ + summaries: ids.flatMap((id) => { + const email = byId.get(id); + return email ? [summaryOfDocument(email)] : []; + }), + heldIds: new Set(fakeHeldIds), + }); + }, updateMessages: ( user: string, ids: readonly string[], @@ -5202,7 +5282,11 @@ describe('응답 계약 (e2e)', () => { expect(body(res)).toEqual({ success: true, - data: { undoSendSeconds: 10, externalImages: 'ask' }, + data: { + undoSendSeconds: 10, + externalImages: 'ask', + groupByConversation: false, + }, }); }); @@ -5213,6 +5297,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(first)).toEqual({ undoSendSeconds: 10, externalImages: 'always', + groupByConversation: false, }); const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect( @@ -5221,6 +5306,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(second)).toEqual({ undoSendSeconds: 30, externalImages: 'always', + groupByConversation: false, }); const read = await request(server) @@ -5230,6 +5316,7 @@ describe('응답 계약 (e2e)', () => { expect(dataOf(read)).toEqual({ undoSendSeconds: 30, externalImages: 'always', + groupByConversation: false, }); const rows: { undo_send_seconds: number; @@ -5243,6 +5330,40 @@ describe('응답 계약 (e2e)', () => { ]); }); + it('대화로 묶어 보기(14단계) — 처음 끔, PATCH 로 켜고 끄며 다른 칸은 그대로 · DB 의 열에 저장', async () => { + const GROUP = 'settings.group@e2e.arasystem.test'; + const on = await patchSettings(GROUP, { + groupByConversation: true, + }).expect(200); + expect(dataOf(on)).toEqual({ + undoSendSeconds: 10, + externalImages: 'ask', + groupByConversation: true, + }); + const read = await request(server) + .get('/api/mail-settings') + .set('Cookie', cookieOf(GROUP)) + .expect(200); + expect(dataOf<{ groupByConversation: boolean }>(read)).toMatchObject({ + groupByConversation: true, + }); + const [row]: { group_by_conversation: boolean }[] = + await dataSource.query( + 'SELECT group_by_conversation FROM mail_settings WHERE user_email = $1', + [GROUP], + ); + expect(row).toEqual({ group_by_conversation: true }); + const off = await patchSettings(GROUP, { + groupByConversation: false, + undoSendSeconds: 5, + }).expect(200); + expect(dataOf(off)).toEqual({ + undoSendSeconds: 5, + externalImages: 'ask', + groupByConversation: false, + }); + }); + it.each<[string, object]>([ ['빈 본문(하나 이상)', {}], ['고를 수 없는 초', { undoSendSeconds: 7 }], @@ -5250,6 +5371,8 @@ describe('응답 계약 (e2e)', () => { ['null 초', { undoSendSeconds: null }], ['모르는 외부 이미지 값', { externalImages: 'never' }], ['null 외부 이미지', { externalImages: null }], + ['글로 쓴 대화로 묶기', { groupByConversation: 'true' }], + ['null 대화로 묶기', { groupByConversation: null }], ['모르는 칸', { theme: 'dark' }], ])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => { const res = await patchSettings(ME, payload).expect(400); @@ -8407,6 +8530,554 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('14단계 3차 — 대화 키 · 같은 대화 · 대화로 묶은 목록 (실제 PostgreSQL)', () => { + const ME = 'thread.me@e2e.arasystem.test'; + const OTHER = 'thread.other@e2e.arasystem.test'; + const MINE = { name: '김아라', email: ME }; + const at = (day: number, hour: number) => `2026-11-0${day}T0${hour}:00:00Z`; + /** 견적 대화 — 회신 · 답장 · 전달 말머리 · 겹친 Re · 꼬리표, 휴지통에만 든 답장 하나 */ + const DOCUMENTS = [ + indexDocument({ + id: 't-orig', + subject: '견적 요청', + messageIds: ['orig@e2e.test'], + receivedAt: at(1, 1), + }), + indexDocument({ + id: 't-re', + subject: 'Re: 견적 요청', + messageIds: ['re1@e2e.test'], + inReplyTo: ['orig@e2e.test'], + references: ['orig@e2e.test'], + receivedAt: at(1, 2), + }), + indexDocument({ + id: 't-hoesin', + subject: '회신: 견적 요청', + messageIds: ['re2@e2e.test'], + inReplyTo: ['re1@e2e.test'], + references: ['orig@e2e.test', 're1@e2e.test'], + from: [MINE], + mailboxIds: ['sent'], + receivedAt: at(1, 3), + }), + indexDocument({ + id: 't-fwd', + subject: 'FW: 전달: [외부] 견적 요청', + messageIds: ['fw@e2e.test'], + references: ['orig@e2e.test'], + receivedAt: at(1, 4), + }), + indexDocument({ + id: 't-rere', + subject: 'RE: RE: Re[2]: 견적 요청', + messageIds: ['re3@e2e.test'], + inReplyTo: ['re2@e2e.test'], + keywords: [], + receivedAt: at(1, 5), + }), + indexDocument({ + id: 't-trash', + subject: 'Re: 견적 요청', + messageIds: ['re4@e2e.test'], + inReplyTo: ['re3@e2e.test'], + mailboxIds: [SEARCH_EXCLUDED_MAILBOX], + receivedAt: at(1, 6), + }), + // 답장 관계는 있지만 제목이 다르다 — 따로 + indexDocument({ + id: 't-other', + subject: 'Re: 다른 일', + messageIds: ['other@e2e.test'], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 7), + }), + // Message-ID 가 없다 — 혼자 + indexDocument({ + id: 't-nomid', + subject: 'Re: 견적 요청', + messageIds: [], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 8), + }), + // 원본(lo@)보다 먼저 들어온 답장 둘 — 원본이 오면 합친다 + indexDocument({ + id: 't-late-r1', + subject: '답장: 회의록 공유', + messageIds: ['lr1@e2e.test'], + inReplyTo: ['lo@e2e.test'], + receivedAt: at(2, 1), + }), + indexDocument({ + id: 't-late-r2', + subject: 'Re: 회의록 공유', + messageIds: ['lr2@e2e.test'], + inReplyTo: ['lo@e2e.test'], + receivedAt: at(2, 2), + }), + // 발송 중인 내부 초안 — 견적 대화에 이어지지만 목록 · 다른 메일의 대화에서는 빠진다 + indexDocument({ + id: 't-sending', + subject: 'Re: 견적 요청', + messageIds: ['re5@e2e.test'], + inReplyTo: ['re3@e2e.test'], + mailboxIds: ['drafts'], + keywords: ['$draft', '$ara-sending'], + receivedAt: at(2, 3), + }), + // 주소 없이 이름만 `대표 ` 꼴인 보낸 사람 — 참여자가 그 주소로 보이지 않는다 (리뷰 L3, 공지 메일함) + indexDocument({ + id: 't-notice', + subject: '공지', + messageIds: ['notice@e2e.test'], + from: [{ name: '대표 ', email: null }], + mailboxIds: ['notice'], + receivedAt: at(3, 1), + }), + indexDocument({ + id: 't-notice-re', + subject: 'Re: 공지', + messageIds: ['notice-re@e2e.test'], + inReplyTo: ['notice@e2e.test'], + mailboxIds: ['notice'], + receivedAt: at(3, 2), + }), + ]; + const LATE_ORIGINAL = indexDocument({ + id: 't-late-orig', + subject: '회의록 공유', + messageIds: ['lo@e2e.test'], + receivedAt: at(1, 9), + }); + + const keysOf = async (user: string) => + Object.fromEntries( + ( + await dataSource.query<{ email_id: string; thread_key: string }[]>( + 'SELECT email_id, thread_key FROM mail_search_messages WHERE user_email = $1', + [user], + ) + ).map((row) => [row.email_id, row.thread_key]), + ); + const conversation = ( + id: string, + query: Record = {}, + ) => + request(server) + .get(`/api/messages/${id}/conversation`) + .query(query) + .set('Cookie', cookieOf(ME)); + const conversations = (query: Record) => + request(server) + .get('/api/conversations') + .query(query) + .set('Cookie', cookieOf(ME)); + interface ConversationPage { + items: { id: string }[]; + pageInfo: Page['pageInfo']; + basis: string; + } + interface ConversationRow { + id: string; + latest: { id: string }; + participants: { name: string | null; email: string }[]; + messageCount: number; + messageIds: string[]; + pendingSendIds: string[]; + isRead: boolean; + isFlagged: boolean; + hasAttachment: boolean; + } + interface ConversationListPage { + items: ConversationRow[]; + pageInfo: Page['pageInfo']; + basis: string; + } + const setStatus = (status: 'ready' | 'building') => + dataSource.query( + 'UPDATE mail_search_states SET status = $2 WHERE user_email = $1', + [ME, status], + ); + + beforeAll(async () => { + for (const [user, emails] of [ + [ME, DOCUMENTS], + [ + OTHER, + // 같은 메일을 받은 다른 직원 — 같은 Message-ID · 제목이어도 그 직원의 대화다 + [ + indexDocument({ + id: 'x-orig', + subject: '견적 요청', + messageIds: ['orig@e2e.test'], + receivedAt: at(1, 1), + }), + indexDocument({ + id: 'x-re', + subject: 'Re: 견적 요청', + messageIds: ['re1@e2e.test'], + inReplyTo: ['orig@e2e.test'], + receivedAt: at(1, 2), + }), + ], + ], + ] as const) { + await request(server) + .get('/api/search-index') + .set('Cookie', cookieOf(user)) + .expect(200); + fakeIndexSource.mailboxes.set(user, { + state: 'c1', + emails: [...emails], + }); + expect( + await app + .get(MailSearchBuildService) + .build(user, Date.now() + 120_000), + ).toBe('ready'); + } + }); + + afterAll(async () => { + for (const user of [ME, OTHER]) { + fakeIndexSource.mailboxes.delete(user); + for (const table of ['mail_search_messages', 'mail_search_states']) { + await dataSource.query( + `DELETE FROM "${table}" WHERE user_email = $1`, + [user], + ); + } + } + }); + + it('대화 키 — 답장 관계 + 말머리(Re · RE · Re[2] · 회신 · 전달 · FW · [꼬리표])를 뗀 제목이 같으면 가장 작은 키 하나, 제목이 다르거나 Message-ID 가 없으면 혼자 · 다른 직원과 섞이지 않는다', async () => { + const keys = await keysOf(ME); + for (const id of [ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + 't-trash', + ]) { + expect(keys[id]).toBe('t-fwd'); + } + expect(keys['t-other']).toBe('t-other'); + expect(keys['t-nomid']).toBe('t-nomid'); + // 원본이 아직 없다 — 두 답장은 따로 + expect(keys['t-late-r1']).toBe('t-late-r1'); + expect(keys['t-late-r2']).toBe('t-late-r2'); + expect(await keysOf(OTHER)).toEqual({ + 'x-orig': 'x-orig', + 'x-re': 'x-orig', + }); + }); + + it('늦게 온 원본은 들어올 때 갈려 있던 답장들을 잇는다 — 같은 메일을 다시 넣어도(겹친 채우기) 합친 키를 풀지 않는다', async () => { + const repository = app.get(MailSearchIndexRepository); + fakeIndexSource.mailboxes.get(ME)?.emails.push(LATE_ORIGINAL); + await repository.write( + ME, + { upserts: [toIndexRow(LATE_ORIGINAL)] }, + new Date(), + ); + let keys = await keysOf(ME); + for (const id of ['t-late-orig', 't-late-r1', 't-late-r2']) { + expect(keys[id]).toBe('t-late-orig'); + } + + await repository.write( + ME, + { upserts: [toIndexRow(DOCUMENTS[1]), toIndexRow(DOCUMENTS[8])] }, + new Date(), + ); + keys = await keysOf(ME); + expect(keys['t-re']).toBe('t-fwd'); + expect(keys['t-late-r1']).toBe('t-late-orig'); + }); + + it('같은 대화 — 오래된 차례 · 보낸편지함 포함 · 휴지통에만 든 메일은 빼되 지금 메일은 넣는다, total · basis index', async () => { + const page = dataOf( + await conversation('t-rere').expect(200), + ); + expect(page.items.map((item) => item.id)).toEqual([ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 5, + }); + expect(page.basis).toBe('index'); + + const fromTrash = dataOf( + await conversation('t-trash').expect(200), + ); + expect(fromTrash.items.map((item) => item.id)).toContain('t-trash'); + expect(fromTrash.pageInfo.total).toBe(6); + + // 발송 중인 내부 초안도 그 메일을 열면 넣는다 (엔진 방식과 같다) + const sending = dataOf( + await conversation('t-sending').expect(200), + ); + expect(sending.items.map((item) => item.id)).toEqual([ + 't-orig', + 't-re', + 't-hoesin', + 't-fwd', + 't-rere', + 't-sending', + ]); + expect(sending.pageInfo.total).toBe(6); + + // 한 통뿐인 대화는 그 메일 하나 + const alone = dataOf( + await conversation('t-other').expect(200), + ); + expect(alone.items.map((item) => item.id)).toEqual(['t-other']); + }); + + it('같은 대화의 쪽 넘기기 — 커서는 그 메일의 대화에서만, limit 처음 값이자 최대 100', async () => { + const first = dataOf( + await conversation('t-rere', { limit: 2 }).expect(200), + ); + expect(first.items.map((item) => item.id)).toEqual(['t-orig', 't-re']); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 }); + const second = dataOf( + await conversation('t-rere', { + limit: 2, + cursor: first.pageInfo.nextCursor as string, + }).expect(200), + ); + expect(second.items.map((item) => item.id)).toEqual([ + 't-hoesin', + 't-fwd', + ]); + expect(second.pageInfo.total).toBeNull(); + + for (const [id, query] of [ + ['t-re', { cursor: first.pageInfo.nextCursor as string }], + ['t-rere', { limit: 101 }], + ['t-rere', { limit: 0 }], + ['t-rere', { q: '견적' }], + ] as const) { + const res = await conversation(id, query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('색인 준비 중 · 색인에 없는 메일은 엔진의 스레드(basis engine) — 엔진에도 없으면 404 RES_001', async () => { + const before = engineConversations.length; + const missing = await conversation(MISSING_MESSAGE).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + await setStatus('building'); + try { + const engine = dataOf( + await conversation('t-re').expect(200), + ); + expect(engine.basis).toBe('engine'); + } finally { + await setStatus('ready'); + } + expect( + engineConversations.slice(before).map((call) => call.emailId), + ).toEqual([MISSING_MESSAGE, 't-re']); + }); + + it('대화로 묶은 목록 — 그 메일함 안 마지막 받은 시각 최신부터, 통 수는 대화 전체(휴지통에만 든 것 빼고) · 그 메일함 안 id · 읽음 · 참여자', async () => { + const page = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect(page.basis).toBe('index'); + expect(page.items.map((row) => row.latest.id)).toEqual([ + 't-late-r2', + 't-nomid', + 't-other', + 't-rere', + ]); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 4, + }); + const quote = page.items[3]; + expect(quote).toMatchObject({ + id: 't-fwd', + messageCount: 5, + messageIds: ['t-rere', 't-fwd', 't-re', 't-orig'], + pendingSendIds: [], + isRead: false, + isFlagged: false, + hasAttachment: false, + }); + expect(quote.participants.map((p) => p.email)).toEqual([ + 'doyun.lee@e2e.arasystem.test', + ME, + ]); + expect(page.items[0]).toMatchObject({ + id: 't-late-orig', + messageCount: 3, + messageIds: ['t-late-r2', 't-late-r1', 't-late-orig'], + }); + + // 보낸편지함에서는 그 메일함의 메일만 — 통 수는 대화 전체. 붙잡힌 메일(보내기 취소를 기다리는)은 pendingSendIds 로 (리뷰 L6) + const before = heldSummaryRequests.length; + fakeHeldIds.add('t-hoesin'); + try { + const sent = dataOf( + await conversations({ mailboxId: 'sent' }).expect(200), + ); + expect(sent.items).toHaveLength(1); + expect(sent.items[0]).toMatchObject({ + latest: { id: 't-hoesin' }, + messageIds: ['t-hoesin'], + pendingSendIds: ['t-hoesin'], + messageCount: 5, + }); + } finally { + fakeHeldIds.clear(); + } + // 기다리는 제출은 쪽마다 한 번 — 마지막 메일의 요약과 같은 요청 + expect(heldSummaryRequests.slice(before)).toEqual([ + { user: ME, ids: ['t-hoesin'] }, + ]); + }); + + it('참여자는 첫 보낸 사람 칸에서 — 이름 안의 `<주소>` 는 참여자 주소가 되지 않는다 (리뷰 L3)', async () => { + const keys = await keysOf(ME); + expect(keys['t-notice-re']).toBe('t-notice'); + const senders = await dataSource.query< + { + email_id: string; + from_email: string | null; + from_name: string | null; + }[] + >( + 'SELECT email_id, from_email, from_name FROM mail_search_messages WHERE user_email = $1 AND email_id = ANY($2::text[]) ORDER BY email_id', + [ME, ['t-notice', 't-notice-re']], + ); + expect(senders).toEqual([ + { email_id: 't-notice', from_email: null, from_name: null }, + { + email_id: 't-notice-re', + from_email: 'doyun.lee@e2e.arasystem.test', + from_name: '이도윤', + }, + ]); + const notice = dataOf( + await conversations({ mailboxId: 'notice' }).expect(200), + ); + expect(notice.items).toHaveLength(1); + expect(notice.items[0]).toMatchObject({ + messageCount: 2, + messageIds: ['t-notice-re', 't-notice'], + participants: [ + { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }, + ], + }); + }); + + it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음)는 두 경로 모두 400 VAL_001 — 색인에 없는 메일이어도 404 보다 먼저 (리뷰 L1 · L8)', async () => { + const forged = (payload: Record) => + Buffer.from(JSON.stringify(payload)).toString('base64url'); + for (const [id, cursor] of [ + ['t-rere', forged({ m: 'ti' })], + ['t-rere', forged({ v: 1, m: 'ti', t: 'x', k: '../a', s: 'zz' })], + [MISSING_MESSAGE, forged({ m: 'ti' })], + [MISSING_MESSAGE, forged({ v: 1, m: 'ti', t: 1, k: 'a', s: 'zz' })], + ] as const) { + const res = await conversation(id, { cursor }).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + for (const cursor of [ + forged({ m: 'li' }), + forged({ v: 1, m: 'li', t: -1, k: 't-fwd', s: 'zz' }), + ]) { + const res = await conversations({ mailboxId: 'inbox', cursor }).expect( + 400, + ); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('묶은 목록의 쪽 넘기기 · 범위 — 커서는 그 메일함에서만, 없는 메일함은 404 · mailboxId 없음은 400', async () => { + const first = dataOf( + await conversations({ mailboxId: 'inbox', limit: 2 }).expect(200), + ); + expect(first.items.map((row) => row.latest.id)).toEqual([ + 't-late-r2', + 't-nomid', + ]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 }); + const second = dataOf( + await conversations({ + mailboxId: 'inbox', + limit: 2, + cursor: first.pageInfo.nextCursor as string, + }).expect(200), + ); + expect(second.items.map((row) => row.latest.id)).toEqual([ + 't-other', + 't-rere', + ]); + expect(second.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: null, + }); + + const otherMailbox = await conversations({ + mailboxId: 'sent', + cursor: first.pageInfo.nextCursor as string, + }).expect(400); + expect(body(otherMailbox).error?.code).toBe('VAL_001'); + const missing = await conversations({ + mailboxId: MISSING_MAILBOX, + }).expect(404); + expect(body(missing).error?.code).toBe('RES_001'); + const invalid: Record[] = [ + {}, + { mailboxId: '../a' }, + { mailboxId: 'inbox', q: '견적' }, + ]; + for (const query of invalid) { + const res = await conversations(query).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + it('색인 준비 중이면 엔진의 스레드(basis engine — collapseThreads) · 다른 직원의 줄은 섞이지 않는다', async () => { + const before = engineConversationLists.length; + await setStatus('building'); + try { + const engine = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect(engine.basis).toBe('engine'); + } finally { + await setStatus('ready'); + } + expect(engineConversationLists.slice(before)).toEqual([ + { + user: ME, + query: { mailboxId: 'inbox', limit: 20, cursor: undefined }, + }, + ]); + const mine = dataOf( + await conversations({ mailboxId: 'inbox' }).expect(200), + ); + expect( + mine.items + .flatMap((row) => [row.id, ...row.messageIds]) + .filter((id) => id.startsWith('x-')), + ).toEqual([]); + }); + }); + describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => { let systemSettings: SystemSettingsService; /** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */ diff --git a/backend/test/search-text-samples.ts b/backend/test/search-text-samples.ts index 80f2512..bbbbfc2 100644 --- a/backend/test/search-text-samples.ts +++ b/backend/test/search-text-samples.ts @@ -2,7 +2,7 @@ * 메일 검색의 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다). * 서버의 색인 · 찾기(src/shared/utils/search-text.ts · subject-base.ts)와 화면의 미리 확인 · 강조(frontend `shared/utils/search-match.ts`)를 같은 표로 본다. * - * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로 — 14단계 frontend 차례에 옮긴다). + * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다). */