generated from ttipo/project-base
feat(backend): 대화 묶기를 더한다 — 대화 키 · 같은 대화 · 대화로 묶은 목록 · 설정
14단계 3차다. 같은 대화(답장 · 전달로 이어진 메일)를 읽기 화면에 보이고, 목록을 대화로
묶어 볼 수 있게 한다. 색인이 준비된 직원은 우리 대화 키로, 채우는 중인 직원은 엔진의
스레드로 묶는다(응답의 basis — 첫 쪽에서 정하고 커서가 잇는다).
- 대화 키: 색인 쓰기 때 넣은 메일의 부모 · 자식(References · In-Reply-To) 가운데 말머리
(회신 · 답장 · 전달 포함)를 뗀 제목이 같은 것을 가장 작은 키로 합친다. 늦게 온 원본도
잇고, 다시 넣어도 키는 풀리지 않는다. 색인 판 4
- GET /api/messages/{id}/conversation — 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과
발송 중인 내부 초안은 빼고 지금 메일은 늘 넣는다
- GET /api/conversations?mailboxId= — 그 메일함의 대화 한 줄씩(참여자 10명 · 통 수 ·
messageIds 최신 500 · pendingSendIds). 직원 주소 btree 로 먼저 좁힌다(10명 흉내 큰 메일함
첫 쪽 81ms)
- 설정 groupByConversation(처음 끔) — 서버는 저장만 하고 묶을지는 화면이 정한다
- 마이그레이션 AddMailSearchSender — 참여자는 따로 둔 첫 보낸 사람 칸에서만 읽는다
리뷰 반영: 엔진 방식에 개수 · 시간 상한(같은 대화 한 요청 1,000통 · 묶은 목록 스레드마다
500 · 한 요청 2,000 · 15초, 요약 속성은 내보낼 메일만), Mailbox/get 장애는 503,
꾸민 커서는 엔진 · DB 보다 먼저 400, 같은 대화 한 문장, 이름 안의 <주소>로 참여자를
꾸미지 못하게, 로그 추적 ID. 검색 표본 표 머리 주석(frontend 로 옮겼다)도 함께 고친다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeCursor } from '@/modules/mail/jmap/jmap-cursor';
|
||||
import {
|
||||
conversationCursorMethod,
|
||||
decodeConversationCursor,
|
||||
encodeConversationCursor,
|
||||
} from './conversation-cursor';
|
||||
import { encodeIndexCursor } from './search-cursor';
|
||||
|
||||
function codeOf(run: () => unknown): string | null {
|
||||
try {
|
||||
run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('대화 커서 (conversation-cursor — 14단계 C3 · C4)', () => {
|
||||
const position = { at: new Date('2026-11-02T01:23:45.678Z'), key: 'm9x' };
|
||||
|
||||
it('자리를 그대로 되돌린다 — 같은 대화는 그 메일, 묶은 목록은 그 메일함 범위에서만', () => {
|
||||
const thread = encodeConversationCursor('thread', position, 'e1');
|
||||
const list = encodeConversationCursor('list', position, 'inbox');
|
||||
expect(thread).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(decodeConversationCursor('thread', thread, 'e1')).toEqual(position);
|
||||
expect(decodeConversationCursor('list', list, 'inbox')).toEqual(position);
|
||||
|
||||
// 다른 메일 · 다른 메일함 · 다른 종류의 커서는 VAL_001
|
||||
expect(codeOf(() => decodeConversationCursor('thread', thread, 'e2'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(codeOf(() => decodeConversationCursor('list', list, 'sent'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(codeOf(() => decodeConversationCursor('list', thread, 'e1'))).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('방식을 커서로 안다 — 그 종류의 색인 커서만 index, 엔진 · 검색 커서 · 알 수 없는 값은 engine', () => {
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeConversationCursor('thread', position, 'e1'),
|
||||
'thread',
|
||||
),
|
||||
).toBe('index');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeConversationCursor('list', position, 'inbox'),
|
||||
'thread',
|
||||
),
|
||||
).toBe('engine');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeCursor(
|
||||
{ anchor: 'm1', position: 3 },
|
||||
{ conditions: ['conversation', 'e1'] },
|
||||
),
|
||||
'thread',
|
||||
),
|
||||
).toBe('engine');
|
||||
expect(
|
||||
conversationCursorMethod(
|
||||
encodeIndexCursor({ receivedAt: position.at, emailId: 'm1' }, 'scope'),
|
||||
'list',
|
||||
),
|
||||
).toBe('engine');
|
||||
for (const cursor of ['', 'not-a-cursor', 'a'.repeat(600)]) {
|
||||
expect(conversationCursorMethod(cursor, 'list')).toBe('engine');
|
||||
}
|
||||
|
||||
// `m` 만 맞은 꾸민 커서는 색인 커서로 본다 — 나머지 모양은 decode 가 VAL_001 로 (서비스 입구 — 리뷰 L1)
|
||||
const onlyMethod = Buffer.from(JSON.stringify({ m: 'ti' })).toString(
|
||||
'base64url',
|
||||
);
|
||||
expect(conversationCursorMethod(onlyMethod, 'thread')).toBe('index');
|
||||
expect(
|
||||
codeOf(() => decodeConversationCursor('thread', onlyMethod, 'e1')),
|
||||
).toBe('VAL_001');
|
||||
expect(conversationCursorMethod(onlyMethod, 'list')).toBe('engine');
|
||||
});
|
||||
|
||||
it('모양이 틀린 커서는 VAL_001 — 시각이 날짜가 아님 · 이상한 키 · 너무 긴 값', () => {
|
||||
const real = JSON.parse(
|
||||
Buffer.from(
|
||||
encodeConversationCursor('list', position, 'inbox'),
|
||||
'base64url',
|
||||
).toString('utf8'),
|
||||
) as Record<string, unknown>;
|
||||
const forge = (patch: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify({ ...real, ...patch })).toString('base64url');
|
||||
for (const cursor of [
|
||||
forge({ t: 9e15 }),
|
||||
forge({ t: -1 }),
|
||||
forge({ k: '../x' }),
|
||||
forge({ k: 'k'.repeat(65) }),
|
||||
forge({ v: 2 }),
|
||||
'not-json',
|
||||
'a'.repeat(600),
|
||||
]) {
|
||||
expect(
|
||||
codeOf(() => decodeConversationCursor('list', cursor, 'inbox')),
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
import { SEARCH_ID_COLUMN_LENGTH } from './mail-search.constants';
|
||||
|
||||
/**
|
||||
* 색인으로 본 대화의 커서 (14단계 설계 C3 · C4 — 클라이언트에게는 불투명 문자열).
|
||||
* - 같은 대화(`m: 'ti'`): `{ v: 1, m, t: 받은 시각(ms), k: 메일 id, s }` — 그 줄 다음(오래된 차례 — 받은 시각 · id 가 더 큰 쪽)부터
|
||||
* - 묶은 목록(`m: 'li'`): `{ v: 1, m, t: 그 메일함 안 마지막 받은 시각(ms), k: 대화 키, s }` — 그 대화 다음(최신 차례 — 더 작은 쪽)부터
|
||||
* 방식(m)이 커서에 실려 쪽을 넘기는 중에 색인이 준비되거나 다시 채우기에 들어가도 처음 방식대로 이어 간다 — 엔진 방식의 커서(jmap-cursor.ts — `m` 이 없다)는 엔진으로.
|
||||
* `m` 이 그 종류의 방식이면 색인 커서로 보고 나머지 모양은 서비스 입구에서 decode 로 본다(엔진 · DB 에 묻기 전에 VAL_001 — 3차 리뷰 L1).
|
||||
* 범위 지문(s)에는 그 메일 id(같은 대화) · 메일함 id(묶은 목록)가 든다 — 다른 대화 · 메일함의 커서를 섞어 쓰면 VAL_001
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 512;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** 대화 키 · 메일 id 의 최대 길이 — 표의 열(thread_key · email_id) */
|
||||
const MAX_KEY_LENGTH = SEARCH_ID_COLUMN_LENGTH;
|
||||
|
||||
/** 커서의 종류 */
|
||||
export type ConversationCursorKind = 'thread' | 'list';
|
||||
|
||||
const METHOD: Readonly<Record<ConversationCursorKind, string>> = {
|
||||
thread: 'ti',
|
||||
list: 'li',
|
||||
};
|
||||
|
||||
/** 이어 갈 자리 — 받은 시각과 그 줄(같은 대화는 메일 id, 묶은 목록은 대화 키) */
|
||||
export interface ConversationPosition {
|
||||
readonly at: Date;
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
m: string;
|
||||
t: number;
|
||||
k: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
function scopeHash(kind: ConversationCursorKind, scope: string): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([kind, scope]))
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
function invalidCursor(reason: string): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-cursor:${reason}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 방식 — `m` 이 그 종류의 색인 방식이면 'index'(판 · 시각 · 키 · 지문은 decodeConversationCursor 가 본다 — 틀리면 VAL_001),
|
||||
* 아니면 'engine'(엔진 쪽 커서 — 모양이 틀리면 엔진 쪽 decode 가 VAL_001)
|
||||
*/
|
||||
export function conversationCursorMethod(
|
||||
cursor: string,
|
||||
kind: ConversationCursorKind,
|
||||
): 'index' | 'engine' {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return 'engine';
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
) as unknown;
|
||||
return typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(parsed as Partial<EncodedCursor>).m === METHOD[kind]
|
||||
? 'index'
|
||||
: 'engine';
|
||||
} catch {
|
||||
return 'engine';
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeConversationCursor(
|
||||
kind: ConversationCursorKind,
|
||||
position: ConversationPosition,
|
||||
scope: string,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
m: METHOD[kind],
|
||||
t: position.at.getTime(),
|
||||
k: position.key,
|
||||
s: scopeHash(kind, scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 VAL_001 */
|
||||
export function decodeConversationCursor(
|
||||
kind: ConversationCursorKind,
|
||||
cursor: string,
|
||||
scope: string,
|
||||
): ConversationPosition {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) throw invalidCursor('too-long');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw invalidCursor('not-json');
|
||||
}
|
||||
const { v, m, t, k, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
const valid =
|
||||
v === CURSOR_VERSION &&
|
||||
m === METHOD[kind] &&
|
||||
typeof t === 'number' &&
|
||||
Number.isSafeInteger(t) &&
|
||||
t >= 0 &&
|
||||
Number.isFinite(new Date(t).getTime()) &&
|
||||
typeof k === 'string' &&
|
||||
k.length <= MAX_KEY_LENGTH &&
|
||||
MAIL_ID_PATTERN.test(k) &&
|
||||
typeof s === 'string';
|
||||
if (!valid) throw invalidCursor('shape');
|
||||
if (s !== scopeHash(kind, scope)) throw invalidCursor('other-list');
|
||||
return { at: new Date(t), key: k };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
conversationDetailsSql,
|
||||
conversationPageSql,
|
||||
conversationParticipantsSql,
|
||||
mailboxConversationsSql,
|
||||
} from './conversation-sql';
|
||||
|
||||
const USER = 'ara.kim@arasystem.test';
|
||||
const EXCLUSIONS = {
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
const flat = (sql: string) => sql.replace(/\s+/g, ' ');
|
||||
|
||||
describe('대화의 SQL (conversation-sql — 14단계 C3 · C4)', () => {
|
||||
it('같은 대화 한 쪽 — 그 메일의 대화 키를 같은 문장에서 읽고(리뷰 L2), 오래된 차례 · 빼는 메일함에만 든 메일 · 내부 발송 초안은 빼되 지금 메일은 넣고, 이어 갈 자리 다음부터', () => {
|
||||
const after = { at: new Date('2026-11-01T00:00:00Z'), key: 'm3' };
|
||||
const query = conversationPageSql(USER, 'm5', EXCLUSIONS, after, 101);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "page"."email_id", "page"."received_at", "page"."total" ' +
|
||||
'FROM (SELECT "thread_key" FROM "mail_search_messages" WHERE "user_email" = $1 AND "email_id" = $2) AS "current" ' +
|
||||
'LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ' +
|
||||
'AND ((NOT ("mailbox_ids" <@ $3::text[]) AND NOT ("keywords" && $4::text[])) OR "email_id" = $2) ' +
|
||||
'AND ("received_at", "email_id") > ($5::timestamptz, $6) ' +
|
||||
'ORDER BY "received_at" ASC, "email_id" ASC LIMIT $7) AS "page" ON TRUE ' +
|
||||
'ORDER BY "page"."received_at" ASC, "page"."email_id" ASC',
|
||||
);
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
'm5',
|
||||
['b', 'c'],
|
||||
['$ara-sending'],
|
||||
after.at,
|
||||
'm3',
|
||||
101,
|
||||
]);
|
||||
|
||||
// 빼는 키워드만 있어도 지금 메일은 넣는다
|
||||
const keywordOnly = conversationPageSql(
|
||||
USER,
|
||||
'm5',
|
||||
{ excludedMailboxIds: [], excludedKeywords: ['$ara-sending'] },
|
||||
null,
|
||||
10,
|
||||
);
|
||||
expect(flat(keywordOnly.sql)).toContain(
|
||||
'AND ((NOT ("keywords" && $3::text[])) OR "email_id" = $2) ORDER BY',
|
||||
);
|
||||
});
|
||||
|
||||
it('묶은 목록 한 쪽 — 그 직원의 줄(주소 btree)에서 메일함은 GIN 을 못 쓰는 꼴(= ANY)로 거른 뒤 대화 키로 묶는다', () => {
|
||||
const query = mailboxConversationsSql(USER, 'a', EXCLUSIONS, null, 21);
|
||||
|
||||
expect(flat(query.sql)).toBe(
|
||||
'SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND $2 = ANY("mailbox_ids") AND NOT ("keywords" && $3::text[]) ' +
|
||||
'GROUP BY "thread_key" ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT $4',
|
||||
);
|
||||
expect(query.params).toEqual([USER, 'a', ['$ara-sending'], 21]);
|
||||
expect(query.sql).not.toContain('"mailbox_ids" @>');
|
||||
|
||||
const next = mailboxConversationsSql(
|
||||
USER,
|
||||
'a',
|
||||
EXCLUSIONS,
|
||||
{ at: new Date('2026-11-01T00:00:00Z'), key: 'k9' },
|
||||
21,
|
||||
);
|
||||
expect(flat(next.sql)).toContain(
|
||||
'GROUP BY "thread_key" HAVING (max("received_at"), "thread_key") < ($4::timestamptz, $5) ORDER BY',
|
||||
);
|
||||
});
|
||||
|
||||
it('묶은 목록의 자세히 — 그 쪽의 대화 키만, 통 수는 대화 전체(빼는 메일함에만 든 메일 빼고 — 그 메일함 것은 넣고), 그 메일함 안 id 는 최신부터 상한까지(리뷰 L4) · 보낸 사람 글은 끌어오지 않는다', () => {
|
||||
const query = conversationDetailsSql(
|
||||
USER,
|
||||
'a',
|
||||
['k1', 'k2'],
|
||||
EXCLUSIONS,
|
||||
500,
|
||||
);
|
||||
const sql = flat(query.sql);
|
||||
|
||||
expect(sql).toContain(
|
||||
'WHERE "user_email" = $1 AND "thread_key" = ANY($2::text[]) AND NOT ("keywords" && $8::text[]) GROUP BY "thread_key"',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'count(*) FILTER (WHERE (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")))::int AS "message_count"',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'(array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE $3 = ANY("mailbox_ids")))[1:$7::int] AS "mailbox_message_ids"',
|
||||
);
|
||||
expect(sql).not.toContain('from_text');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['k1', 'k2'],
|
||||
'a',
|
||||
['b', 'c'],
|
||||
'$seen',
|
||||
'$flagged',
|
||||
500,
|
||||
['$ara-sending'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('묶은 목록의 참여자 — 첫 보낸 사람 칸에서 주소(소문자)마다 처음 보낸 메일 하나, 그 차례로 상한까지 · 대화마다 (리뷰 L3 · L4)', () => {
|
||||
const query = conversationParticipantsSql(
|
||||
USER,
|
||||
'a',
|
||||
['k1', 'k2'],
|
||||
EXCLUSIONS,
|
||||
10,
|
||||
);
|
||||
const sql = flat(query.sql);
|
||||
|
||||
expect(sql).toContain('FROM unnest($2::text[]) AS "keys"("thread_key")');
|
||||
expect(sql).toContain(
|
||||
'SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM "mail_search_messages" ' +
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL ' +
|
||||
'AND (NOT ("mailbox_ids" <@ $4::text[]) OR $3 = ANY("mailbox_ids")) AND NOT ("keywords" && $5::text[]) ' +
|
||||
'ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT $6) AS "first"',
|
||||
);
|
||||
expect(sql).not.toContain('from_text');
|
||||
expect(query.params).toEqual([
|
||||
USER,
|
||||
['k1', 'k2'],
|
||||
'a',
|
||||
['b', 'c'],
|
||||
['$ara-sending'],
|
||||
10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('빼는 것이 없으면 조건도 없다 (지금 메일의 예외도 없다)', () => {
|
||||
const none = { excludedMailboxIds: [], excludedKeywords: [] };
|
||||
const page = conversationPageSql(USER, 'm5', none, null, 10);
|
||||
expect(flat(page.sql)).toContain(
|
||||
'WHERE "user_email" = $1 AND "thread_key" = "current"."thread_key" ORDER BY',
|
||||
);
|
||||
expect(page.params).toEqual([USER, 'm5', 10]);
|
||||
|
||||
const details = conversationDetailsSql(USER, 'a', ['k1'], none, 500);
|
||||
expect(flat(details.sql)).toContain(
|
||||
'count(*) FILTER (WHERE TRUE)::int AS "message_count"',
|
||||
);
|
||||
expect(flat(details.sql)).toContain('AND TRUE GROUP BY "thread_key"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
FLAGGED_KEYWORD,
|
||||
SEARCH_MESSAGES_TABLE,
|
||||
SEEN_KEYWORD,
|
||||
} from './mail-search.constants';
|
||||
import type { ConversationPosition } from './conversation-cursor';
|
||||
import type { SqlQuery } from './search-sql';
|
||||
|
||||
/**
|
||||
* 대화의 SQL (순수 함수 — 14단계 설계 C3 · C4). 값은 모두 매개변수다.
|
||||
* 언제나 그 직원 주소(`"user_email" = $1` — btree)로 먼저 좁힌다. 메일함 조건은 `$n = ANY("mailbox_ids")` 로 적는다 —
|
||||
* `mailbox_ids` GIN 은 회사 전체 posting 이라(메일함 id 가 계정마다 같은 꼴) 쓰지 않게(리뷰 — 그 직원의 줄에서만 거른다)
|
||||
*/
|
||||
|
||||
const TABLE = `"${SEARCH_MESSAGES_TABLE}"`;
|
||||
|
||||
/** 대화에서 빼는 것 — 빼는 메일함에만 든 메일 · 빼는 키워드(내부 발송 초안) */
|
||||
export interface ConversationExclusions {
|
||||
readonly excludedMailboxIds: readonly string[];
|
||||
readonly excludedKeywords: readonly string[];
|
||||
}
|
||||
|
||||
/** 매개변수를 차례로 쌓는다 */
|
||||
class Params {
|
||||
readonly values: unknown[] = [];
|
||||
|
||||
add(value: unknown): string {
|
||||
this.values.push(value);
|
||||
return `$${this.values.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 빼는 키워드 — 없으면 늘 참 */
|
||||
function keywordCondition(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
): string {
|
||||
return exclusions.excludedKeywords.length > 0
|
||||
? `NOT ("keywords" && ${params.add([...exclusions.excludedKeywords])}::text[])`
|
||||
: 'TRUE';
|
||||
}
|
||||
|
||||
/** 빼는 메일함에만 든 메일이 아니다 — 없으면 늘 참 */
|
||||
function mailboxCondition(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
): string {
|
||||
return exclusions.excludedMailboxIds.length > 0
|
||||
? `NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[])`
|
||||
: 'TRUE';
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 대화 한 쪽 — 지금 보는 메일(`emailId`)의 대화 키를 같은 문장에서 읽어(3차 리뷰 L2 — 키를 읽은 뒤 그 키가 합쳐져 빈 쪽이 나오지 않게 · 왕복 하나)
|
||||
* 그 키의 줄을 오래된 차례(받은 시각 · id)로, `after` 다음부터 `limit` 줄. 빼는 메일함에만 든 메일 · 빼는 키워드의 메일(내부 발송 초안)은 빼되
|
||||
* 지금 보는 메일은 넣는다(엔진 방식 `jmap-conversations.ts` 의 inConversation 과 같다).
|
||||
* - 그 메일이 색인에 없으면 줄이 없다. 있는데 쪽이 비면 `email_id` 가 null 인 줄 하나다(LEFT JOIN LATERAL) — 둘을 가른다
|
||||
* - `total` 은 이 쪽의 조건(이어 갈 자리 빼고)에 맞는 줄 수 — 첫 쪽에서만 쓴다
|
||||
*/
|
||||
export function conversationPageSql(
|
||||
user: string,
|
||||
emailId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
after: ConversationPosition | null,
|
||||
limit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const email$ = params.add(emailId);
|
||||
const where = [
|
||||
`"user_email" = ${user$}`,
|
||||
`"thread_key" = "current"."thread_key"`,
|
||||
];
|
||||
const kept = [
|
||||
mailboxCondition(params, exclusions),
|
||||
keywordCondition(params, exclusions),
|
||||
].filter((condition) => condition !== 'TRUE');
|
||||
if (kept.length > 0) {
|
||||
where.push(`((${kept.join(' AND ')}) OR "email_id" = ${email$})`);
|
||||
}
|
||||
if (after !== null) {
|
||||
where.push(
|
||||
`("received_at", "email_id") > (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sql:
|
||||
`SELECT "page"."email_id", "page"."received_at", "page"."total"` +
|
||||
` FROM (SELECT "thread_key" FROM ${TABLE} WHERE "user_email" = ${user$} AND "email_id" = ${email$}) AS "current"` +
|
||||
` LEFT JOIN LATERAL (SELECT "email_id", "received_at", count(*) OVER ()::int AS "total" FROM ${TABLE}` +
|
||||
` WHERE ${where.join(' AND ')} ORDER BY "received_at" ASC, "email_id" ASC LIMIT ${params.add(limit)}) AS "page" ON TRUE` +
|
||||
` ORDER BY "page"."received_at" ASC, "page"."email_id" ASC`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록 한 쪽 — 그 메일함 안의 대화(대화 키)마다 그 메일함 안 마지막 받은 시각, 최신부터 `limit` 개.
|
||||
* 그 직원의 줄(주소 btree)에서만 메일함 · 키워드로 거른 뒤 대화 키로 묶는다. `after` 면 그 대화 다음부터(받은 시각 · 키가 더 작은 쪽).
|
||||
* `total` 은 묶은 대화 수(이어 갈 자리 다음의 것) — 첫 쪽에서만 쓴다
|
||||
*/
|
||||
export function mailboxConversationsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
after: ConversationPosition | null,
|
||||
limit: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const where = [
|
||||
`"user_email" = ${params.add(user)}`,
|
||||
`${params.add(mailboxId)} = ANY("mailbox_ids")`,
|
||||
keywordCondition(params, exclusions),
|
||||
];
|
||||
const having =
|
||||
after === null
|
||||
? ''
|
||||
: ` HAVING (max("received_at"), "thread_key") < (${params.add(after.at)}::timestamptz, ${params.add(after.key)})`;
|
||||
return {
|
||||
sql: `SELECT "thread_key", max("received_at") AS "latest_at", count(*) OVER ()::int AS "total" FROM ${TABLE} WHERE ${where.join(' AND ')} GROUP BY "thread_key"${having} ORDER BY "latest_at" DESC, "thread_key" DESC LIMIT ${params.add(limit)}`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화마다 자세히 — 그 쪽의 대화 키만(주소 + 대화 키 btree).
|
||||
* - 대화 전체(빼는 메일함에만 든 메일은 빼되 그 메일함의 메일은 넣는다): 통 수
|
||||
* - 그 메일함 안: 메일 id(최신부터 `maxIds` 통까지 — 첫째가 마지막 메일, 3차 리뷰 L4) · 모두 읽음 · 하나라도 중요 · 하나라도 첨부
|
||||
* 참여자는 conversationParticipantsSql 이 따로 읽는다(주소마다 한 번 · 상한까지 — 대화 전체의 보낸 사람 글을 끌어오지 않는다)
|
||||
*/
|
||||
export function conversationDetailsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
threadKeys: readonly string[],
|
||||
exclusions: ConversationExclusions,
|
||||
maxIds: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const keys$ = params.add([...threadKeys]);
|
||||
const mailbox$ = params.add(mailboxId);
|
||||
const inMailbox = `${mailbox$} = ANY("mailbox_ids")`;
|
||||
const kept = keptInConversation(params, exclusions, inMailbox);
|
||||
const seen$ = params.add(SEEN_KEYWORD);
|
||||
const flagged$ = params.add(FLAGGED_KEYWORD);
|
||||
const maxIds$ = params.add(maxIds);
|
||||
const keywords = keywordCondition(params, exclusions);
|
||||
return {
|
||||
sql:
|
||||
`SELECT "thread_key",` +
|
||||
` count(*) FILTER (WHERE ${kept})::int AS "message_count",` +
|
||||
` (array_agg("email_id" ORDER BY "received_at" DESC, "email_id" DESC) FILTER (WHERE ${inMailbox}))[1:${maxIds$}::int] AS "mailbox_message_ids",` +
|
||||
` coalesce(bool_and(${seen$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), true) AS "is_read",` +
|
||||
` coalesce(bool_or(${flagged$} = ANY("keywords")) FILTER (WHERE ${inMailbox}), false) AS "is_flagged",` +
|
||||
` coalesce(bool_or("has_attachment") FILTER (WHERE ${inMailbox}), false) AS "has_attachment"` +
|
||||
` FROM ${TABLE} WHERE "user_email" = ${user$} AND "thread_key" = ANY(${keys$}::text[]) AND ${keywords} GROUP BY "thread_key"`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화마다 참여자 — 첫 보낸 사람 칸(from_email · from_name — 3차 리뷰 L3)에서 주소(소문자)마다 처음 보낸 메일 하나,
|
||||
* 그 차례(받은 시각 · id)로 `max` 명까지(대화마다 LATERAL — 주소 + 대화 키 btree). 대화의 범위는 자세히(통 수)와 같다
|
||||
*/
|
||||
export function conversationParticipantsSql(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
threadKeys: readonly string[],
|
||||
exclusions: ConversationExclusions,
|
||||
max: number,
|
||||
): SqlQuery {
|
||||
const params = new Params();
|
||||
const user$ = params.add(user);
|
||||
const keys$ = params.add([...threadKeys]);
|
||||
const mailbox$ = params.add(mailboxId);
|
||||
const kept = keptInConversation(
|
||||
params,
|
||||
exclusions,
|
||||
`${mailbox$} = ANY("mailbox_ids")`,
|
||||
);
|
||||
const keywords = keywordCondition(params, exclusions);
|
||||
const max$ = params.add(max);
|
||||
return {
|
||||
sql:
|
||||
`SELECT "keys"."thread_key", "first"."from_email", "first"."from_name"` +
|
||||
` FROM unnest(${keys$}::text[]) AS "keys"("thread_key")` +
|
||||
` CROSS JOIN LATERAL (SELECT "senders"."from_email", "senders"."from_name", "senders"."received_at", "senders"."email_id" FROM (` +
|
||||
`SELECT DISTINCT ON (lower("from_email")) "from_email", "from_name", "received_at", "email_id" FROM ${TABLE}` +
|
||||
` WHERE "user_email" = ${user$} AND "thread_key" = "keys"."thread_key" AND "from_email" IS NOT NULL AND ${kept} AND ${keywords}` +
|
||||
` ORDER BY lower("from_email"), "received_at" ASC, "email_id" ASC) AS "senders"` +
|
||||
` ORDER BY "senders"."received_at" ASC, "senders"."email_id" ASC LIMIT ${max$}) AS "first"` +
|
||||
` ORDER BY "keys"."thread_key", "first"."received_at" ASC, "first"."email_id" ASC`,
|
||||
params: params.values,
|
||||
};
|
||||
}
|
||||
|
||||
/** 대화에 드는 줄 — 빼는 메일함에만 든 메일이 아니거나 `orCondition`(그 메일함의 메일) */
|
||||
function keptInConversation(
|
||||
params: Params,
|
||||
exclusions: ConversationExclusions,
|
||||
orCondition: string,
|
||||
): string {
|
||||
return exclusions.excludedMailboxIds.length > 0
|
||||
? `(NOT ("mailbox_ids" <@ ${params.add([...exclusions.excludedMailboxIds])}::text[]) OR ${orCondition})`
|
||||
: 'TRUE';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
firstSender,
|
||||
isIndexableId,
|
||||
referencesOf,
|
||||
storableText,
|
||||
@@ -10,8 +11,10 @@ import type { IndexDocument } from './mail-index-source';
|
||||
import {
|
||||
INDEX_ADDRESSES_MAX,
|
||||
INDEX_BODY_MAX_CHARS,
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
INDEX_REFS_MAX,
|
||||
INDEX_SUBJECT_MAX_CHARS,
|
||||
SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
} from './mail-search.constants';
|
||||
|
||||
function documentOf(overrides: Partial<IndexDocument> = {}): IndexDocument {
|
||||
@@ -125,6 +128,51 @@ describe('toIndexRow (엔진의 메일 한 통 → 색인 한 줄)', () => {
|
||||
expect(row.mailboxIds).toEqual(['inbox']);
|
||||
});
|
||||
|
||||
it('첫 보낸 사람 칸(참여자 — 리뷰 L3) — 주소 · 이름을 따로, 이름 안의 `<주소>` 꼴은 주소가 되지 않는다', () => {
|
||||
// 주소 없이 이름만 `대표 <ceo@…>` — 보낸 사람 글(조건 검색)에는 남지만 참여자 칸은 비운다
|
||||
const nameOnly = toIndexRow(
|
||||
documentOf({ from: [{ name: '대표 <ceo@example.com>', email: null }] }),
|
||||
);
|
||||
expect(nameOnly.fromText).toBe('대표 <ceo@example.com>');
|
||||
expect(nameOnly.fromEmail).toBeNull();
|
||||
expect(nameOnly.fromName).toBeNull();
|
||||
|
||||
// 이름이 `<다른 주소>` 를 품어도 칸의 주소는 엔진이 준 주소다
|
||||
const disguised = toIndexRow(
|
||||
documentOf({
|
||||
from: [
|
||||
{ name: ' 대표 <ceo@example.com> ', email: ' real@example.net ' },
|
||||
{ name: '둘째', email: 'second@example.com' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(disguised.fromEmail).toBe('real@example.net');
|
||||
expect(disguised.fromName).toBe('대표 <ceo@example.com>');
|
||||
|
||||
expect(toIndexRow(documentOf({ from: [] })).fromEmail).toBeNull();
|
||||
});
|
||||
|
||||
it('첫 보낸 사람 — 이름은 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다) · 주소가 열보다 길면 담지 않는다', () => {
|
||||
const long = firstSender([
|
||||
{
|
||||
name: `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀`,
|
||||
email: 'a@example.com',
|
||||
},
|
||||
]);
|
||||
expect([...(long.name ?? '')]).toHaveLength(INDEX_FROM_NAME_MAX_CHARS);
|
||||
expect(long.name?.endsWith('😀')).toBe(true);
|
||||
|
||||
const tooLong = `${'a'.repeat(SEARCH_EMAIL_COLUMN_LENGTH)}@example.com`;
|
||||
expect(firstSender([{ name: '긴 주소', email: tooLong }])).toEqual({
|
||||
email: null,
|
||||
name: null,
|
||||
});
|
||||
expect(firstSender([{ name: ' ', email: 'a@example.com' }])).toEqual({
|
||||
email: 'a@example.com',
|
||||
name: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('받은 시각이 날짜가 아니면 1970-01-01 · 크기는 32비트 정수 안 · Message-ID 가 없으면 null · 긴 대화 id 는 null', () => {
|
||||
const row = toIndexRow(
|
||||
documentOf({
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
INDEX_ATTACHMENT_NAMES_MAX,
|
||||
INDEX_ATTACHMENT_NAME_MAX_CHARS,
|
||||
INDEX_BODY_MAX_CHARS,
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
INDEX_MESSAGE_ID_MAX_LENGTH,
|
||||
INDEX_PREVIEW_MAX_CHARS,
|
||||
INDEX_REFS_MAX,
|
||||
INDEX_SUBJECT_MAX_CHARS,
|
||||
SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
SEARCH_ID_COLUMN_LENGTH,
|
||||
} from './mail-search.constants';
|
||||
|
||||
@@ -22,7 +24,8 @@ import {
|
||||
* - 접기 글(fold_*)은 foldText — 원문과 위치가 같다. 받는 사람 칸은 받는 사람과 참조를 줄바꿈으로 이어 접는다
|
||||
* - 묶음(grams)은 다섯 접기 칸에서 따로 뽑아 합친다(칸 경계를 넘지 않는다, 겹치지 않게)
|
||||
* - 답장 관계(refs): In-Reply-To 다음 References 를 가까운 것(뒤)부터, 겹치지 않게 앞 INDEX_REFS_MAX 개. 너무 긴 id 는 뺀다
|
||||
* - 대화 키는 14단계 1차에서는 그 메일 id 다 (잇기는 3차 — 설계 B4)
|
||||
* - 첫 보낸 사람은 주소 · 이름을 따로도 둔다(from_email · from_name — 묶은 목록의 참여자, 3차 리뷰 L3). 주소가 없으면 null — 이름 안의 `<주소>` 꼴은 주소로 읽지 않는다
|
||||
* - 대화 키는 넣을 때 그 메일 id 다 — 색인에 쓸 때 이어진 메일의 키와 합친다(3차 — 설계 B4, mail-search-index.repository.ts 의 linkThreads)
|
||||
*/
|
||||
|
||||
/** 색인 한 줄의 값 — 표의 칸과 같다 (user_email · indexed_at 은 쓰는 쪽이 붙인다) */
|
||||
@@ -37,6 +40,8 @@ export interface IndexRowValues {
|
||||
readonly subject: string;
|
||||
readonly subjectBase: string;
|
||||
readonly fromText: string;
|
||||
readonly fromEmail: string | null;
|
||||
readonly fromName: string | null;
|
||||
readonly toText: string;
|
||||
readonly ccText: string;
|
||||
readonly attachmentNames: string;
|
||||
@@ -103,6 +108,39 @@ function addressLine(address: IndexAddress): string {
|
||||
return storableText(line, INDEX_ADDRESS_LINE_MAX_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 이름 — 앞뒤 공백을 지운 NFC, 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트(서로게이트 쌍을 가르지 않는다), 비면 null.
|
||||
* 색인의 첫 보낸 사람 칸(firstSender)과 엔진 방식 대화의 참여자(modules/mail/jmap/jmap-conversations.ts)가 같은 자르기를 쓴다
|
||||
*/
|
||||
export function participantName(
|
||||
name: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = storableText(
|
||||
(name ?? '').trim(),
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
).trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 보낸 사람 — 주소(앞뒤 공백을 지운 NFC, SEARCH_EMAIL_COLUMN_LENGTH 를 넘거나 없으면 null)와 이름(participantName).
|
||||
* 목록 요약(jmap.mapper.ts 의 toAddress — `from[0]`)과 같은 사람이다. 주소가 없으면 이름도 두지 않는다(참여자는 주소로 센다)
|
||||
*/
|
||||
export function firstSender(addresses: readonly IndexAddress[]): {
|
||||
readonly email: string | null;
|
||||
readonly name: string | null;
|
||||
} {
|
||||
const first = addresses[0];
|
||||
const email = storableText(
|
||||
(first?.email ?? '').trim(),
|
||||
SEARCH_EMAIL_COLUMN_LENGTH + 1,
|
||||
);
|
||||
if (email === '' || email.length > SEARCH_EMAIL_COLUMN_LENGTH) {
|
||||
return { email: null, name: null };
|
||||
}
|
||||
return { email, name: participantName(first?.name) };
|
||||
}
|
||||
|
||||
function addressText(addresses: readonly IndexAddress[]): string {
|
||||
return addresses
|
||||
.slice(0, INDEX_ADDRESSES_MAX)
|
||||
@@ -149,6 +187,7 @@ export function isIndexableId(id: string): boolean {
|
||||
export function toIndexRow(document: IndexDocument): IndexRowValues {
|
||||
const subject = storableText(document.subject, INDEX_SUBJECT_MAX_CHARS);
|
||||
const fromText = addressText(document.from);
|
||||
const sender = firstSender(document.from);
|
||||
const toText = addressText(document.to);
|
||||
const ccText = addressText(document.cc);
|
||||
const attachmentNames = document.attachmentNames
|
||||
@@ -187,6 +226,8 @@ export function toIndexRow(document: IndexDocument): IndexRowValues {
|
||||
subject,
|
||||
subjectBase: subjectBase(subject),
|
||||
fromText,
|
||||
fromEmail: sender.email,
|
||||
fromName: sender.name,
|
||||
toText,
|
||||
ccText,
|
||||
attachmentNames,
|
||||
@@ -199,6 +240,7 @@ export function toIndexRow(document: IndexDocument): IndexRowValues {
|
||||
foldAttachment,
|
||||
foldBody,
|
||||
grams: [...grams],
|
||||
// 처음 키는 그 메일 id — 색인에 쓸 때 이어진 메일의 키와 합친다(mail-search-index.repository.ts 의 linkThreads)
|
||||
threadKey: document.id,
|
||||
engineThreadId: isIndexableId(threadId) ? threadId : null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
encodeConversationCursor,
|
||||
type ConversationPosition,
|
||||
} from './conversation-cursor';
|
||||
import {
|
||||
conversationDetailsSql,
|
||||
conversationPageSql,
|
||||
conversationParticipantsSql,
|
||||
mailboxConversationsSql,
|
||||
type ConversationExclusions,
|
||||
} from './conversation-sql';
|
||||
import {
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from './mail-search.constants';
|
||||
import type { ConversationParticipant } from './mail-search.types';
|
||||
|
||||
/** 같은 대화 한 쪽 — 메일 id 만(요약은 부르는 쪽이 엔진에서 읽는다) */
|
||||
export interface IndexConversationPage {
|
||||
readonly ids: readonly string[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
/** 첫 쪽에서만 — 다음 쪽은 null */
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 묶은 목록의 대화 하나 (색인) — 마지막 메일의 요약은 부르는 쪽이 엔진에서 읽는다 */
|
||||
export interface IndexConversation {
|
||||
readonly key: string;
|
||||
/** 그 메일함 안 마지막 메일 */
|
||||
readonly latestId: string;
|
||||
/** 그 메일함 안 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 */
|
||||
readonly mailboxMessageIds: readonly string[];
|
||||
readonly messageCount: number;
|
||||
readonly participants: readonly ConversationParticipant[];
|
||||
readonly isRead: boolean;
|
||||
readonly isFlagged: boolean;
|
||||
readonly hasAttachment: boolean;
|
||||
}
|
||||
|
||||
export interface IndexConversationList {
|
||||
readonly items: readonly IndexConversation[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 쪽을 읽는 자리 — 커서는 서비스 입구에서 읽어(VAL_001 — 엔진 · DB 에 묻기 전) 자리로 넘긴다 (3차 리뷰 L1) */
|
||||
export interface ConversationPageRequest {
|
||||
readonly limit: number;
|
||||
readonly after: ConversationPosition | null;
|
||||
}
|
||||
|
||||
interface PageRow {
|
||||
/** 그 메일은 색인에 있는데 쪽이 비었으면 null (LEFT JOIN LATERAL) */
|
||||
readonly email_id: string | null;
|
||||
readonly received_at: Date | string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
interface ListRow {
|
||||
readonly thread_key: string;
|
||||
readonly latest_at: Date | string;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
readonly thread_key: string;
|
||||
readonly message_count: number;
|
||||
readonly mailbox_message_ids: string[] | null;
|
||||
readonly is_read: boolean;
|
||||
readonly is_flagged: boolean;
|
||||
readonly has_attachment: boolean;
|
||||
}
|
||||
|
||||
interface ParticipantRow {
|
||||
readonly thread_key: string;
|
||||
readonly from_email: string;
|
||||
readonly from_name: string | null;
|
||||
}
|
||||
|
||||
function dateOf(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
/** 표의 직원 주소 — 늘 소문자다 (MailSearchIndexService 와 같은 맞춤) */
|
||||
function normalize(user: string): string {
|
||||
return user.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 색인으로 본 대화 (14단계 설계 C3 · C4) — 대화 키(B4)로 묶는다. 엔진을 모른다(빼는 메일함 · 키워드는 부르는 쪽이 엔진에서 받아 준다).
|
||||
* - 같은 대화: 그 메일의 키와 그 키의 줄을 한 문장으로(3차 리뷰 L2), 오래된 차례 · 커서(conversation-cursor.ts 의 thread)
|
||||
* - 묶은 목록: 그 메일함 안의 줄을 대화 키로 묶어 마지막 받은 시각 최신부터 · 커서(list), 그 쪽의 대화만 자세히(통 수 · 그 메일함 안 id(최신부터
|
||||
* CONVERSATION_MESSAGE_IDS_MAX 통까지) · 읽음 …)와 참여자(첫 보낸 사람 칸 — 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지)
|
||||
* 모든 쿼리는 그 직원 주소로 먼저 좁힌다 — 메일함 GIN(회사 전체 posting)을 쓰지 않는다(conversation-sql.ts)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailConversationQueryService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* 같은 대화 한 쪽 — 오래된 차례. 지금 보는 메일(`emailId`)은 빼는 메일함에 있어도 넣는다.
|
||||
* 그 메일이 색인에 없으면 null (부르는 쪽이 첫 쪽이면 엔진으로, 이어 가는 쪽이면 RES_001)
|
||||
*/
|
||||
async conversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
page: ConversationPageRequest,
|
||||
): Promise<IndexConversationPage | null> {
|
||||
const query = conversationPageSql(
|
||||
normalize(user),
|
||||
emailId,
|
||||
exclusions,
|
||||
page.after,
|
||||
page.limit + 1,
|
||||
);
|
||||
const rows = await this.dataSource.query<PageRow[]>(
|
||||
query.sql,
|
||||
query.params,
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const found = rows.flatMap((row) =>
|
||||
row.email_id !== null && row.received_at !== null
|
||||
? [
|
||||
{
|
||||
id: row.email_id,
|
||||
at: dateOf(row.received_at),
|
||||
total: row.total,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const shown = found.slice(0, page.limit);
|
||||
const hasNext = found.length > page.limit;
|
||||
const last = shown[shown.length - 1];
|
||||
return {
|
||||
ids: shown.map((row) => row.id),
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeConversationCursor(
|
||||
'thread',
|
||||
{ at: last.at, key: last.id },
|
||||
emailId,
|
||||
)
|
||||
: null,
|
||||
total: page.after === null ? Number(found[0]?.total ?? 0) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 묶은 목록 한 쪽 — 그 메일함 안 마지막 받은 시각 최신부터 */
|
||||
async mailboxConversations(
|
||||
user: string,
|
||||
mailboxId: string,
|
||||
exclusions: ConversationExclusions,
|
||||
page: ConversationPageRequest,
|
||||
): Promise<IndexConversationList> {
|
||||
const key = normalize(user);
|
||||
const { after } = page;
|
||||
// 쪽 · 자세히 · 참여자는 같은 스냅숏에서 — 그사이 온 메일로 대화 키가 합쳐져도 한 쪽 안에서는 어긋나지 않게
|
||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||
const listQuery = mailboxConversationsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
exclusions,
|
||||
after,
|
||||
page.limit + 1,
|
||||
);
|
||||
const rows = await manager.query<ListRow[]>(
|
||||
listQuery.sql,
|
||||
listQuery.params,
|
||||
);
|
||||
const shown = rows.slice(0, page.limit);
|
||||
const hasNext = rows.length > page.limit;
|
||||
const keys = shown.map((row) => row.thread_key);
|
||||
let detailRows: DetailRow[] = [];
|
||||
let participantRows: ParticipantRow[] = [];
|
||||
if (keys.length > 0) {
|
||||
const detailQuery = conversationDetailsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
keys,
|
||||
exclusions,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
);
|
||||
detailRows = await manager.query<DetailRow[]>(
|
||||
detailQuery.sql,
|
||||
detailQuery.params,
|
||||
);
|
||||
const participantQuery = conversationParticipantsSql(
|
||||
key,
|
||||
mailboxId,
|
||||
keys,
|
||||
exclusions,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
);
|
||||
participantRows = await manager.query<ParticipantRow[]>(
|
||||
participantQuery.sql,
|
||||
participantQuery.params,
|
||||
);
|
||||
}
|
||||
const details = new Map(detailRows.map((row) => [row.thread_key, row]));
|
||||
const participants = new Map<string, ConversationParticipant[]>();
|
||||
for (const row of participantRows) {
|
||||
const list = participants.get(row.thread_key) ?? [];
|
||||
list.push({ name: row.from_name, email: row.from_email });
|
||||
participants.set(row.thread_key, list);
|
||||
}
|
||||
const items = shown.flatMap((row): IndexConversation[] => {
|
||||
const detail = details.get(row.thread_key);
|
||||
const ids = detail?.mailbox_message_ids ?? [];
|
||||
if (!detail || ids.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
key: row.thread_key,
|
||||
latestId: ids[0],
|
||||
mailboxMessageIds: ids,
|
||||
messageCount: Math.max(Number(detail.message_count), 1),
|
||||
participants: participants.get(row.thread_key) ?? [],
|
||||
isRead: detail.is_read,
|
||||
isFlagged: detail.is_flagged,
|
||||
hasAttachment: detail.has_attachment,
|
||||
},
|
||||
];
|
||||
});
|
||||
const last = shown[shown.length - 1];
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeConversationCursor(
|
||||
'list',
|
||||
{ at: dateOf(last.latest_at), key: last.thread_key },
|
||||
mailboxId,
|
||||
)
|
||||
: null,
|
||||
total: after === null ? Number(rows[0]?.total ?? 0) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SEARCH_MESSAGES_TABLE,
|
||||
SEARCH_STATES_TABLE,
|
||||
} from './mail-search.constants';
|
||||
import { mergeThreadKeys } from './thread-keys';
|
||||
import type {
|
||||
SearchIndexStatus,
|
||||
SearchPausedReason,
|
||||
@@ -48,7 +49,10 @@ export interface SearchIndexWriteResult {
|
||||
readonly updatedIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id) 밖의 모두 */
|
||||
/**
|
||||
* 넣기에서 바꿀 칸 (열 이름) — 키(user_email · email_id)와 대화 키(thread_key) 밖의 모두.
|
||||
* 대화 키는 이미 있는 줄이면 그대로 둔다 — 합친 대화를 다시 넣기(겹친 채우기 등)가 풀지 않게 (새 줄은 그 메일의 id 로 넣고 linkThreads 가 잇는다)
|
||||
*/
|
||||
const UPSERT_COLUMNS = [
|
||||
'mailbox_ids',
|
||||
'keywords',
|
||||
@@ -59,6 +63,8 @@ const UPSERT_COLUMNS = [
|
||||
'subject',
|
||||
'subject_base',
|
||||
'from_text',
|
||||
'from_email',
|
||||
'from_name',
|
||||
'to_text',
|
||||
'cc_text',
|
||||
'attachment_names',
|
||||
@@ -71,7 +77,6 @@ const UPSERT_COLUMNS = [
|
||||
'fold_attachment',
|
||||
'fold_body',
|
||||
'grams',
|
||||
'thread_key',
|
||||
'engine_thread_id',
|
||||
'indexed_at',
|
||||
];
|
||||
@@ -292,7 +297,13 @@ export class MailSearchIndexRepository {
|
||||
[userEmail, deletes],
|
||||
);
|
||||
}
|
||||
await this.upsertRows(manager, userEmail, change.upserts ?? [], now);
|
||||
const upserted = await this.upsertRows(
|
||||
manager,
|
||||
userEmail,
|
||||
change.upserts ?? [],
|
||||
now,
|
||||
);
|
||||
await this.linkThreads(manager, userEmail, upserted);
|
||||
const updatedIds = await this.updateMutable(
|
||||
manager,
|
||||
userEmail,
|
||||
@@ -309,14 +320,14 @@ export class MailSearchIndexRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 넣기(있으면 모든 칸을 바꾸기) — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
|
||||
/** 넣기(있으면 대화 키 밖의 모든 칸을 바꾸기) → 넣은 id — 한 요청에 같은 id 가 둘이면 뒤의 것만 (ON CONFLICT 는 한 문장 안의 같은 키를 받지 않는다) */
|
||||
private async upsertRows(
|
||||
manager: EntityManager,
|
||||
userEmail: string,
|
||||
rows: readonly IndexRowValues[],
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
): Promise<string[]> {
|
||||
if (rows.length === 0) return [];
|
||||
const latest = new Map(rows.map((row) => [row.emailId, row]));
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
@@ -331,6 +342,44 @@ export class MailSearchIndexRepository {
|
||||
)
|
||||
.orUpdate(UPSERT_COLUMNS, ['user_email', 'email_id'])
|
||||
.execute();
|
||||
return [...latest.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 키 잇기 (14단계 설계 B4) — 넣은 메일 M 마다, M 의 답장 관계(refs)가 가리키는 메일과 M 을 가리키는 메일 가운데 말머리를 뗀 제목이 같은 것의
|
||||
* 키를 M 의 키와 하나로(가장 작은 키 — thread-keys.ts). Message-ID 가 없는 메일은 잇지 않는다(혼자). 그 직원의 줄만 본다.
|
||||
* 쿼리 둘: 이어진 키 쌍 읽기(주소 + Message-ID 색인 · refs GIN) → 바꿀 키를 한 문장으로 UPDATE(주소 + 대화 키 색인). 쓰기 트랜잭션 안이다(상태 줄 잠금 — 같은 직원은 차례로)
|
||||
*/
|
||||
private async linkThreads(
|
||||
manager: EntityManager,
|
||||
userEmail: string,
|
||||
ids: readonly string[],
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const links = await manager.query<{ a: string; b: string }[]>(
|
||||
`SELECT "n"."thread_key" AS "a", "o"."thread_key" AS "b"
|
||||
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
|
||||
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
|
||||
ON "o"."user_email" = "n"."user_email" AND "o"."message_id" = ANY("n"."refs") AND "o"."subject_base" = "n"."subject_base"
|
||||
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
|
||||
AND "o"."thread_key" <> "n"."thread_key"
|
||||
UNION
|
||||
SELECT "n"."thread_key", "o"."thread_key"
|
||||
FROM "${SEARCH_MESSAGES_TABLE}" AS "n"
|
||||
JOIN "${SEARCH_MESSAGES_TABLE}" AS "o"
|
||||
ON "o"."user_email" = "n"."user_email" AND "o"."refs" @> ARRAY["n"."message_id"]::text[] AND "o"."subject_base" = "n"."subject_base"
|
||||
WHERE "n"."user_email" = $1 AND "n"."email_id" = ANY($2::text[]) AND "n"."message_id" IS NOT NULL
|
||||
AND "o"."message_id" IS NOT NULL AND "o"."thread_key" <> "n"."thread_key"`,
|
||||
[userEmail, [...ids]],
|
||||
);
|
||||
const renames = mergeThreadKeys(links.map((link) => [link.a, link.b]));
|
||||
if (renames.size === 0) return;
|
||||
await manager.query(
|
||||
`UPDATE "${SEARCH_MESSAGES_TABLE}" AS "message" SET "thread_key" = "rename"."to"
|
||||
FROM unnest($2::text[], $3::text[]) AS "rename"("from", "to")
|
||||
WHERE "message"."user_email" = $1 AND "message"."thread_key" = "rename"."from"`,
|
||||
[userEmail, [...renames.keys()], [...renames.values()]],
|
||||
);
|
||||
}
|
||||
|
||||
/** 메일함 · 키워드만 바꾼다 (한 문장) → 바꾼 id */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import {
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
SEARCH_ID_COLUMN_LENGTH,
|
||||
SEARCH_MESSAGES_TABLE,
|
||||
@@ -78,10 +79,31 @@ export class MailSearchMessageEntity {
|
||||
@Column({ name: 'subject_base', type: 'text' })
|
||||
subjectBase!: string;
|
||||
|
||||
/** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) */
|
||||
/** "이름 <주소>" 를 줄바꿈으로 이은 원문(NFC) — 보낸 사람 조건 검색(fold_from)의 바탕 */
|
||||
@Column({ name: 'from_text', type: 'text' })
|
||||
fromText!: string;
|
||||
|
||||
/**
|
||||
* 첫 보낸 사람의 주소 (14단계 3차 리뷰 L3 — 묶은 목록의 참여자) — 엔진이 준 주소 그대로(앞뒤 공백만 지운 NFC). 주소가 없거나
|
||||
* SEARCH_EMAIL_COLUMN_LENGTH 를 넘으면 null — 이름에 든 `<주소>` 꼴을 주소로 읽지 않는다 (참여자는 이 칸만 본다)
|
||||
*/
|
||||
@Column({
|
||||
name: 'from_email',
|
||||
type: 'varchar',
|
||||
length: SEARCH_EMAIL_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
fromEmail!: string | null;
|
||||
|
||||
/** 첫 보낸 사람의 이름 — 앞 INDEX_FROM_NAME_MAX_CHARS 코드 포인트, 없으면 null */
|
||||
@Column({
|
||||
name: 'from_name',
|
||||
type: 'varchar',
|
||||
length: INDEX_FROM_NAME_MAX_CHARS,
|
||||
nullable: true,
|
||||
})
|
||||
fromName!: string | null;
|
||||
|
||||
@Column({ name: 'to_text', type: 'text' })
|
||||
toText!: string;
|
||||
|
||||
@@ -124,7 +146,10 @@ export class MailSearchMessageEntity {
|
||||
@Column({ type: 'text', array: true })
|
||||
grams!: string[];
|
||||
|
||||
/** 우리 대화 키 — 14단계 1차에서는 그 메일 id (잇기는 3차) */
|
||||
/**
|
||||
* 우리 대화 키 (14단계 3차 — 설계 B4) — 넣을 때는 그 메일 id, 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면 대화의 가장 작은 키로 합친다
|
||||
* (mail-search-index.repository.ts 의 linkThreads). 다시 넣어도 이미 있는 줄의 키는 그대로다
|
||||
*/
|
||||
@Column({
|
||||
name: 'thread_key',
|
||||
type: 'varchar',
|
||||
@@ -132,7 +157,7 @@ export class MailSearchMessageEntity {
|
||||
})
|
||||
threadKey!: string;
|
||||
|
||||
/** 엔진의 threadId — 색인을 다 만들기 전 대화 · 묶기의 대신 */
|
||||
/** 엔진의 threadId (참고용 — 색인을 다 만들기 전의 대화 · 묶기는 엔진에 바로 묻는다) */
|
||||
@Column({
|
||||
name: 'engine_thread_id',
|
||||
type: 'varchar',
|
||||
|
||||
@@ -25,6 +25,11 @@ export const INDEX_SUBJECT_MAX_CHARS = 1_000;
|
||||
export const INDEX_ADDRESSES_MAX = 200;
|
||||
/** 주소 한 줄("이름 <주소>")의 길이 (코드 포인트) */
|
||||
export const INDEX_ADDRESS_LINE_MAX_CHARS = 400;
|
||||
/**
|
||||
* 첫 보낸 사람의 이름을 담는 길이 (코드 포인트 — 열 `from_name` 의 varchar 길이와 같다). 묶은 목록의 참여자 이름이다 (14단계 3차 리뷰 L3).
|
||||
* 주소(`from_email`)는 SEARCH_EMAIL_COLUMN_LENGTH 까지 — 넘으면 담지 않는다
|
||||
*/
|
||||
export const INDEX_FROM_NAME_MAX_CHARS = 100;
|
||||
/** 첨부 이름을 담는 수 · 이름 하나의 길이 (코드 포인트) */
|
||||
export const INDEX_ATTACHMENT_NAMES_MAX = 100;
|
||||
export const INDEX_ATTACHMENT_NAME_MAX_CHARS = 255;
|
||||
@@ -110,8 +115,10 @@ export const SEARCH_CRON_TIME_ZONE = 'Asia/Seoul';
|
||||
* 색인 판 — 접기(foldText) · 글자 두 개 묶음(gramsOf) · 색인 줄의 모양을 바꾸면 올린다 (상태 줄의 index_format).
|
||||
* 판이 다른 직원은 building 으로 돌아가 밤에 다시 채운다(옛 줄은 다시 채우며 덮고, 마지막 대조가 남은 것을 지운다) — 그동안 검색은 엔진 방식이다.
|
||||
* 1: 14단계 1차 · 2: 공백류를 띄어쓰기로 접는다(14단계 2차 — 구절이 줄바꿈으로 갈린 글에도 맞게)
|
||||
* · 3: 대화 키를 잇는다(14단계 3차 — 답장 관계 + 말머리를 뗀 제목. 판 2 까지의 줄은 키가 메일마다 따로다)
|
||||
* · 4: 첫 보낸 사람의 주소 · 이름 칸(from_email · from_name — 3차 리뷰 L3, 마이그레이션 AddMailSearchSender. 판 3 까지의 줄은 칸이 비었다)
|
||||
*/
|
||||
export const INDEX_FORMAT = 2;
|
||||
export const INDEX_FORMAT = 4;
|
||||
|
||||
/**
|
||||
* 찾기 (14단계 2차 — 설계 G · 2026-11-05 다시 잼). 줄의 글을 푸는 일(TOAST — 묶음 배열 0.1ms · 접은 본문 확인 0.15~0.3ms)이 비싸 그 수를 묶는다:
|
||||
@@ -139,3 +146,31 @@ export const SINGLE_CHAR_TERMS_SKIP_BODY = true;
|
||||
export const SNIPPET_LEAD_CHARS = 20;
|
||||
export const SNIPPET_TOTAL_CHARS = 80;
|
||||
export const SNIPPET_BOUNDARY_SLACK = 10;
|
||||
|
||||
/**
|
||||
* 대화 (14단계 3차 — 설계 C3 · C4). 같은 대화 칸의 한 쪽(기본이자 최대 — 화면이 한 번에 보인다) · 묶은 목록 줄의 참여자 수 상한(보낸 사람 — 받은 차례로 앞에서부터)
|
||||
* · 묶은 목록 줄의 messageIds 상한(그 메일함 안 — 최신부터, 3차 리뷰 L4. 넘는 오래된 메일은 줄의 여러 통 처리에 들지 않는다)
|
||||
*/
|
||||
export const CONVERSATION_PAGE_LIMIT = 100;
|
||||
export const CONVERSATION_PARTICIPANTS_MAX = 10;
|
||||
export const CONVERSATION_MESSAGE_IDS_MAX = 500;
|
||||
|
||||
/**
|
||||
* 엔진 방식 대화(색인 전 · 첫 쪽 DB 장애의 대신 — modules/mail/jmap/jmap-conversations.ts)의 상한 (3차 리뷰 H1) — GitHub · Jira 알림처럼
|
||||
* 수천 통인 스레드에서도 한 요청이 엔진을 크게 쓰지 않게.
|
||||
* - 거르기(휴지통 · 스팸메일함에만 든 메일 · 내부 초안)는 값싼 속성만 읽고, 요약 속성은 내보낼 메일(같은 대화 — 한 쪽 · 묶은 목록 — 줄마다 마지막 메일)만 읽는다
|
||||
* - 같은 대화: 한 요청이 훑는 메일 CONVERSATION_ENGINE_SCAN_MAX 통까지 — 스레드가 그 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 필요한 만큼만(total null)
|
||||
* - 묶은 목록: 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통 · 한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통까지(작은 스레드부터 다 주고 남는 몫을 나눈다)
|
||||
* - 한 요청의 시간 CONVERSATION_ENGINE_BUDGET_MS (메일함 처리의 BulkRun 과 같은 결) — 훑는 호출은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS"
|
||||
* 중 짧은 쪽(마지막 요약 읽기 몫을 늘 남긴다 — 3차 재확인 N1), 마지막 읽기만 남은 시간을 다 쓴다. 남은 시간이 그 몫보다 적으면 더 훑지 않고,
|
||||
* 훑던 호출이 시간 때문에 끊기면 읽은 데까지로 답한다(같은 대화: 다 세야 하는 작은 스레드 · 아무것도 못 읽음은 503 MAIL_001 / 묶은 목록: 어림 줄)
|
||||
*/
|
||||
export const CONVERSATION_ENGINE_SCAN_MAX = 1_000;
|
||||
export const CONVERSATION_ENGINE_THREAD_SCAN_MAX = 500;
|
||||
export const CONVERSATION_ENGINE_LIST_SCAN_MAX = 2_000;
|
||||
export const CONVERSATION_ENGINE_BUDGET_MS = 15_000;
|
||||
export const CONVERSATION_ENGINE_RESERVE_MS = 3_000;
|
||||
|
||||
/** 색인의 키워드(엔진이 준 그대로 — JMAP 표준) 가운데 묶은 목록이 보는 것 — 읽음 · 중요 표시 */
|
||||
export const SEEN_KEYWORD = '$seen';
|
||||
export const FLAGGED_KEYWORD = '$flagged';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { JmapIndexSource } from '@/modules/mail/jmap/jmap-index-source';
|
||||
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
|
||||
import { MailConversationQueryService } from './mail-conversation-query.service';
|
||||
import { MAIL_INDEX_SOURCE } from './mail-index-source';
|
||||
import { MailSearchBuildService } from './mail-search-build.service';
|
||||
import { MailSearchIndexRepository } from './mail-search-index.repository';
|
||||
@@ -36,11 +37,13 @@ import { SearchIndexController } from './search-index.controller';
|
||||
MailSearchBuildService,
|
||||
MailSearchIndexService,
|
||||
MailSearchQueryService,
|
||||
MailConversationQueryService,
|
||||
],
|
||||
exports: [
|
||||
MailSearchIndexService,
|
||||
MailSearchSyncService,
|
||||
MailSearchQueryService,
|
||||
MailConversationQueryService,
|
||||
],
|
||||
})
|
||||
export class MailSearchModule {}
|
||||
|
||||
@@ -68,3 +68,12 @@ export interface MessageDetailHighlight {
|
||||
readonly attachments: Record<string, SearchRange[]>;
|
||||
readonly textBody: SearchRange[];
|
||||
}
|
||||
|
||||
/** 대화의 참여자 — 보낸 사람 (메일 모듈의 MailAddress 와 같은 모양) */
|
||||
export interface ConversationParticipant {
|
||||
readonly name: string | null;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목 — 14단계 B4) · 엔진의 스레드(색인을 다 만들기 전) */
|
||||
export type ConversationBasis = 'index' | 'engine';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { mergeThreadKeys } from './thread-keys';
|
||||
|
||||
describe('대화 키 합치기 (mergeThreadKeys — 14단계 설계 B4)', () => {
|
||||
it('이어진 두 키는 작은 키로 — 바뀌는 쪽만 돌려준다', () => {
|
||||
expect([...mergeThreadKeys([['m9', 'm2']])]).toEqual([['m9', 'm2']]);
|
||||
expect([...mergeThreadKeys([['m2', 'm9']])]).toEqual([['m9', 'm2']]);
|
||||
});
|
||||
|
||||
it('잇닿은 쌍은 하나로 — 대화 전체가 가장 작은 키 (늦게 온 원본이 두 답장을 잇는 꼴)', () => {
|
||||
const renames = mergeThreadKeys([
|
||||
['reply-b', 'orig-z'],
|
||||
['reply-a', 'orig-z'],
|
||||
['reply-c', 'reply-b'],
|
||||
]);
|
||||
// 키는 글자 차례로 견준다 — 'orig-z' < 'reply-a'
|
||||
expect(Object.fromEntries(renames)).toEqual({
|
||||
'reply-a': 'orig-z',
|
||||
'reply-b': 'orig-z',
|
||||
'reply-c': 'orig-z',
|
||||
});
|
||||
});
|
||||
|
||||
it('따로인 대화는 따로 — 쌍의 차례와 상관없이 같은 결과', () => {
|
||||
const links: [string, string][] = [
|
||||
['b', 'a'],
|
||||
['y', 'x'],
|
||||
['c', 'b'],
|
||||
['z', 'y'],
|
||||
];
|
||||
const expected = { b: 'a', c: 'a', y: 'x', z: 'x' };
|
||||
expect(Object.fromEntries(mergeThreadKeys(links))).toEqual(expected);
|
||||
expect(Object.fromEntries(mergeThreadKeys([...links].reverse()))).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 키끼리 · 빈 쌍은 바꿀 것이 없다', () => {
|
||||
expect(mergeThreadKeys([])).toEqual(new Map());
|
||||
expect(mergeThreadKeys([['a', 'a']])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('긴 사슬도 쌍 수에 비례한다 (CPU 시간)', () => {
|
||||
const links = Array.from(
|
||||
{ length: 50_000 },
|
||||
(_, index) =>
|
||||
[
|
||||
`k${String(index + 1).padStart(6, '0')}`,
|
||||
`k${String(index).padStart(6, '0')}`,
|
||||
] as const,
|
||||
);
|
||||
const cpuMs = startCpuTimer();
|
||||
const renames = mergeThreadKeys(links);
|
||||
// 지금 수십 ms — 부하를 견디게 10배 넘게
|
||||
expect(cpuMs()).toBeLessThan(1_000);
|
||||
expect(renames.size).toBe(50_000);
|
||||
expect(new Set(renames.values())).toEqual(new Set(['k000000']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 대화 키 합치기 (순수 함수 — 14단계 설계 B4).
|
||||
* 색인 줄마다 대화 키(thread_key)가 있다 — 처음에는 그 메일의 id 다. 이어진 메일(답장 관계 + 말머리를 뗀 제목이 같음)을 찾으면
|
||||
* 두 키의 줄을 모두 **가장 작은 키**로 바꾼다. 이미 합친 대화는 한 키를 나눠 쓰므로, 새로 들어온 메일과 바로 이어진 메일의 키만 합치면
|
||||
* 대화 전체가 한 키가 된다(늦게 온 원본이 둘로 갈려 있던 답장들을 잇는 것도 같다).
|
||||
*
|
||||
* `links` 는 [키, 키] 쌍들 — 이어진 두 메일의 지금 키. 돌려주는 것은 바꿀 키 → 새 키(가장 작은 키)이다. 바꿀 것이 없으면 빈 Map.
|
||||
* 쌍 수에 거의 비례한다(경로 줄이기 · 크기로 잇기).
|
||||
*/
|
||||
export function mergeThreadKeys(
|
||||
links: readonly (readonly [string, string])[],
|
||||
): Map<string, string> {
|
||||
const parent = new Map<string, string>();
|
||||
const find = (key: string): string => {
|
||||
let root = key;
|
||||
for (;;) {
|
||||
const up = parent.get(root);
|
||||
if (up === undefined || up === root) break;
|
||||
root = up;
|
||||
}
|
||||
// 지나온 키를 뿌리에 바로 잇는다 (다음 찾기가 짧게)
|
||||
let node = key;
|
||||
while (node !== root) {
|
||||
const up = parent.get(node) as string;
|
||||
parent.set(node, root);
|
||||
node = up;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
for (const [a, b] of links) {
|
||||
if (!parent.has(a)) parent.set(a, a);
|
||||
if (!parent.has(b)) parent.set(b, b);
|
||||
const rootA = find(a);
|
||||
const rootB = find(b);
|
||||
if (rootA === rootB) continue;
|
||||
// 가장 작은 키가 뿌리가 되게 — 합친 대화의 키는 늘 그 대화의 가장 작은 키다
|
||||
if (rootA < rootB) parent.set(rootB, rootA);
|
||||
else parent.set(rootA, rootB);
|
||||
}
|
||||
const renames = new Map<string, string>();
|
||||
for (const key of parent.keys()) {
|
||||
const root = find(key);
|
||||
if (root !== key) renames.set(key, root);
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
@@ -46,4 +46,15 @@ describe('보내기 · 읽기 설정 PATCH 본문 (MailUserSettingsPatchDto)', (
|
||||
'whitelistValidation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('대화로 묶어 보기(14단계)는 참 · 거짓만 — 글로 쓴 값 · 숫자 · null 은 형식 오류', async () => {
|
||||
for (const groupByConversation of [true, false]) {
|
||||
expect(await constraintsOf({ groupByConversation })).toEqual([]);
|
||||
}
|
||||
for (const groupByConversation of ['true', 1, 0, null, 'on']) {
|
||||
expect(await constraintsOf({ groupByConversation })).toEqual([
|
||||
'isBoolean',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, ValidateIf } from 'class-validator';
|
||||
import { IsBoolean, IsIn, ValidateIf } from 'class-validator';
|
||||
|
||||
import {
|
||||
EXTERNAL_IMAGE_MODES,
|
||||
@@ -16,6 +16,8 @@ const whenSent = (_object: object, value: unknown) => value !== undefined;
|
||||
const UNDO_DESCRIPTION =
|
||||
`보내기 취소 — [보내기]를 누른 뒤 메일 서버가 이 초만큼 붙잡았다가 보낸다(${UNDO_SEND_SECONDS_CHOICES.join(' · ')} — 0 이면 바로 보낸다). 처음 10. ` +
|
||||
'예약 발송에는 더하지 않는다';
|
||||
const GROUP_DESCRIPTION =
|
||||
'목록을 대화로 묶어 보기(14단계) — true 면 화면이 메일함 목록을 GET /api/conversations 로 받는다. 처음 false. 검색 결과는 묶지 않는다';
|
||||
const IMAGES_DESCRIPTION =
|
||||
'외부 이미지 — ask 늘 묻기(처음 값) · trusted 보낸 사람이 내 주소록이나 사내 목록(직원 · 공용 주소)에 있으면 늘 보기 · always 늘 보기. ' +
|
||||
'스팸메일함의 메일은 설정과 상관없이 묻는다(images=show 로만 보인다)';
|
||||
@@ -35,6 +37,9 @@ export class MailUserSettingsDto implements MailUserSettings {
|
||||
example: 'ask',
|
||||
})
|
||||
externalImages!: ExternalImageMode;
|
||||
|
||||
@ApiProperty({ description: GROUP_DESCRIPTION, example: false })
|
||||
groupByConversation!: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,4 +68,9 @@ export class MailUserSettingsPatchDto implements MailUserSettingsPatch {
|
||||
message: '외부 이미지 설정을 다시 골라 주세요.',
|
||||
})
|
||||
externalImages?: ExternalImageMode;
|
||||
|
||||
@ApiPropertyOptional({ description: GROUP_DESCRIPTION, example: true })
|
||||
@ValidateIf(whenSent)
|
||||
@IsBoolean({ message: '대화로 묶어 보기를 다시 골라 주세요.' })
|
||||
groupByConversation?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class MailUserSettingsController {
|
||||
@ApiOperation({
|
||||
summary: '보내기 · 읽기 설정',
|
||||
description:
|
||||
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask" } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다.',
|
||||
'저장한 적 없으면 처음 값 { undoSendSeconds: 10, externalImages: "ask", groupByConversation: false } 이다. 서버는 발송 때 undoSendSeconds, 메일을 읽을 때 externalImages 를 본다. groupByConversation 은 화면만 본다(14단계).',
|
||||
})
|
||||
@ApiStandardResponse(MailUserSettingsDto, { description: '지금 설정' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
@@ -49,7 +49,7 @@ export class MailUserSettingsController {
|
||||
@ApiStandardResponse(MailUserSettingsDto, { description: '바꾼 뒤의 설정' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, null) · 모르는 필드',
|
||||
'빈 바꾸기 · 고를 수 없는 값(보내기 취소 0 · 5 · 10 · 20 · 30 밖, 외부 이미지 ask · trusted · always 밖, 대화로 묶기가 true · false 아님, null) · 모르는 필드',
|
||||
)
|
||||
update(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@@ -58,6 +58,7 @@ export class MailUserSettingsController {
|
||||
return this.settings.update(user.email, {
|
||||
undoSendSeconds: dto.undoSendSeconds,
|
||||
externalImages: dto.externalImages,
|
||||
groupByConversation: dto.groupByConversation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
const COLUMNS: Readonly<Record<keyof MailUserSettings, string>> = {
|
||||
undoSendSeconds: 'undo_send_seconds',
|
||||
externalImages: 'external_images',
|
||||
groupByConversation: 'group_by_conversation',
|
||||
};
|
||||
|
||||
/** RETURNING 한 줄 (열 이름이 키다) */
|
||||
@@ -19,6 +20,7 @@ interface ReturnedRow {
|
||||
readonly user_email: string;
|
||||
readonly undo_send_seconds: number;
|
||||
readonly external_images: string;
|
||||
readonly group_by_conversation: boolean;
|
||||
readonly updated_at: Date;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,8 @@ export class MailUserSettingsRepository {
|
||||
userEmail,
|
||||
undoSendSeconds: patch.undoSendSeconds ?? base.undoSendSeconds,
|
||||
externalImages: patch.externalImages ?? base.externalImages,
|
||||
groupByConversation:
|
||||
patch.groupByConversation ?? base.groupByConversation,
|
||||
updatedAt: now,
|
||||
})
|
||||
.orUpdate([...changed, 'updated_at'], ['user_email'])
|
||||
@@ -66,6 +70,7 @@ export class MailUserSettingsRepository {
|
||||
'userEmail',
|
||||
'undoSendSeconds',
|
||||
'externalImages',
|
||||
'groupByConversation',
|
||||
'updatedAt',
|
||||
])
|
||||
.execute();
|
||||
@@ -74,6 +79,7 @@ export class MailUserSettingsRepository {
|
||||
saved.userEmail = row?.user_email ?? userEmail;
|
||||
saved.undoSendSeconds = Number(row?.undo_send_seconds);
|
||||
saved.externalImages = row?.external_images ?? '';
|
||||
saved.groupByConversation = row?.group_by_conversation === true;
|
||||
saved.updatedAt = row?.updated_at ?? now;
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ function row(
|
||||
userEmail: string,
|
||||
undoSendSeconds: number,
|
||||
externalImages: string,
|
||||
groupByConversation = false,
|
||||
): MailUserSettingsEntity {
|
||||
const entity = new MailUserSettingsEntity();
|
||||
entity.userEmail = userEmail;
|
||||
entity.undoSendSeconds = undoSendSeconds;
|
||||
entity.externalImages = externalImages;
|
||||
entity.groupByConversation = groupByConversation;
|
||||
entity.updatedAt = new Date('2026-10-29T00:00:00.000Z');
|
||||
return entity;
|
||||
}
|
||||
@@ -48,6 +50,9 @@ function fakeRepository() {
|
||||
current?.undoSendSeconds ??
|
||||
base.undoSendSeconds,
|
||||
patch.externalImages ?? current?.externalImages ?? base.externalImages,
|
||||
patch.groupByConversation ??
|
||||
current?.groupByConversation ??
|
||||
base.groupByConversation,
|
||||
);
|
||||
rows.set(userEmail, saved);
|
||||
return Promise.resolve(saved);
|
||||
@@ -94,10 +99,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(DEFAULT_MAIL_USER_SETTINGS).toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -111,10 +118,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(service.get('b@arasystem.test')).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,7 +150,11 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
|
||||
await expect(
|
||||
service.update(ARA, { externalImages: 'always' }),
|
||||
).resolves.toEqual({ undoSendSeconds: 20, externalImages: 'always' });
|
||||
).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.upsert).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
{ externalImages: 'always' },
|
||||
@@ -151,6 +164,7 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'always',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -190,10 +204,12 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await expect(reading).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(service.get(ARA)).resolves.toEqual({
|
||||
undoSendSeconds: 30,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -215,4 +231,41 @@ describe('MailUserSettingsService — 보내기 · 읽기 설정 (9단계)', ()
|
||||
await service.get('first@arasystem.test');
|
||||
expect(fake.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('대화로 묶어 보기(14단계) — 처음 끔, 보낸 칸만 바꾸고 다른 칸은 그대로 · 로그에 켬/끔', async () => {
|
||||
const fake = fakeRepository();
|
||||
fake.rows.set(ARA, row(ARA, 20, 'trusted'));
|
||||
const service = new MailUserSettingsService(fake.repository);
|
||||
|
||||
await expect(service.get(ARA)).resolves.toMatchObject({
|
||||
groupByConversation: false,
|
||||
});
|
||||
await expect(
|
||||
service.update(ARA, { groupByConversation: true }),
|
||||
).resolves.toEqual({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'trusted',
|
||||
groupByConversation: true,
|
||||
});
|
||||
expect(fake.upsert).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
{ groupByConversation: true },
|
||||
DEFAULT_MAIL_USER_SETTINGS,
|
||||
expect.any(Date),
|
||||
);
|
||||
const lines = logSpy.mock.calls.map(([line]) => String(line)).join('\n');
|
||||
expect(lines).toContain('대화로 묶기 켬');
|
||||
});
|
||||
|
||||
it('표의 대화로 묶기 값이 참 · 거짓이 아니면(손으로 고친 값) 처음 값(끔)으로 읽는다', async () => {
|
||||
const fake = fakeRepository();
|
||||
const odd = row(ARA, 20, 'trusted');
|
||||
(odd as unknown as { groupByConversation: unknown }).groupByConversation =
|
||||
'yes';
|
||||
fake.rows.set(ARA, odd);
|
||||
const service = new MailUserSettingsService(fake.repository);
|
||||
await expect(service.get(ARA)).resolves.toMatchObject({
|
||||
groupByConversation: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,11 +39,16 @@ function toSettings(row: MailUserSettingsEntity | null): MailUserSettings {
|
||||
externalImages: isExternalImageMode(row.externalImages)
|
||||
? row.externalImages
|
||||
: DEFAULT_MAIL_USER_SETTINGS.externalImages,
|
||||
groupByConversation:
|
||||
typeof row.groupByConversation === 'boolean'
|
||||
? row.groupByConversation
|
||||
: DEFAULT_MAIL_USER_SETTINGS.groupByConversation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
|
||||
* 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계 · 14단계의 대화로 묶기) — 발송(보내기 취소 초)과 메일 읽기(외부 이미지)가 읽는다.
|
||||
* 대화로 묶기는 화면만 본다(서버는 GET /api/conversations 를 부르면 묶는다 — 설정과 상관없이).
|
||||
* - 저장한 적 없으면 처음 값(10초 · 늘 묻기) — 읽기만으로는 줄을 만들지 않는다
|
||||
* - 바꾸기는 보낸 칸만(하나 이상 — 비면 VAL_001). 값의 형식은 요청 검증(dto)이 본다
|
||||
*
|
||||
@@ -83,7 +88,8 @@ export class MailUserSettingsService {
|
||||
): Promise<MailUserSettings> {
|
||||
if (
|
||||
patch.undoSendSeconds === undefined &&
|
||||
patch.externalImages === undefined
|
||||
patch.externalImages === undefined &&
|
||||
patch.groupByConversation === undefined
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'empty-mail-settings-patch' },
|
||||
@@ -99,7 +105,7 @@ export class MailUserSettingsService {
|
||||
);
|
||||
this.remember(cacheKey(userEmail), saved);
|
||||
this.logger.log(
|
||||
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}`,
|
||||
`${traceTag()} 보내기 · 읽기 설정을 바꿨습니다: user=${maskEmail(userEmail)}, 보내기 취소 ${saved.undoSendSeconds}초, 외부 이미지 ${saved.externalImages}, 대화로 묶기 ${saved.groupByConversation ? '켬' : '끔'}`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -17,15 +17,18 @@ export type ExternalImageMode = (typeof EXTERNAL_IMAGE_MODES)[number];
|
||||
export interface MailUserSettings {
|
||||
readonly undoSendSeconds: UndoSendSeconds;
|
||||
readonly externalImages: ExternalImageMode;
|
||||
/** 목록을 대화로 묶어 보기 (14단계 — 처음 끔: 전환 직후 보던 목록을 바꾸지 않는다) */
|
||||
readonly groupByConversation: boolean;
|
||||
}
|
||||
|
||||
/** 바꿀 것 — 하나 이상 (빈 바꾸기는 서비스가 VAL_001) */
|
||||
export type MailUserSettingsPatch = Partial<MailUserSettings>;
|
||||
|
||||
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기) */
|
||||
/** 저장한 적 없는 직원의 값 (계약 — 처음 10초 · 늘 묻기 · 대화로 묶지 않음) */
|
||||
export const DEFAULT_MAIL_USER_SETTINGS: MailUserSettings = Object.freeze({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
|
||||
/** 저장한 값이 아는 값인가 — DB 를 손으로 고쳤거나 옛 값이면 처음 값으로 읽는다 */
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import {
|
||||
ConversationListQueryDto,
|
||||
ConversationQueryDto,
|
||||
} from './dto/conversation-query.dto';
|
||||
import {
|
||||
ConversationListPageDto,
|
||||
ConversationPageDto,
|
||||
} from './dto/mail-response.dto';
|
||||
import { MESSAGES_ROUTE } from './mail.constants';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
|
||||
/**
|
||||
* 대화 (14단계 — 계약 2절 "같은 대화" · "대화로 묶어 보기"). 같은 대화는 메일 자원 아래(`/api/messages/{id}/conversation`),
|
||||
* 묶은 목록은 따로(`/api/conversations`) — 검색은 묶지 않는다
|
||||
*/
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller()
|
||||
export class ConversationController {
|
||||
constructor(private readonly conversations: MailConversationService) {}
|
||||
|
||||
@Get(`${MESSAGES_ROUTE}/:id/conversation`)
|
||||
@ApiOperation({
|
||||
summary: '같은 대화 — 그 메일과 답장 · 전달로 이어진 메일 (오래된 차례)',
|
||||
description:
|
||||
'보낸편지함을 포함한 모든 메일함에서 — 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나다. ' +
|
||||
'기준(basis): 그 직원의 색인이 준비됐으면 index — 답장 관계(References · In-Reply-To)가 있고 말머리(Re · Fw · 회신 · 답장 · 전달 · [꼬리표] — 여러 겹)를 뗀 제목이 같은 메일, ' +
|
||||
'준비 중이면 engine — 엔진의 스레드. 쪽을 넘기는 중에는 커서의 기준대로 이어 간다. ' +
|
||||
`limit 은 처음 값이자 최대가 ${CONVERSATION_PAGE_LIMIT} 다. 항목은 목록과 같은 요약(기다리는 발송 pendingSend 포함)이다.`,
|
||||
})
|
||||
@ApiStandardResponse(ConversationPageDto, { description: '같은 대화' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`limit 범위(1~${CONVERSATION_PAGE_LIMIT}) 밖 · 다른 대화의 커서 · 모르는 쿼리`,
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일 (남의 메일 포함)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
conversation(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Query() query: ConversationQueryDto,
|
||||
) {
|
||||
return this.conversations.conversation(user, id, query);
|
||||
}
|
||||
|
||||
@Get('conversations')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'대화로 묶은 목록 — 그 메일함의 대화 하나가 한 줄 (마지막 받은 시각 최신부터)',
|
||||
description:
|
||||
'그 메일함 안의 메일을 대화로 묶는다 — 줄마다 그 메일함 안 마지막 메일(latest) · 보낸 사람들 · 대화 전체의 통 수 · 그 메일함 안 메일 id(여러 통 처리에) · 읽음 · 중요 · 첨부. ' +
|
||||
'기준(basis)은 같은 대화와 같다(색인 준비 전에는 엔진의 스레드 — collapseThreads). 발송 중인 내부 초안은 빠지고, 붙잡힌 메일(보내기 취소 · 예약 — pendingSend)은 목록처럼 보인다. ' +
|
||||
'검색은 묶지 않는다(GET /api/messages).',
|
||||
})
|
||||
@ApiStandardResponse(ConversationListPageDto, {
|
||||
description: '대화로 묶은 목록',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'mailboxId 없음 · 형식이 틀림 · limit 범위 밖 · 다른 메일함의 커서 · 모르는 쿼리',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '없는 메일함')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: ConversationListQueryDto,
|
||||
) {
|
||||
return this.conversations.list(user, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeConversationCursor } from '@/modules/mail-search/conversation-cursor';
|
||||
import type { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
|
||||
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import type { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import { encodeCursor } from './jmap/jmap-cursor';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MessageSummary } from './mail.types';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
const SCOPE = {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: ['b', 'c'],
|
||||
excludedKeywords: ['$ara-sending'],
|
||||
};
|
||||
|
||||
function summary(id: string, mailboxIds = ['a']): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds,
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-11-02T01:00:00.000Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
pendingSend: null,
|
||||
};
|
||||
}
|
||||
|
||||
const forged = (payload: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
|
||||
function setup(ready = true) {
|
||||
const summaries = (ids: readonly string[]) =>
|
||||
ids
|
||||
.filter((id) => id !== 'gone')
|
||||
.map((id) => summary(id, id === 'moved' ? ['e'] : ['a']));
|
||||
const provider = {
|
||||
conversationScope: jest.fn().mockResolvedValue(SCOPE),
|
||||
getSummaries: jest.fn((_user: string, ids: readonly string[]) =>
|
||||
Promise.resolve(summaries(ids)),
|
||||
),
|
||||
getSummariesWithHeld: jest.fn((_user: string, ids: readonly string[]) =>
|
||||
Promise.resolve({
|
||||
summaries: summaries(ids),
|
||||
heldIds: new Set(['m1', 'elsewhere']),
|
||||
}),
|
||||
),
|
||||
getConversation: jest.fn().mockResolvedValue({
|
||||
items: [summary('e1')],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
}),
|
||||
listConversations: jest.fn().mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
}),
|
||||
};
|
||||
const searchIndex = { isSearchReady: jest.fn().mockResolvedValue(ready) };
|
||||
const searchSync = { syncBeforeRead: jest.fn().mockResolvedValue(true) };
|
||||
const conversations = {
|
||||
conversation: jest.fn().mockResolvedValue({
|
||||
ids: ['m1', 'm2', 'gone'],
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 3,
|
||||
}),
|
||||
mailboxConversations: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
key: 'k1',
|
||||
latestId: 'm2',
|
||||
mailboxMessageIds: ['m2', 'm1'],
|
||||
messageCount: 4,
|
||||
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
key: 'k2',
|
||||
latestId: 'moved',
|
||||
mailboxMessageIds: ['moved'],
|
||||
messageCount: 1,
|
||||
participants: [],
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
},
|
||||
],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
}),
|
||||
};
|
||||
const service = new MailConversationService(
|
||||
provider as unknown as MailProvider,
|
||||
searchIndex as unknown as MailSearchIndexService,
|
||||
searchSync as unknown as MailSearchSyncService,
|
||||
conversations as unknown as MailConversationQueryService,
|
||||
);
|
||||
return { service, provider, searchIndex, searchSync, conversations };
|
||||
}
|
||||
|
||||
describe('MailConversationService (같은 대화 · 묶은 목록 — 14단계 C3 · C4)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
});
|
||||
afterEach(() => warnSpy.mockRestore());
|
||||
|
||||
it('색인이 준비됐으면 색인 — 바로 전 맞추기 → 범위 → 색인의 쪽(그 메일의 키와 한 문장) → 요약은 엔진에서(없는 메일은 뺀다), basis index', async () => {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
|
||||
const result = await service.conversation(user, 'm2', { limit: 100 });
|
||||
|
||||
expect(searchSync.syncBeforeRead).toHaveBeenCalledWith(user.email);
|
||||
expect(provider.conversationScope).toHaveBeenCalledWith(user.email);
|
||||
expect(conversations.conversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
SCOPE,
|
||||
{ limit: 100, after: null },
|
||||
);
|
||||
expect(result.items.map((item) => item.id)).toEqual(['m1', 'm2']);
|
||||
expect(result.pageInfo).toEqual({
|
||||
hasNext: true,
|
||||
nextCursor: 'next',
|
||||
total: 3,
|
||||
});
|
||||
expect(result.basis).toBe('index');
|
||||
expect(provider.getConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('색인이 준비 중 · 그 메일이 색인에 아직 없으면 엔진의 스레드 — basis engine', async () => {
|
||||
const notReady = setup(false);
|
||||
const engine = await notReady.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(engine.basis).toBe('engine');
|
||||
expect(notReady.provider.getConversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
{ limit: 100, cursor: undefined },
|
||||
);
|
||||
|
||||
const notIndexed = setup();
|
||||
notIndexed.conversations.conversation.mockResolvedValue(null);
|
||||
const fallback = await notIndexed.service.conversation(user, 'new', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(fallback.basis).toBe('engine');
|
||||
expect(notIndexed.provider.getSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('쪽을 넘기는 중에는 커서의 방식대로 — 색인 커서는 입구에서 읽어 자리로 넘기고, 그 메일이 색인에서 사라졌으면 404 RES_001', async () => {
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const indexCursor = encodeConversationCursor(
|
||||
'thread',
|
||||
{ at, key: 'm1' },
|
||||
'm2',
|
||||
);
|
||||
const notReady = setup(false);
|
||||
await notReady.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: indexCursor,
|
||||
});
|
||||
expect(notReady.conversations.conversation).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'm2',
|
||||
SCOPE,
|
||||
{ limit: 100, after: { at, key: 'm1' } },
|
||||
);
|
||||
expect(notReady.searchIndex.isSearchReady).not.toHaveBeenCalled();
|
||||
|
||||
const ready = setup();
|
||||
await ready.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: encodeCursor(
|
||||
{ anchor: 'm1', position: 1 },
|
||||
{ conditions: ['conversation', 'm2'] },
|
||||
),
|
||||
});
|
||||
expect(ready.provider.getConversation).toHaveBeenCalled();
|
||||
expect(ready.conversations.conversation).not.toHaveBeenCalled();
|
||||
|
||||
const vanished = setup();
|
||||
vanished.conversations.conversation.mockResolvedValue(null);
|
||||
await expect(
|
||||
vanished.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
cursor: indexCursor,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
});
|
||||
|
||||
it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음 · 다른 메일의 커서)는 맞추기 · 엔진 · DB 에 묻기 전에 400 VAL_001 — 두 경로 모두 (리뷰 L1)', async () => {
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const threadCursors = [
|
||||
forged({ m: 'ti' }),
|
||||
forged({ v: 1, m: 'ti', t: 'x', k: 'm1', s: 'zz' }),
|
||||
encodeConversationCursor('thread', { at, key: 'm1' }, 'other'),
|
||||
];
|
||||
for (const cursor of threadCursors) {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
await expect(
|
||||
service.conversation(user, 'm2', { limit: 100, cursor }),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
|
||||
expect(provider.conversationScope).not.toHaveBeenCalled();
|
||||
expect(conversations.conversation).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
const listCursors = [
|
||||
forged({ m: 'li' }),
|
||||
forged({ v: 1, m: 'li', t: -5, k: 'k1', s: 'zz' }),
|
||||
encodeConversationCursor('list', { at, key: 'k1' }, 'sent'),
|
||||
];
|
||||
for (const cursor of listCursors) {
|
||||
const { service, provider, searchSync, conversations } = setup();
|
||||
await expect(
|
||||
service.list(user, { mailboxId: 'a', limit: 20, cursor }),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(searchSync.syncBeforeRead).not.toHaveBeenCalled();
|
||||
expect(provider.conversationScope).not.toHaveBeenCalled();
|
||||
expect(conversations.mailboxConversations).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('첫 쪽의 색인 읽기가 DB 장애면 엔진으로 — 코드가 있는 실패(RES_001 · MAIL_001)는 그대로', async () => {
|
||||
const broken = setup();
|
||||
broken.conversations.conversation.mockRejectedValue(new Error('db down'));
|
||||
const result = await broken.service.conversation(user, 'm2', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(result.basis).toBe('engine');
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const [line] = warnSpy.mock.calls[0] as [string];
|
||||
expect(line).toContain('[SYS_001]');
|
||||
expect(line).not.toContain(user.email);
|
||||
|
||||
const coded = setup();
|
||||
coded.provider.conversationScope.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(
|
||||
coded.service.conversation(user, 'm2', { limit: 100 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
expect(coded.provider.getConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('묶은 목록 — 색인이면 그 메일함의 범위(있는지 본다) → 색인의 쪽 → 마지막 메일의 요약과 붙잡힌 메일을 한 번에, 줄의 pendingSendIds · 그 메일함을 떠난 대화는 뺀다 (리뷰 L6)', async () => {
|
||||
const { service, provider, conversations } = setup();
|
||||
|
||||
const result = await service.list(user, { mailboxId: 'a', limit: 20 });
|
||||
|
||||
expect(provider.conversationScope).toHaveBeenCalledWith(user.email, 'a');
|
||||
expect(conversations.mailboxConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
SCOPE,
|
||||
{ limit: 20, after: null },
|
||||
);
|
||||
expect(provider.getSummariesWithHeld).toHaveBeenCalledTimes(1);
|
||||
expect(provider.getSummariesWithHeld).toHaveBeenCalledWith(user.email, [
|
||||
'm2',
|
||||
'moved',
|
||||
]);
|
||||
expect(provider.getSummaries).not.toHaveBeenCalled();
|
||||
expect(result.basis).toBe('index');
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
id: 'k1',
|
||||
latest: summary('m2'),
|
||||
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
messageCount: 4,
|
||||
messageIds: ['m2', 'm1'],
|
||||
pendingSendIds: ['m1'],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
hasAttachment: false,
|
||||
},
|
||||
]);
|
||||
expect(result.pageInfo.total).toBe(2);
|
||||
|
||||
// 빈 쪽이면 엔진에 요약 · 제출을 묻지 않는다
|
||||
const empty = setup();
|
||||
empty.conversations.mailboxConversations.mockResolvedValue({
|
||||
items: [],
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
const none = await empty.service.list(user, { mailboxId: 'a', limit: 20 });
|
||||
expect(none.items).toEqual([]);
|
||||
expect(empty.provider.getSummariesWithHeld).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('묶은 목록 — 준비 중이면 엔진(collapseThreads), 커서의 방식은 이어 간다', async () => {
|
||||
const notReady = setup(false);
|
||||
const engine = await notReady.service.list(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
});
|
||||
expect(engine.basis).toBe('engine');
|
||||
expect(notReady.provider.listConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
{ mailboxId: 'a', limit: 20, cursor: undefined },
|
||||
);
|
||||
|
||||
const at = new Date('2026-11-02T01:00:00.000Z');
|
||||
const indexCursor = encodeConversationCursor(
|
||||
'list',
|
||||
{ at, key: 'k9' },
|
||||
'a',
|
||||
);
|
||||
const resumed = setup(false);
|
||||
const next = await resumed.service.list(user, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
cursor: indexCursor,
|
||||
});
|
||||
expect(next.basis).toBe('index');
|
||||
expect(resumed.conversations.mailboxConversations).toHaveBeenCalledWith(
|
||||
user.email,
|
||||
'a',
|
||||
SCOPE,
|
||||
{ limit: 20, after: { at, key: 'k9' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
conversationCursorMethod,
|
||||
decodeConversationCursor,
|
||||
type ConversationPosition,
|
||||
} from '@/modules/mail-search/conversation-cursor';
|
||||
import { MailConversationQueryService } from '@/modules/mail-search/mail-conversation-query.service';
|
||||
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import { MailSearchSyncService } from '@/modules/mail-search/mail-search-sync.service';
|
||||
import type { ConversationBasis } from '@/modules/mail-search/mail-search.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type {
|
||||
ConversationListQueryDto,
|
||||
ConversationQueryDto,
|
||||
} from './dto/conversation-query.dto';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import type { ConversationSummary, MessageSummary } from './mail.types';
|
||||
|
||||
/** 대화 응답 — 목록 모양에 기준(basis)을 더한다 */
|
||||
export type ConversationResult<T> = PaginatedResult<T> & {
|
||||
readonly basis: ConversationBasis;
|
||||
};
|
||||
|
||||
function errorKind(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : typeof error;
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return typeof code === 'string' ? `${name}/${code}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 (14단계 설계 C3 · C4 — 디자인 README "같은 대화" · "대화로 묶어 보기").
|
||||
* - 기준: 커서가 있으면 그 커서의 방식(쪽을 넘기는 중에 준비가 끝나거나 다시 채우기에 들어가도 처음 방식대로), 없으면 그 직원의 색인이 준비됐으면(ready · 지금 판)
|
||||
* 우리 색인(대화 키 — 답장 관계 + 말머리를 뗀 제목), 아니면 엔진의 스레드. 응답에 기준(basis)을 싣는다.
|
||||
* 색인 커서는 입구에서 읽는다 — 모양이 틀리거나 다른 대화 · 메일함의 커서면 맞추기 · 엔진 · DB 에 묻기 전에 VAL_001 (3차 리뷰 L1)
|
||||
* - 색인 방식: 바로 전 맞추기(1.5초 안) → 엔진에 범위(휴지통 · 스팸메일함 · 내부 초안 키워드 — 메일함이 있는지) → 색인 → 보일 메일의 요약은 엔진에서(목록과 같은 모양 · 기다리는 발송)
|
||||
* 엔진에 없는 메일(그사이 지움)과 그 메일함을 떠난 마지막 메일의 대화는 뺀다. 첫 쪽의 색인 읽기가 DB 장애면 엔진 방식으로 답한다
|
||||
* - 같은 대화: 그 메일이 색인에 없으면(방금 와 아직 넣지 않음) 엔진 방식 — 엔진에도 없으면 RES_001. 색인 커서로 이어 가는데 없으면 RES_001
|
||||
* - 묶은 목록의 붙잡힌 메일(pendingSendIds): 마지막 메일의 요약을 읽는 요청에서 계정의 기다리는 제출을 한 번 읽어 줄의 messageIds 와 맞춘다(getSummariesWithHeld)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailConversationService {
|
||||
private readonly logger = new Logger(MailConversationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
private readonly searchIndex: MailSearchIndexService,
|
||||
private readonly searchSync: MailSearchSyncService,
|
||||
private readonly conversations: MailConversationQueryService,
|
||||
) {}
|
||||
|
||||
/** GET /api/messages/{id}/conversation */
|
||||
async conversation(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
query: ConversationQueryDto,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
if (query.cursor) {
|
||||
if (conversationCursorMethod(query.cursor, 'thread') === 'index') {
|
||||
const after = decodeConversationCursor('thread', query.cursor, emailId);
|
||||
return this.conversationFromIndex(user, emailId, query.limit, after);
|
||||
}
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
if (!(await this.searchIndex.isSearchReady(user.email))) {
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
try {
|
||||
return await this.conversationFromIndex(user, emailId, query.limit, null);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) throw error;
|
||||
this.warnFallback(user, error);
|
||||
return this.conversationFromEngine(user, emailId, query);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/conversations */
|
||||
async list(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
if (query.cursor) {
|
||||
if (conversationCursorMethod(query.cursor, 'list') === 'index') {
|
||||
const after = decodeConversationCursor(
|
||||
'list',
|
||||
query.cursor,
|
||||
query.mailboxId,
|
||||
);
|
||||
return this.listFromIndex(user, query, after);
|
||||
}
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
if (!(await this.searchIndex.isSearchReady(user.email))) {
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
try {
|
||||
return await this.listFromIndex(user, query, null);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) throw error;
|
||||
this.warnFallback(user, error);
|
||||
return this.listFromEngine(user, query);
|
||||
}
|
||||
}
|
||||
|
||||
private async conversationFromEngine(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
query: ConversationQueryDto,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
const page = await this.provider.getConversation(user.email, emailId, {
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'engine',
|
||||
};
|
||||
}
|
||||
|
||||
private async conversationFromIndex(
|
||||
user: AuthenticatedUser,
|
||||
emailId: string,
|
||||
limit: number,
|
||||
after: ConversationPosition | null,
|
||||
): Promise<ConversationResult<MessageSummary>> {
|
||||
await this.searchSync.syncBeforeRead(user.email);
|
||||
const scope = await this.provider.conversationScope(user.email);
|
||||
const page = await this.conversations.conversation(
|
||||
user.email,
|
||||
emailId,
|
||||
scope,
|
||||
{ limit, after },
|
||||
);
|
||||
if (page === null) {
|
||||
// 색인에 없다 — 첫 쪽이면 엔진의 스레드로(방금 온 메일), 이어 가는 쪽이면 그 메일이 그사이 사라졌다
|
||||
if (after !== null) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'conversation-message-not-indexed' },
|
||||
});
|
||||
}
|
||||
return this.conversationFromEngine(user, emailId, { limit });
|
||||
}
|
||||
const summaries =
|
||||
page.ids.length > 0
|
||||
? await this.provider.getSummaries(user.email, page.ids)
|
||||
: [];
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const items = page.ids.flatMap((id) => {
|
||||
const summary = byId.get(id);
|
||||
return summary ? [summary] : [];
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'index',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFromEngine(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
const page = await this.provider.listConversations(user.email, {
|
||||
mailboxId: query.mailboxId,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(page.items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'engine',
|
||||
};
|
||||
}
|
||||
|
||||
private async listFromIndex(
|
||||
user: AuthenticatedUser,
|
||||
query: ConversationListQueryDto,
|
||||
after: ConversationPosition | null,
|
||||
): Promise<ConversationResult<ConversationSummary>> {
|
||||
await this.searchSync.syncBeforeRead(user.email);
|
||||
const scope = await this.provider.conversationScope(
|
||||
user.email,
|
||||
query.mailboxId,
|
||||
);
|
||||
const page = await this.conversations.mailboxConversations(
|
||||
user.email,
|
||||
query.mailboxId,
|
||||
scope,
|
||||
{ limit: query.limit, after },
|
||||
);
|
||||
const latestIds = page.items.map((item) => item.latestId);
|
||||
// 마지막 메일의 요약과 계정의 붙잡힌 메일 — 기다리는 제출은 이 쪽에서 한 번만 읽는다 (줄이 없으면 묻지 않는다)
|
||||
const { summaries, heldIds } =
|
||||
latestIds.length > 0
|
||||
? await this.provider.getSummariesWithHeld(user.email, latestIds)
|
||||
: { summaries: [], heldIds: new Set<string>() };
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const items = page.items.flatMap((item): ConversationSummary[] => {
|
||||
const latest = byId.get(item.latestId);
|
||||
// 엔진에 없거나(그사이 지움) 그 메일함을 떠났다 — 맞추기가 곧 고친다
|
||||
if (!latest || !latest.mailboxIds.includes(query.mailboxId)) return [];
|
||||
const messageIds = [...item.mailboxMessageIds];
|
||||
return [
|
||||
{
|
||||
id: item.key,
|
||||
latest,
|
||||
participants: [...item.participants],
|
||||
messageCount: item.messageCount,
|
||||
messageIds,
|
||||
pendingSendIds: messageIds.filter((id) => heldIds.has(id)),
|
||||
isRead: item.isRead,
|
||||
isFlagged: item.isFlagged,
|
||||
hasAttachment: item.hasAttachment,
|
||||
},
|
||||
];
|
||||
});
|
||||
return {
|
||||
...toPaginatedResult(items, {
|
||||
hasNext: page.hasNext,
|
||||
nextCursor: page.nextCursor,
|
||||
total: page.total,
|
||||
}),
|
||||
basis: 'index',
|
||||
};
|
||||
}
|
||||
|
||||
private warnFallback(user: AuthenticatedUser, error: unknown): void {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 검색 색인으로 대화를 읽지 못해 엔진의 스레드로 답합니다: user=${maskEmail(user.email)}, reason=${errorKind(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { CONVERSATION_PAGE_LIMIT } from '@/modules/mail-search/mail-search.constants';
|
||||
import { MAIL_ID_PATTERN } from '@/modules/mail/mail.constants';
|
||||
|
||||
/**
|
||||
* GET /api/messages/{id}/conversation — 같은 대화의 쪽 (14단계). limit 의 처음 값이자 최대가 CONVERSATION_PAGE_LIMIT 다(화면은 한 번에 보인다)
|
||||
*/
|
||||
export class ConversationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'이전 응답의 nextCursor 값 — 그 메일의 대화에서만 쓴다(다르면 400 VAL_001). 첫 쪽은 생략한다',
|
||||
example: 'eyJ2IjoxfQ',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
|
||||
cursor?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `한 번에 가져올 메일 수 (1~${CONVERSATION_PAGE_LIMIT})`,
|
||||
default: CONVERSATION_PAGE_LIMIT,
|
||||
example: CONVERSATION_PAGE_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
|
||||
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
|
||||
@Max(CONVERSATION_PAGE_LIMIT, {
|
||||
message: `조회 개수는 ${CONVERSATION_PAGE_LIMIT} 이하여야 합니다.`,
|
||||
})
|
||||
limit: number = CONVERSATION_PAGE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/conversations — 대화로 묶은 목록 (14단계). 메일함이 꼭 있어야 한다(검색은 묶지 않는다)
|
||||
*/
|
||||
export class ConversationListQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일함 id — 그 메일함의 대화를 묶는다. 없는 메일함은 404 RES_001',
|
||||
example: 'a',
|
||||
})
|
||||
@Matches(MAIL_ID_PATTERN, { message: '메일함을 다시 선택해 주세요.' })
|
||||
mailboxId!: string;
|
||||
}
|
||||
@@ -15,12 +15,18 @@ import {
|
||||
type ContactEmailLabel,
|
||||
} from '@/modules/contacts/contacts.types';
|
||||
import { MAX_BODY_VALUE_BYTES } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import type {
|
||||
ConversationBasis,
|
||||
MessageDetailHighlight,
|
||||
MessageListHighlight,
|
||||
SearchRange,
|
||||
} from '@/modules/mail-search/mail-search.types';
|
||||
import type {
|
||||
ConversationSummary,
|
||||
Draft,
|
||||
MailAddress,
|
||||
MailAttachment,
|
||||
@@ -586,6 +592,121 @@ export class MessagePageDto {
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** 대화의 기준 — 우리 색인(답장 관계 + 말머리를 뗀 제목) 또는 엔진의 스레드(색인을 다 만들기 전) — 14단계 */
|
||||
const CONVERSATION_BASES: ConversationBasis[] = ['index', 'engine'];
|
||||
|
||||
/** 같은 대화 (GET /api/messages/{id}/conversation — 14단계) */
|
||||
export class ConversationPageDto {
|
||||
@ApiProperty({
|
||||
type: [MessageSummaryDto],
|
||||
description:
|
||||
'그 메일의 대화 — 오래된 차례(받은 시각). 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되 지금 메일은 넣는다. 한 통뿐이면 그 메일 하나',
|
||||
})
|
||||
items!: MessageSummaryDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description:
|
||||
'total — 대화의 메일 수. 색인 방식은 첫 쪽에서만, 엔진 방식은 스레드를 모두 읽었을 때(상한 이하)만 쪽마다 — 넘으면 null (쪽이 limit 보다 적어도 hasNext 면 이어 읽는다)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전)',
|
||||
enum: CONVERSATION_BASES,
|
||||
example: 'index',
|
||||
})
|
||||
basis!: ConversationBasis;
|
||||
}
|
||||
|
||||
/** 묶은 목록의 대화 하나 (GET /api/conversations — 14단계) */
|
||||
export class ConversationSummaryDto implements ConversationSummary {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화 — 불투명 문자열(색인이면 대화 키, 엔진이면 스레드 id). 한 목록 안에서 대화를 가리는 데만 쓴다',
|
||||
example: 'eaaaaab',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: MessageSummaryDto,
|
||||
description: '그 메일함 안 마지막 메일 — 줄을 누르면 연다',
|
||||
})
|
||||
latest!: MessageSummaryDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: [MailAddressDto],
|
||||
description: `보낸 사람들 — 대화 전체에서 받은 차례, 주소마다 한 번, ${CONVERSATION_PARTICIPANTS_MAX}명까지 (화면은 내 주소를 "나"로). 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만`,
|
||||
})
|
||||
participants!: MailAddressDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다',
|
||||
example: 4,
|
||||
})
|
||||
messageCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: `그 메일함 안 그 대화의 메일 — 최신부터 ${CONVERSATION_MESSAGE_IDS_MAX}통까지(넘는 오래된 메일은 빠진다). 고르기 · 읽음 · 중요 · 이동 · 삭제는 이 id 들로 여러 통 API(PATCH /api/messages 등)에 — pendingSendIds 는 뺀다`,
|
||||
example: ['eaaaaab', 'eaaaaaa'],
|
||||
})
|
||||
messageIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description:
|
||||
'messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일)의 id — 없으면 빈 배열. 붙잡힌 메일은 취소로만 보낸편지함을 떠난다(여러 통 처리에서 뺀다)',
|
||||
example: [],
|
||||
})
|
||||
pendingSendIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '그 메일함 안 메일을 모두 읽었다',
|
||||
example: false,
|
||||
})
|
||||
isRead!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그 메일함 안 메일 가운데 하나라도 중요 표시',
|
||||
example: false,
|
||||
})
|
||||
isFlagged!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 hasAttachment 와 같은 판정)',
|
||||
example: true,
|
||||
})
|
||||
hasAttachment!: boolean;
|
||||
}
|
||||
|
||||
/** 대화로 묶은 목록 (GET /api/conversations — 14단계) */
|
||||
export class ConversationListPageDto {
|
||||
@ApiProperty({
|
||||
type: [ConversationSummaryDto],
|
||||
description: '그 메일함의 대화 — 그 메일함 안 마지막 받은 시각 최신부터',
|
||||
})
|
||||
items!: ConversationSummaryDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description:
|
||||
'total — 그 메일함의 대화 수(색인 방식은 첫 쪽에서만 · 엔진 방식은 엔진이 센 스레드 수)',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'묶은 기준 — index 우리 색인 · engine 엔진의 스레드(그 직원의 색인을 다 만들기 전 — 통 수 · 묶음이 엔진 규칙이다)',
|
||||
enum: CONVERSATION_BASES,
|
||||
example: 'index',
|
||||
})
|
||||
basis!: ConversationBasis;
|
||||
}
|
||||
|
||||
const REJECTION_REASONS: RecipientRejectionReason[] = [
|
||||
'not-found',
|
||||
'refused',
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { firstSender } from '@/modules/mail-search/index-document';
|
||||
import {
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import type { JmapClient } from './jmap.client';
|
||||
import { JmapConversations, shareScan } from './jmap-conversations';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
/** 내보낼 메일만 읽어야 하는 요약 속성의 표 — 미리 보기 */
|
||||
const SUMMARY_ONLY = 'preview';
|
||||
const FUTURE = '2099-01-01T00:00:00Z';
|
||||
|
||||
/** 호출 한 번이 시작될 때 — 손으로 돌리는 시계를 넘기거나, Error 를 돌려주면 그 호출을 그 오류로 끝낸다 */
|
||||
type CallHook = (
|
||||
index: number,
|
||||
options: { timeoutMs?: number } | undefined,
|
||||
) => unknown;
|
||||
|
||||
/** 엔진 대역 — 받은 호출을 적고 준비한 응답을 차례로 */
|
||||
function fakeClient(responses: JmapInvocation[][], onCall?: CallHook) {
|
||||
const recorded: {
|
||||
calls: JmapInvocation[];
|
||||
options: { timeoutMs?: number } | undefined;
|
||||
}[] = [];
|
||||
const queue = [...responses];
|
||||
const call = jest.fn(
|
||||
(
|
||||
_user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
recorded.push({ calls: build('d'), options });
|
||||
const failure = onCall?.(recorded.length - 1, options);
|
||||
if (failure instanceof Error) return Promise.reject(failure);
|
||||
const next = queue.shift();
|
||||
if (!next) return Promise.reject(new Error('준비한 응답이 없습니다'));
|
||||
return Promise.resolve(next);
|
||||
},
|
||||
);
|
||||
return {
|
||||
recorded,
|
||||
call,
|
||||
reader: new JmapConversations({ call } as unknown as JmapClient),
|
||||
};
|
||||
}
|
||||
|
||||
const MAILBOX_LIST = [
|
||||
{ id: 'a', role: 'inbox' },
|
||||
{ id: 'b', role: 'trash' },
|
||||
{ id: 'c', role: 'junk' },
|
||||
{ id: 'e', role: 'sent' },
|
||||
];
|
||||
const mailboxes: JmapInvocation = ['Mailbox/get', { list: MAILBOX_LIST }, 'm'];
|
||||
|
||||
/** 메일 하나 (엔진이 주는 꼴 — 요청한 속성과 상관없이 다 싣는다) */
|
||||
function email(
|
||||
id: string,
|
||||
receivedAt: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: { a: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: `보낸 ${id}`, email: `${id}@example.com` }],
|
||||
to: [{ name: null, email: ARA }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '미리 보기',
|
||||
receivedAt,
|
||||
attachments: [],
|
||||
size: 100,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const noHeld: JmapInvocation[] = [
|
||||
['EmailSubmission/query', { ids: [], total: 0, position: 0 }, 'pq'],
|
||||
['EmailSubmission/get', { list: [] }, 'pg'],
|
||||
];
|
||||
|
||||
/** 한 요청에서 그 호출 id 의 인자 */
|
||||
function argsOf(calls: JmapInvocation[], id: string): Record<string, unknown> {
|
||||
return calls.find(([, , callId]) => callId === id)?.[1] ?? {};
|
||||
}
|
||||
|
||||
/** 그 요청의 Email/get 이 요약 속성(미리 보기)을 읽었나 */
|
||||
function readsSummary(calls: JmapInvocation[]): boolean {
|
||||
return calls.some(
|
||||
([method, args]) =>
|
||||
method === 'Email/get' &&
|
||||
Array.isArray(args.properties) &&
|
||||
(args.properties as string[]).includes(SUMMARY_ONLY),
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 요청의 Email/get 이 묻는 id (back-reference 면 빈 배열) */
|
||||
function idsOf(calls: JmapInvocation[]): string[] {
|
||||
return calls.flatMap(([method, args]) =>
|
||||
method === 'Email/get' && Array.isArray(args.ids)
|
||||
? (args.ids as string[])
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
interface FakeMail {
|
||||
readonly id: string;
|
||||
readonly threadId: string;
|
||||
readonly receivedAt: string;
|
||||
readonly mailboxIds: Record<string, boolean>;
|
||||
readonly keywords?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 들고 요청마다 답하는 엔진 대역 (큰 스레드 — 3차 리뷰 H1). Email/get · Thread/get · Mailbox/get · collapseThreads 의 Email/query ·
|
||||
* 제출 조회를 안다(back-reference 는 /ids · /list/[*]/threadId 만)
|
||||
*/
|
||||
function fakeEngine(mails: readonly FakeMail[], onCall?: CallHook) {
|
||||
const byId = new Map(mails.map((mail) => [mail.id, mail]));
|
||||
const threads = new Map<string, string[]>();
|
||||
for (const mail of [...mails].sort((a, b) =>
|
||||
a.receivedAt.localeCompare(b.receivedAt),
|
||||
)) {
|
||||
threads.set(mail.threadId, [
|
||||
...(threads.get(mail.threadId) ?? []),
|
||||
mail.id,
|
||||
]);
|
||||
}
|
||||
const requests: JmapInvocation[][] = [];
|
||||
/** 호출마다 받은 제한 시간 */
|
||||
const timeouts: (number | undefined)[] = [];
|
||||
const full = (mail: FakeMail) => email(mail.id, mail.receivedAt, { ...mail });
|
||||
const call = jest.fn(
|
||||
(
|
||||
_user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
const invocations = build('d');
|
||||
requests.push(invocations);
|
||||
timeouts.push(options?.timeoutMs);
|
||||
const failure = onCall?.(requests.length - 1, options);
|
||||
if (failure instanceof Error) return Promise.reject(failure);
|
||||
const results = new Map<string, Record<string, unknown>>();
|
||||
const resolve = (args: Record<string, unknown>): string[] => {
|
||||
const ref = args['#ids'] as
|
||||
| { resultOf: string; path: string }
|
||||
| undefined;
|
||||
if (!ref) return (args.ids as string[] | null) ?? [];
|
||||
const source = results.get(ref.resultOf) ?? {};
|
||||
if (ref.path === '/ids') return (source.ids as string[]) ?? [];
|
||||
return ((source.list as { threadId: string }[]) ?? []).map(
|
||||
(item) => item.threadId,
|
||||
);
|
||||
};
|
||||
const responses: JmapInvocation[] = invocations.map(
|
||||
([method, args, id]) => {
|
||||
let result: Record<string, unknown>;
|
||||
if (method === 'Email/get') {
|
||||
const ids = resolve(args);
|
||||
result = {
|
||||
list: ids.flatMap((emailId) => {
|
||||
const mail = byId.get(emailId);
|
||||
return mail ? [full(mail)] : [];
|
||||
}),
|
||||
notFound: ids.filter((emailId) => !byId.has(emailId)),
|
||||
};
|
||||
} else if (method === 'Thread/get') {
|
||||
result = {
|
||||
list: resolve(args).map((threadId) => ({
|
||||
id: threadId,
|
||||
emailIds: threads.get(threadId) ?? [],
|
||||
})),
|
||||
};
|
||||
} else if (method === 'Mailbox/get') {
|
||||
result = { list: MAILBOX_LIST };
|
||||
} else if (method === 'Email/query') {
|
||||
const filter = args.filter as { inMailbox: string };
|
||||
const seen = new Set<string>();
|
||||
const ids = [...mails]
|
||||
.filter((mail) => mail.mailboxIds[filter.inMailbox])
|
||||
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
|
||||
.filter((mail) => {
|
||||
if (seen.has(mail.threadId)) return false;
|
||||
seen.add(mail.threadId);
|
||||
return true;
|
||||
})
|
||||
.map((mail) => mail.id);
|
||||
const position = (args.position as number | undefined) ?? 0;
|
||||
result = {
|
||||
ids: ids.slice(position, position + (args.limit as number)),
|
||||
position,
|
||||
total: ids.length,
|
||||
};
|
||||
} else if (method === 'EmailSubmission/query') {
|
||||
result = { ids: [], total: 0, position: 0 };
|
||||
} else {
|
||||
result = { list: [] };
|
||||
}
|
||||
results.set(id, result);
|
||||
return [method, result, id];
|
||||
},
|
||||
);
|
||||
return Promise.resolve(responses);
|
||||
},
|
||||
);
|
||||
return {
|
||||
requests,
|
||||
timeouts,
|
||||
call,
|
||||
reader: new JmapConversations({ call } as unknown as JmapClient),
|
||||
};
|
||||
}
|
||||
|
||||
/** 받은 시각 — n 초 뒤 (차례가 id 차례와 같게) */
|
||||
const second = (n: number) =>
|
||||
new Date(Date.UTC(2026, 10, 1) + n * 1000).toISOString();
|
||||
|
||||
describe('엔진의 스레드로 본 대화 (JmapConversations — 14단계, 색인 전)', () => {
|
||||
let warnSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('같은 대화 (conversation)', () => {
|
||||
const lookup = (emailIds: string[]): JmapInvocation[] => [
|
||||
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
|
||||
['Thread/get', { list: [{ id: 't1', emailIds }] }, 't'],
|
||||
mailboxes,
|
||||
];
|
||||
|
||||
it('그 메일의 스레드 → 값싼 속성으로 걸러 오래된 차례 — 휴지통 · 스팸에만 든 메일 · 내부 초안은 빼되 지금 메일은 넣고, 요약 속성은 내보낼 메일만', async () => {
|
||||
const { reader, recorded } = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3', 'm4', 'm5']),
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m1', '2026-11-01T00:00:00Z', {
|
||||
mailboxIds: { e: true },
|
||||
}),
|
||||
email('m2', '2026-11-02T00:00:00Z', {
|
||||
mailboxIds: { c: true },
|
||||
}),
|
||||
email('m3', '2026-11-03T00:00:00Z', {
|
||||
mailboxIds: { b: true },
|
||||
}),
|
||||
email('m4', '2026-11-04T00:00:00Z', {
|
||||
keywords: { $draft: true, '$ara-sending': true },
|
||||
}),
|
||||
email('m5', '2026-11-05T00:00:00Z', {
|
||||
mailboxIds: { b: true, a: true },
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: ['m1', 'm2', 'm5'].map((id, index) =>
|
||||
email(id, `2026-11-0${index + 1}T00:00:00Z`),
|
||||
),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
|
||||
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
|
||||
|
||||
// m3(휴지통에만) · m4(내부 초안) 빠짐, m2(스팸이지만 지금 메일) · m5(휴지통과 받은편지함) 듦
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm5']);
|
||||
expect(page.total).toBe(3);
|
||||
expect(page.hasNext).toBe(false);
|
||||
expect(recorded[0].options).toEqual({
|
||||
invalidRequestCode: 'RES_001',
|
||||
timeoutMs: expect.any(Number) as number,
|
||||
});
|
||||
expect(recorded[0].calls.map(([method]) => method)).toEqual([
|
||||
'Email/get',
|
||||
'Thread/get',
|
||||
'Mailbox/get',
|
||||
]);
|
||||
// 거르기는 값싼 속성만, 요약은 내보낼 세 통만 (기다리는 발송과 함께)
|
||||
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'keywords',
|
||||
'receivedAt',
|
||||
]);
|
||||
expect(readsSummary(recorded[1].calls)).toBe(false);
|
||||
expect(idsOf(recorded[2].calls)).toEqual(['m1', 'm2', 'm5']);
|
||||
expect(recorded[2].calls.map(([method]) => method)).toEqual([
|
||||
'Email/get',
|
||||
'EmailSubmission/query',
|
||||
'EmailSubmission/get',
|
||||
]);
|
||||
});
|
||||
|
||||
it('쪽을 넘긴다 — 커서는 그 메일의 대화에서만(다른 메일의 커서는 엔진에 묻기 전에 VAL_001), 스레드 안 위치 · 마지막 메일이 빠졌으면 위치로', async () => {
|
||||
const cheap: JmapInvocation[] = [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: ['m1', 'm2', 'm3'].map((id, index) =>
|
||||
email(id, `2026-11-0${index + 1}T00:00:00Z`),
|
||||
),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
];
|
||||
const summaries = (ids: string[]): JmapInvocation[] => [
|
||||
[
|
||||
'Email/get',
|
||||
{ list: ids.map((id) => email(id, '2026-11-01T00:00:00Z')) },
|
||||
'g',
|
||||
],
|
||||
...noHeld,
|
||||
];
|
||||
const first = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m1', 'm2']),
|
||||
]);
|
||||
const page = await first.reader.conversation(ARA, 'm2', { limit: 2 });
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBe(3);
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, { conditions: ['conversation', 'm2'] }),
|
||||
).toEqual({ anchor: 'm2', position: 2 });
|
||||
|
||||
const next = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m3']),
|
||||
]);
|
||||
const second = await next.reader.conversation(ARA, 'm2', {
|
||||
limit: 2,
|
||||
cursor: page.nextCursor!,
|
||||
});
|
||||
expect(second.items.map((item) => item.id)).toEqual(['m3']);
|
||||
expect(second.hasNext).toBe(false);
|
||||
expect(idsOf(next.recorded[2].calls)).toEqual(['m3']);
|
||||
|
||||
const other = fakeClient([]);
|
||||
await expect(
|
||||
other.reader.conversation(ARA, 'm9', {
|
||||
limit: 2,
|
||||
cursor: page.nextCursor!,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'VAL_001' });
|
||||
expect(other.call).not.toHaveBeenCalled();
|
||||
|
||||
const gone = encodeCursor(
|
||||
{ anchor: 'm-gone', position: 1 },
|
||||
{ conditions: ['conversation', 'm2'] },
|
||||
);
|
||||
const resumed = fakeClient([
|
||||
lookup(['m1', 'm2', 'm3']),
|
||||
cheap,
|
||||
summaries(['m2', 'm3']),
|
||||
]);
|
||||
const fromPosition = await resumed.reader.conversation(ARA, 'm2', {
|
||||
limit: 5,
|
||||
cursor: gone,
|
||||
});
|
||||
expect(fromPosition.items.map((item) => item.id)).toEqual(['m2', 'm3']);
|
||||
});
|
||||
|
||||
it('그 메일이 없으면(남의 메일 · 지움) 404 RES_001', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['Email/get', { list: [], notFound: ['zz'] }, 'e'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
mailboxes,
|
||||
],
|
||||
]);
|
||||
const error = await reader
|
||||
.conversation(ARA, 'zz', { limit: 100 })
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(BusinessException);
|
||||
expect((error as BusinessException).code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('메일함 역할을 읽지 못하면 빼지 않고 이어 간다 — 경고는 코드 · 가린 주소만 (리뷰 L5)', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['Email/get', { list: [{ id: 'm2', threadId: 't1' }] }, 'e'],
|
||||
['Thread/get', { list: [{ id: 't1', emailIds: ['m2'] }] }, 't'],
|
||||
['error', { type: 'serverFail' }, 'm'],
|
||||
],
|
||||
[['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g']],
|
||||
[
|
||||
['Email/get', { list: [email('m2', '2026-11-02T00:00:00Z')] }, 'g'],
|
||||
// 기다리는 제출을 다 주지 못했다 — 목록과 같은 경고
|
||||
[
|
||||
'EmailSubmission/query',
|
||||
{ ids: ['s1'], total: 600, position: 0 },
|
||||
'pq',
|
||||
],
|
||||
['EmailSubmission/get', { list: [] }, 'pg'],
|
||||
],
|
||||
]);
|
||||
const page = await reader.conversation(ARA, 'm2', { limit: 100 });
|
||||
expect(page.items.map((item) => item.id)).toEqual(['m2']);
|
||||
const lines = warnSpy.mock.calls.map(([line]) => String(line));
|
||||
expect(lines).toHaveLength(2);
|
||||
for (const line of lines) {
|
||||
expect(line).toMatch(/^\[MAIL_001\]/);
|
||||
expect(line).toContain('user=a***@arasystem.test');
|
||||
expect(line).not.toContain(ARA);
|
||||
}
|
||||
});
|
||||
|
||||
it('2,000통짜리 스레드 — 거르기는 커서 자리부터 상한까지만, 요약 속성은 한 쪽(100통)만 · total 은 null (리뷰 H1)', async () => {
|
||||
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
mailboxIds: { a: true },
|
||||
}));
|
||||
const engine = fakeEngine(mails);
|
||||
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(page.items).toHaveLength(100);
|
||||
expect(page.items[0].id).toBe('m0000');
|
||||
expect(page.total).toBeNull();
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({ anchor: 'm0099', position: 100 });
|
||||
// 엔진 요청: 찾기 · 거르기 한 번(500통) · 요약 한 번
|
||||
expect(engine.call).toHaveBeenCalledTimes(3);
|
||||
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
|
||||
expect(summaryIds).toHaveLength(100);
|
||||
const scanned = engine.requests
|
||||
.filter((request) => !readsSummary(request))
|
||||
.flatMap(idsOf)
|
||||
.filter((id) => id !== 'm1999');
|
||||
expect(scanned.length).toBeLessThanOrEqual(CONVERSATION_ENGINE_SCAN_MAX);
|
||||
});
|
||||
|
||||
it('거를 메일이 많은 큰 스레드 — 한 요청은 상한까지만 훑고 빈 쪽이어도 이어 갈 자리를 준다, 다음 요청에서 지금 메일이 든다', async () => {
|
||||
const mails: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
// 지금 메일(맨 끝) 말고는 모두 휴지통에만
|
||||
mailboxIds: { [index === 1_999 ? 'a' : 'b']: true },
|
||||
}));
|
||||
const engine = fakeEngine(mails);
|
||||
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items).toEqual([]);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({
|
||||
anchor: 'm0999',
|
||||
position: CONVERSATION_ENGINE_SCAN_MAX,
|
||||
});
|
||||
// 찾기 · 거르기 두 번(500 + 500) — 요약은 읽지 않는다
|
||||
expect(engine.call).toHaveBeenCalledTimes(3);
|
||||
expect(engine.requests.some(readsSummary)).toBe(false);
|
||||
|
||||
const next = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
cursor: page.nextCursor!,
|
||||
});
|
||||
expect(next.items.map((item) => item.id)).toEqual(['m1999']);
|
||||
expect(next.hasNext).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('묶은 목록 (list)', () => {
|
||||
it('collapseThreads 로 그 메일함 안 스레드마다 마지막 메일(요약은 이것만) → 스레드의 나머지 메일을 값싼 속성으로 — 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일 (리뷰 L6)', async () => {
|
||||
const { reader, recorded } = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['m3', 'x1'], position: 0, total: 2 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m3', '2026-11-03T00:00:00Z', {
|
||||
keywords: {},
|
||||
threadId: 't1',
|
||||
}),
|
||||
email('x1', '2026-11-02T00:00:00Z', { threadId: 't2' }),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
list: [
|
||||
{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] },
|
||||
{ id: 't2', emailIds: ['x1'] },
|
||||
],
|
||||
},
|
||||
't',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/query',
|
||||
{ ids: ['s1'], total: 1, position: 0 },
|
||||
'pq',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 's1',
|
||||
emailId: 'm2',
|
||||
undoStatus: 'pending',
|
||||
sendAt: FUTURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
'pg',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('m1', '2026-11-01T00:00:00Z', {
|
||||
mailboxIds: { e: true },
|
||||
from: [{ name: '김아라', email: ARA }],
|
||||
}),
|
||||
email('m2', '2026-11-02T00:00:00Z', {
|
||||
attachments: [
|
||||
{
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment',
|
||||
size: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
email('m4', '2026-11-04T00:00:00Z', {
|
||||
mailboxIds: { b: true },
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
|
||||
expect(argsOf(recorded[0].calls, 'q')).toMatchObject({
|
||||
filter: { inMailbox: 'a', notKeyword: '$ara-sending' },
|
||||
collapseThreads: true,
|
||||
calculateTotal: true,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
});
|
||||
// 마지막 메일(m3 · x1)은 다시 읽지 않는다 — 나머지는 값싼 속성 + 보낸 사람 · 첨부 부분
|
||||
expect(recorded).toHaveLength(2);
|
||||
expect(idsOf(recorded[1].calls)).toEqual(['m1', 'm2', 'm4']);
|
||||
expect(readsSummary(recorded[1].calls)).toBe(false);
|
||||
expect(argsOf(recorded[1].calls, 'g').properties).toEqual([
|
||||
'id',
|
||||
'mailboxIds',
|
||||
'keywords',
|
||||
'receivedAt',
|
||||
'from',
|
||||
'attachments',
|
||||
]);
|
||||
expect(page.total).toBe(2);
|
||||
const [thread, single] = page.items;
|
||||
expect(thread).toMatchObject({
|
||||
id: 't1',
|
||||
messageCount: 3, // m4 는 휴지통에만
|
||||
messageIds: ['m3', 'm2'], // 받은편지함 안 — 최신부터
|
||||
pendingSendIds: ['m2'],
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: true,
|
||||
});
|
||||
expect(thread.latest.id).toBe('m3');
|
||||
expect(thread.participants.map((p) => p.email)).toEqual([
|
||||
ARA,
|
||||
'm2@example.com',
|
||||
'm3@example.com',
|
||||
]);
|
||||
expect(single).toMatchObject({
|
||||
id: 't2',
|
||||
messageCount: 1,
|
||||
messageIds: ['x1'],
|
||||
pendingSendIds: [],
|
||||
isRead: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('메일함 목록을 엔진이 거절하면(메서드 오류) 503 MAIL_001 — 없는 메일함 404 와 가른다 (리뷰 M1)', async () => {
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
['error', { type: 'serverFail' }, 'm'],
|
||||
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
|
||||
['Email/get', { list: [] }, 'g'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
reader.list(ARA, { mailboxId: 'a', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
});
|
||||
|
||||
it('없는 메일함이면 404 RES_001 · 커서는 그 메일함의 묶은 목록에서만', async () => {
|
||||
const missing = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: [], position: 0, total: 0 }, 'q'],
|
||||
['Email/get', { list: [] }, 'g'],
|
||||
['Thread/get', { list: [] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
missing.reader.list(ARA, { mailboxId: 'zz', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'RES_001' });
|
||||
|
||||
const cursor = encodeCursor(
|
||||
{ anchor: 'm3', position: 20 },
|
||||
{ mailboxId: 'a', conditions: ['conversations'] },
|
||||
);
|
||||
const other = fakeClient([]);
|
||||
for (const query of [
|
||||
{ mailboxId: 'e', limit: 20, cursor },
|
||||
// 메일 목록의 커서는 쓸 수 없다
|
||||
{
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
cursor: encodeCursor(
|
||||
{ anchor: 'm3', position: 20 },
|
||||
{ mailboxId: 'a' },
|
||||
),
|
||||
},
|
||||
]) {
|
||||
await expect(other.reader.list(ARA, query)).rejects.toMatchObject({
|
||||
code: 'VAL_001',
|
||||
});
|
||||
}
|
||||
expect(other.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('2,000통짜리 스레드가 든 쪽 — 스레드마다 최신 상한까지 · 요약 속성은 줄마다 마지막 메일만, 상한에 걸린 줄의 통 수는 읽지 못한 메일을 거르지 않고 더한다 (리뷰 H1)', async () => {
|
||||
const big: FakeMail[] = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `g${String(index).padStart(4, '0')}`,
|
||||
threadId: 'tg',
|
||||
receivedAt: second(index),
|
||||
// 오래된 절반은 휴지통에만 — 읽지 못한 쪽이라 통 수에는 그대로 든다
|
||||
mailboxIds: { [index < 1_000 ? 'b' : 'a']: true },
|
||||
}));
|
||||
const small: FakeMail[] = ['s1', 's2'].map((id, index) => ({
|
||||
id,
|
||||
threadId: 'ts',
|
||||
receivedAt: second(5_000 + index),
|
||||
mailboxIds: { a: true },
|
||||
}));
|
||||
const engine = fakeEngine([...big, ...small]);
|
||||
|
||||
const page = await engine.reader.list(ARA, {
|
||||
mailboxId: 'a',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(page.items.map((row) => row.id)).toEqual(['ts', 'tg']);
|
||||
// 엔진 요청: 목록 한 번 · 스레드 메일 한 번
|
||||
expect(engine.call).toHaveBeenCalledTimes(2);
|
||||
const summaryIds = engine.requests.filter(readsSummary).flatMap(idsOf);
|
||||
expect(summaryIds).toEqual([]); // 마지막 메일은 back-reference 로 — 다시 읽지 않는다
|
||||
const memberIds = engine.requests.slice(1).flatMap(idsOf);
|
||||
expect(memberIds).toHaveLength(
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX - 1 + 1,
|
||||
);
|
||||
expect(memberIds.length).toBeLessThanOrEqual(
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
);
|
||||
const [smallRow, bigRow] = page.items;
|
||||
expect(smallRow).toMatchObject({
|
||||
messageCount: 2,
|
||||
messageIds: ['s2', 's1'],
|
||||
});
|
||||
// 최신 500통을 읽었고(모두 받은편지함) 오래된 1,500통은 읽지 않고 센다
|
||||
expect(bigRow.messageCount).toBe(2_000);
|
||||
expect(bigRow.messageIds).toHaveLength(
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
);
|
||||
expect(bigRow.messageIds[0]).toBe('g1999');
|
||||
expect(bigRow.messageIds.length).toBeLessThanOrEqual(
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('한 요청의 시간 (3차 재확인 N1 · N2 — Date.now 를 손으로 돌린다)', () => {
|
||||
const START = Date.UTC(2026, 10, 6, 1, 0, 0);
|
||||
let clock = START;
|
||||
beforeEach(() => {
|
||||
clock = START;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => clock);
|
||||
});
|
||||
|
||||
/** 스레드 하나 — `mailbox(i)` 메일함에 든 메일 `count` 통 (받은 시각 차례) */
|
||||
const thread = (
|
||||
count: number,
|
||||
mailbox: (index: number) => string = () => 'a',
|
||||
): FakeMail[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${String(index).padStart(4, '0')}`,
|
||||
threadId: 't1',
|
||||
receivedAt: second(index),
|
||||
mailboxIds: { [mailbox(index)]: true },
|
||||
}));
|
||||
|
||||
it('1,000통 이하 스레드를 시간 안에 다 읽지 못하면 503 MAIL_001 — 요약은 읽지 않는다', async () => {
|
||||
// 찾기 7초 · 첫 묶음은 제한 시간(5초 — 남은 8초에서 요약 몫 3초를 뺀)을 다 쓰고 돌아온다 → 남은 3초는 더 훑지 않는다
|
||||
const engine = fakeEngine(thread(800), (index, options) => {
|
||||
clock += index === 0 ? 7_000 : (options?.timeoutMs ?? 0);
|
||||
});
|
||||
await expect(
|
||||
engine.reader.conversation(ARA, 'm0799', { limit: 100 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
expect(engine.timeouts).toEqual([10_000, 5_000]);
|
||||
expect(engine.requests.some(readsSummary)).toBe(false);
|
||||
});
|
||||
|
||||
it('1,000통이 넘는 스레드에서 한 묶음 뒤 시간이 끝나면 읽은 데까지 답하고 앞으로 간 커서를 준다 — 요약 읽기는 남은 3초를 받는다', async () => {
|
||||
// 앞 세 통만 받은편지함, 나머지는 휴지통에만(지금 메일은 맨 끝) — 첫 묶음으로는 쪽이 차지 않는다
|
||||
const engine = fakeEngine(
|
||||
thread(2_000, (index) => (index < 3 || index === 1_999 ? 'a' : 'b')),
|
||||
(index, options) => {
|
||||
clock += index === 0 ? 2_000 : (options?.timeoutMs ?? 0);
|
||||
},
|
||||
);
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items.map((item) => item.id)).toEqual([
|
||||
'm0000',
|
||||
'm0001',
|
||||
'm0002',
|
||||
]);
|
||||
expect(page.hasNext).toBe(true);
|
||||
expect(page.total).toBeNull();
|
||||
expect(
|
||||
decodeCursor(page.nextCursor!, {
|
||||
conditions: ['conversation', 'm1999'],
|
||||
}),
|
||||
).toEqual({ anchor: 'm0499', position: 500 });
|
||||
// 찾기 10초 · 묶음 10초(남은 13초 − 3초) · 요약 3초(남은 시간을 다)
|
||||
expect(engine.timeouts).toEqual([10_000, 10_000, 3_000]);
|
||||
});
|
||||
|
||||
it('훑는 호출은 마지막 요약 읽기 몫을 빼고 받는다 — 3.5초 남아 시작한 느린 묶음 뒤에도 요약 읽기가 3초를 받는다 (N1)', async () => {
|
||||
const engine = fakeEngine(thread(2_000), (index, options) => {
|
||||
clock += index === 0 ? 11_500 : (options?.timeoutMs ?? 0);
|
||||
});
|
||||
const page = await engine.reader.conversation(ARA, 'm1999', {
|
||||
limit: 100,
|
||||
});
|
||||
expect(page.items).toHaveLength(100);
|
||||
// 남은 3.5초에서 시작한 묶음은 0.5초만 받고(전에는 3.5초를 받아 요약 읽기가 1ms 였다), 요약 읽기는 3초
|
||||
expect(engine.timeouts).toEqual([10_000, 500, 3_000]);
|
||||
});
|
||||
|
||||
describe('묶은 목록 — 시간이 끝나면 어림 줄로 답한다(503 이 아니다)', () => {
|
||||
const first: JmapInvocation[] = [
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['m3'], position: 0, total: 1 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{ list: [email('m3', '2026-11-03T00:00:00Z', { keywords: {} })] },
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{ list: [{ id: 't1', emailIds: ['m1', 'm2', 'm3', 'm4'] }] },
|
||||
't',
|
||||
],
|
||||
...noHeld,
|
||||
];
|
||||
/** 스레드의 다른 메일을 읽지 못한 줄 — 통 수는 읽지 못한 세 통을 거르지 않고 더하고, 나머지는 마지막 메일로만 */
|
||||
const approximate = {
|
||||
id: 't1',
|
||||
messageCount: 4,
|
||||
messageIds: ['m3'],
|
||||
pendingSendIds: [],
|
||||
participants: [{ name: '보낸 m3', email: 'm3@example.com' }],
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
};
|
||||
|
||||
it('첫 요청이 늦어 남은 시간이 요약 몫뿐이면 스레드 메일을 읽지 않는다', async () => {
|
||||
const { reader, recorded } = fakeClient([first], () => {
|
||||
clock += 13_000;
|
||||
});
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0].options?.timeoutMs).toBe(10_000);
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.items[0]).toMatchObject(approximate);
|
||||
});
|
||||
|
||||
it('스레드 메일 읽기가 시간 때문에 끊기면 그 메일은 읽지 못한 것으로 — 시간이 남았는데 엔진이 실패하면 그대로 503', async () => {
|
||||
const cut = fakeClient([first], (index, options) => {
|
||||
if (index === 0) {
|
||||
clock += 2_000;
|
||||
return undefined;
|
||||
}
|
||||
// 제한 시간(남은 13초 − 요약 몫 3초)을 다 쓰고 끊겼다
|
||||
clock += options?.timeoutMs ?? 0;
|
||||
return new BusinessException('MAIL_001', {
|
||||
context: { reason: 'timeout' },
|
||||
});
|
||||
});
|
||||
const page = await cut.reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
expect(cut.recorded).toHaveLength(2);
|
||||
expect(cut.recorded[1].options?.timeoutMs).toBe(10_000);
|
||||
expect(page.items[0]).toMatchObject(approximate);
|
||||
|
||||
const broken = fakeClient([first], (index) =>
|
||||
index === 0 ? undefined : new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(
|
||||
broken.reader.list(ARA, { mailboxId: 'a', limit: 20 }),
|
||||
).rejects.toMatchObject({ code: 'MAIL_001' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진 방식 참여자의 이름도 색인과 같은 상한 · 같은 자르기 (INDEX_FROM_NAME_MAX_CHARS 코드 포인트 — N3)', async () => {
|
||||
const longName = `${'가'.repeat(INDEX_FROM_NAME_MAX_CHARS - 1)}😀😀 끝`;
|
||||
const { reader } = fakeClient([
|
||||
[
|
||||
mailboxes,
|
||||
['Email/query', { ids: ['x1'], position: 0, total: 1 }, 'q'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
email('x1', '2026-11-02T00:00:00Z', {
|
||||
threadId: 't2',
|
||||
from: [{ name: longName, email: 'long@example.com' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
['Thread/get', { list: [{ id: 't2', emailIds: ['x1'] }] }, 't'],
|
||||
...noHeld,
|
||||
],
|
||||
]);
|
||||
const page = await reader.list(ARA, { mailboxId: 'a', limit: 20 });
|
||||
const [participant] = page.items[0].participants;
|
||||
expect([...(participant.name ?? '')]).toHaveLength(
|
||||
INDEX_FROM_NAME_MAX_CHARS,
|
||||
);
|
||||
expect(participant.name).toBe(
|
||||
firstSender([{ name: longName, email: 'long@example.com' }]).name,
|
||||
);
|
||||
expect(participant.name?.endsWith('😀')).toBe(true);
|
||||
// 목록의 마지막 메일 요약(보낸 사람)은 자르지 않는다
|
||||
expect(page.items[0].latest.from?.name).toBe(longName);
|
||||
});
|
||||
|
||||
it('읽을 몫 나누기 — 작은 스레드는 다 주고 남는 몫을 큰 스레드끼리 똑같이 (모두 상한 안)', () => {
|
||||
expect(shareScan([3, 500, 1, 500], 600)).toEqual([3, 298, 1, 298]);
|
||||
expect(shareScan([2, 2], 2_000)).toEqual([2, 2]);
|
||||
expect(shareScan([500, 500, 500, 500, 500], 2_000)).toEqual([
|
||||
400, 400, 400, 400, 400,
|
||||
]);
|
||||
expect(shareScan([], 2_000)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
CONVERSATION_ENGINE_BUDGET_MS,
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_RESERVE_MS,
|
||||
CONVERSATION_ENGINE_SCAN_MAX,
|
||||
CONVERSATION_ENGINE_THREAD_SCAN_MAX,
|
||||
CONVERSATION_MESSAGE_IDS_MAX,
|
||||
CONVERSATION_PARTICIPANTS_MAX,
|
||||
} from '@/modules/mail-search/mail-search.constants';
|
||||
import { participantName } from '@/modules/mail-search/index-document';
|
||||
import { hasRealAttachment } from '@/modules/mail/html/inline-images';
|
||||
import type {
|
||||
ConversationListQuery,
|
||||
ConversationSummary,
|
||||
ConversationSummaryPage,
|
||||
MailAddress,
|
||||
MessagePage,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
JMAP_REQUEST_TIMEOUT_MS,
|
||||
JmapClient,
|
||||
type JmapCallOptions,
|
||||
} from './jmap.client';
|
||||
import { decodeCursor, encodeCursor, type CursorScope } from './jmap-cursor';
|
||||
import { expectResult, findResult } from './jmap-results';
|
||||
import {
|
||||
SUMMARY_BODY_PART_PROPERTIES,
|
||||
SUMMARY_PROPERTIES,
|
||||
toAddress,
|
||||
toSummary,
|
||||
} from './jmap.mapper';
|
||||
import type {
|
||||
JmapEmail,
|
||||
JmapGetResult,
|
||||
JmapInvocation,
|
||||
JmapMailbox,
|
||||
JmapQueryResult,
|
||||
} from './jmap.types';
|
||||
import { excludedMailboxIdsOf, INTERNAL_SEND_KEYWORD } from './mail-scope';
|
||||
import {
|
||||
heldSubmissionFilter,
|
||||
pendingSendOf,
|
||||
readHeldSendTimes,
|
||||
submissionCalls,
|
||||
warnIfHeldQueryFull,
|
||||
} from './pending-send';
|
||||
|
||||
/** 한 번에 읽는 대화의 메일 수 — 엔진의 한 번 처리 한도(maxObjectsInGet 500) 안 */
|
||||
const MEMBER_BATCH_SIZE = 500;
|
||||
/** 기다리는 발송을 묻는 호출 id 앞머리 (pending-send.ts) */
|
||||
const HELD_CALLS = 'p';
|
||||
|
||||
/** 같은 대화에서 거르기에 읽는 값싼 속성 — 요약 속성(미리 보기 · 첨부 부분 …)은 내보낼 메일만 읽는다 */
|
||||
const FILTER_PROPERTIES = ['id', 'mailboxIds', 'keywords', 'receivedAt'];
|
||||
/** 묶은 목록에서 스레드의 메일마다 읽는 속성 — 거르기 + 참여자(보낸 사람) · 첨부 판정(부분은 SUMMARY_BODY_PART_PROPERTIES 만) */
|
||||
const MEMBER_PROPERTIES = [...FILTER_PROPERTIES, 'from', 'attachments'];
|
||||
|
||||
/** 엔진의 스레드 — emailIds 는 받은 시각 오래된 차례(RFC 8621 §3.1 — 같은 시각이면 엔진이 정한 차례 그대로) */
|
||||
interface JmapThread {
|
||||
readonly id: string;
|
||||
readonly emailIds?: string[];
|
||||
}
|
||||
|
||||
/** 시간 때문에 끊긴 훑기로 볼 여유 — 제한 시간이 다 된 호출이 끝난 뒤 남은 시간이 마지막 요약 읽기 몫을 조금 넘을 수 있다 */
|
||||
const CUT_OFF_SLACK_MS = 100;
|
||||
|
||||
/** 엔진 호출 한 번의 제한 시간 — 기본 제한 시간과 `ms` 중 짧은 쪽 (1ms 이상) */
|
||||
function timeoutOf(ms: number): number {
|
||||
return Math.max(1, Math.min(JMAP_REQUEST_TIMEOUT_MS, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 요청의 시간 (CONVERSATION_ENGINE_BUDGET_MS — 메일함 처리의 BulkRun 과 같은 결).
|
||||
* - 훑는 호출(같은 대화의 찾기 · 거르기, 묶은 목록의 스레드 메일)은 기본 제한 시간과 "남은 시간 − CONVERSATION_ENGINE_RESERVE_MS" 중 짧은 쪽 —
|
||||
* 마지막 요약 읽기 몫을 늘 남긴다(3차 재확인 N1 — 3.5초 남아 시작한 느린 묶음이 요약 읽기를 1ms 로 만들지 않게)
|
||||
* - 마지막 읽기(같은 대화의 요약 · 묶은 목록의 첫 요청)만 남은 시간을 다 쓴다
|
||||
* - 남은 시간이 그 몫보다 적으면 더 훑지 않는다. 훑던 호출이 시간 때문에 끊겼으면(cutOff) 거기까지로 답한다
|
||||
* 시계는 Date.now 다 (시험은 spy 로 돌린다)
|
||||
*/
|
||||
class ReadBudget {
|
||||
private readonly deadline = Date.now() + CONVERSATION_ENGINE_BUDGET_MS;
|
||||
|
||||
canScan(): boolean {
|
||||
return this.left() > CONVERSATION_ENGINE_RESERVE_MS;
|
||||
}
|
||||
|
||||
scanOptions(options: JmapCallOptions = {}): JmapCallOptions {
|
||||
return {
|
||||
...options,
|
||||
timeoutMs: timeoutOf(this.left() - CONVERSATION_ENGINE_RESERVE_MS),
|
||||
};
|
||||
}
|
||||
|
||||
finalOptions(options: JmapCallOptions = {}): JmapCallOptions {
|
||||
return { ...options, timeoutMs: timeoutOf(this.left()) };
|
||||
}
|
||||
|
||||
/** 훑던 호출이 시간 때문에 끊겼나 — 엔진 장애(MAIL_001)이고 남은 시간이 마지막 요약 읽기 몫 남짓뿐이다 */
|
||||
cutOff(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof BusinessException &&
|
||||
error.code === 'MAIL_001' &&
|
||||
this.left() <= CONVERSATION_ENGINE_RESERVE_MS + CUT_OFF_SLACK_MS
|
||||
);
|
||||
}
|
||||
|
||||
private left(): number {
|
||||
return this.deadline - Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function chunks<T>(items: readonly T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
out.push(items.slice(index, index + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 대화에 드는 메일인가 — 발송 중인 내부 초안이 아니고, 빼는 메일함(휴지통 · 스팸메일함)에만 들어 있지 않다 (`keep` 이면 늘 든다) */
|
||||
function inConversation(
|
||||
email: JmapEmail,
|
||||
excluded: ReadonlySet<string>,
|
||||
keep: (email: JmapEmail) => boolean,
|
||||
): boolean {
|
||||
if (keep(email)) return true;
|
||||
if (email.keywords?.[INTERNAL_SEND_KEYWORD]) return false;
|
||||
const mailboxes = Object.keys(email.mailboxIds ?? {});
|
||||
return mailboxes.length === 0 || mailboxes.some((id) => !excluded.has(id));
|
||||
}
|
||||
|
||||
/** 보낸 사람들 — 주어진 차례(받은 차례), 주소마다 한 번, `max` 명까지 · 이름은 INDEX_FROM_NAME_MAX_CHARS 코드 포인트까지 */
|
||||
function participantsOf(
|
||||
emails: readonly JmapEmail[],
|
||||
max: number,
|
||||
): MailAddress[] {
|
||||
const seen = new Set<string>();
|
||||
const participants: MailAddress[] = [];
|
||||
for (const email of emails) {
|
||||
if (participants.length >= max) break;
|
||||
const from = toAddress(email.from?.[0]);
|
||||
if (!from) continue;
|
||||
const key = from.email.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
// 이름은 색인 방식의 참여자와 같은 상한 · 같은 자르기 (index-document.ts 의 participantName)
|
||||
participants.push({ email: from.email, name: participantName(from.name) });
|
||||
}
|
||||
return participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 요청에 읽을 몫 — 스레드마다 `needs[i]` 까지, 모두 `budget` 까지. 작은 스레드부터 다 주고 남는 몫을 큰 스레드끼리 똑같이 나눈다
|
||||
* (큰 스레드 하나가 쪽의 다른 줄을 비우지 않게)
|
||||
*/
|
||||
export function shareScan(needs: readonly number[], budget: number): number[] {
|
||||
const shares = needs.map(() => 0);
|
||||
const order = needs
|
||||
.map((need, index) => ({ need: Math.max(0, need), index }))
|
||||
.sort((a, b) => a.need - b.need);
|
||||
let left = Math.max(0, budget);
|
||||
order.forEach(({ need, index }, position) => {
|
||||
const give = Math.min(need, Math.floor(left / (order.length - position)));
|
||||
shares[index] = give;
|
||||
left -= give;
|
||||
});
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진의 스레드로 본 대화 (14단계 — 자체 색인을 다 만들기 전 · 첫 쪽 DB 장애의 대신, 설계 C3 · C4). 스레드는 엔진의 규칙(답장 관계 + 엔진이 정규화한 제목)이다.
|
||||
* 거르기는 값싼 속성만 읽고 요약 속성은 내보낼 메일만 읽는다. 상한 · 시간은 mail-search.constants.ts 의 CONVERSATION_ENGINE_* (3차 리뷰 H1).
|
||||
* - 같은 대화: 그 메일의 스레드(오래된 차례) — 스레드가 CONVERSATION_ENGINE_SCAN_MAX 통 이하면 모두 읽어 total 을 세고, 넘으면 커서 자리부터 쪽이 찰 때까지만
|
||||
* (한 요청 그 상한까지 · 시간 안 — 쪽이 limit 보다 적어도 이어 갈 자리가 있으면 hasNext, total 은 null). 쪽은 스레드 안 위치(anchor · position) 커서
|
||||
* - 묶은 목록: `Email/query` 의 collapseThreads(그 메일함 안 스레드마다 마지막 메일 — 요약 속성은 이것만) → 스레드마다 최신 CONVERSATION_ENGINE_THREAD_SCAN_MAX 통
|
||||
* (한 요청 모두 CONVERSATION_ENGINE_LIST_SCAN_MAX 통 — shareScan)으로 통 수 · 참여자 · 그 메일함 안 id · 읽음 · 붙잡힌 메일.
|
||||
* **상한에 걸린 줄**(읽지 못한 오래된 메일이 있는 스레드 — 몫 · 시간): 통 수는 읽은 메일 가운데 대화에 드는 것 + 읽지 못한 메일 수(거르지 않고 더한다),
|
||||
* 참여자 · messageIds · 읽음 · 중요 · 첨부 · pendingSendIds 는 읽은 메일(최신 쪽)로만 정한다
|
||||
* 커서는 jmap-cursor.ts 의 모양(1판)에 범위를 따로 둔다 — 목록 · 검색의 커서와 섞이지 않게
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapConversations {
|
||||
private readonly logger = new Logger(JmapConversations.name);
|
||||
|
||||
constructor(private readonly jmap: JmapClient) {}
|
||||
|
||||
async conversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage> {
|
||||
const scope: CursorScope = { conditions: ['conversation', emailId] };
|
||||
// 커서는 엔진에 묻기 전에 본다 — 다른 대화의 커서면 VAL_001
|
||||
const resume = page.cursor ? decodeCursor(page.cursor, scope) : null;
|
||||
const budget = new ReadBudget();
|
||||
const lookup = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: [emailId], properties: ['id', 'threadId'] },
|
||||
'e',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: 'e',
|
||||
name: 'Email/get',
|
||||
path: '/list/*/threadId',
|
||||
},
|
||||
},
|
||||
't',
|
||||
],
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
budget.scanOptions({ invalidRequestCode: 'RES_001' }),
|
||||
);
|
||||
const { list: found = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
lookup,
|
||||
'e',
|
||||
'Email/get',
|
||||
);
|
||||
if (found.length === 0) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'conversation-message-not-found' },
|
||||
});
|
||||
}
|
||||
const thread = expectResult<JmapGetResult<JmapThread>>(
|
||||
lookup,
|
||||
't',
|
||||
'Thread/get',
|
||||
).list?.[0];
|
||||
const excluded = new Set(
|
||||
excludedMailboxIdsOf(this.mailboxesOf(lookup, user)),
|
||||
);
|
||||
const order = thread?.emailIds?.length ? thread.emailIds : [emailId];
|
||||
|
||||
// 이어 갈 자리 — 스레드 안 위치. 마지막으로 본 메일이 그사이 빠졌으면 위치로
|
||||
let start = 0;
|
||||
if (resume) {
|
||||
const anchorAt = order.indexOf(resume.anchor);
|
||||
start =
|
||||
anchorAt >= 0 ? anchorAt + 1 : Math.min(resume.position, order.length);
|
||||
}
|
||||
// 상한 이하면 모두 읽어 센다(total), 넘으면 이어 갈 자리부터 쪽이 찰 때까지만
|
||||
const countAll = order.length <= CONVERSATION_ENGINE_SCAN_MAX;
|
||||
const scanEnd = countAll
|
||||
? order.length
|
||||
: Math.min(order.length, start + CONVERSATION_ENGINE_SCAN_MAX);
|
||||
const keep = (email: JmapEmail) => email.id === emailId;
|
||||
/** 대화에 드는 메일의 스레드 안 위치 */
|
||||
const kept: number[] = [];
|
||||
let scanned = countAll ? 0 : start;
|
||||
let shownCount = 0;
|
||||
while (scanned < scanEnd && (countAll || shownCount <= page.limit)) {
|
||||
if (!budget.canScan()) break;
|
||||
const batch = order.slice(
|
||||
scanned,
|
||||
Math.min(scanEnd, scanned + MEMBER_BATCH_SIZE),
|
||||
);
|
||||
let byId: Map<string, JmapEmail>;
|
||||
try {
|
||||
byId = await this.readEmails(user, batch, FILTER_PROPERTIES, budget);
|
||||
} catch (error) {
|
||||
// 시간 때문에 끊긴 묶음 — 읽은 데까지로 답한다(아래에서 다 읽어야 하는 작은 스레드 · 아무것도 못 읽음은 MAIL_001)
|
||||
if (budget.cutOff(error)) break;
|
||||
throw error;
|
||||
}
|
||||
batch.forEach((id, offset) => {
|
||||
const email = byId.get(id);
|
||||
if (!email || !inConversation(email, excluded, keep)) return;
|
||||
kept.push(scanned + offset);
|
||||
if (scanned + offset >= start) shownCount += 1;
|
||||
});
|
||||
scanned += batch.length;
|
||||
}
|
||||
const readAll = countAll && scanned >= order.length;
|
||||
if (countAll && !readAll) {
|
||||
// 작은 스레드를 시간 안에 다 읽지 못했다 — 엔진이 느리다
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'conversation-budget', scanned },
|
||||
});
|
||||
}
|
||||
if (!countAll && scanned === start && start < order.length) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'conversation-budget', scanned },
|
||||
});
|
||||
}
|
||||
|
||||
const candidates = kept.filter((index) => index >= start);
|
||||
const shown = candidates.slice(0, page.limit);
|
||||
let next: number | null = null;
|
||||
if (candidates.length > page.limit) {
|
||||
next = shown[shown.length - 1] + 1;
|
||||
} else if (scanned < order.length && !readAll) {
|
||||
next = scanned;
|
||||
}
|
||||
const items = await this.summaries(
|
||||
user,
|
||||
shown.map((index) => order[index]),
|
||||
budget,
|
||||
);
|
||||
return {
|
||||
items,
|
||||
hasNext: next !== null,
|
||||
nextCursor:
|
||||
next !== null
|
||||
? encodeCursor({ anchor: order[next - 1], position: next }, scope)
|
||||
: null,
|
||||
total: readAll ? kept.length : null,
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage> {
|
||||
const scope: CursorScope = {
|
||||
mailboxId: query.mailboxId,
|
||||
conditions: ['conversations'],
|
||||
};
|
||||
const resume = query.cursor ? decodeCursor(query.cursor, scope) : null;
|
||||
const budget = new ReadBudget();
|
||||
const now = Date.now();
|
||||
const ask = (start: { anchor: string } | { position: number }) =>
|
||||
this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: {
|
||||
inMailbox: query.mailboxId,
|
||||
notKeyword: INTERNAL_SEND_KEYWORD,
|
||||
},
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
collapseThreads: true,
|
||||
...('anchor' in start
|
||||
? { anchor: start.anchor, anchorOffset: 1 }
|
||||
: { position: start.position }),
|
||||
limit: query.limit,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' },
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
[
|
||||
'Thread/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: 'g',
|
||||
name: 'Email/get',
|
||||
path: '/list/*/threadId',
|
||||
},
|
||||
},
|
||||
't',
|
||||
],
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
],
|
||||
budget.finalOptions({ invalidRequestCode: 'RES_001' }),
|
||||
);
|
||||
let responses = await ask(
|
||||
resume ? { anchor: resume.anchor } : { position: 0 },
|
||||
);
|
||||
// 마지막으로 본 메일이 그사이 지워지거나 옮겨졌으면 위치로 이어 읽는다
|
||||
const first = findResult(responses, 'q', 'Email/query');
|
||||
if (resume && !first.ok && first.error.type === 'anchorNotFound') {
|
||||
responses = await ask({ position: resume.position });
|
||||
}
|
||||
|
||||
// 메일함 목록은 꼭 읽는다 — 엔진 장애(메서드 오류)는 503 MAIL_001, 없는 메일함은 404 RES_001 (3차 리뷰 M1)
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (!mailboxes.some((mailbox) => mailbox.id === query.mailboxId)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
const excluded = new Set(excludedMailboxIdsOf(mailboxes));
|
||||
const result = expectResult<JmapQueryResult>(responses, 'q', 'Email/query');
|
||||
const { list: latestEmails = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const { list: threads = [] } = expectResult<JmapGetResult<JmapThread>>(
|
||||
responses,
|
||||
't',
|
||||
'Thread/get',
|
||||
);
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
|
||||
const latestById = new Map(latestEmails.map((email) => [email.id, email]));
|
||||
const threadById = new Map(threads.map((thread) => [thread.id, thread]));
|
||||
const ids = result.ids ?? [];
|
||||
const rows = ids.flatMap((id) => {
|
||||
const latest = latestById.get(id);
|
||||
if (!latest) return [];
|
||||
const members = latest.threadId
|
||||
? threadById.get(latest.threadId)?.emailIds
|
||||
: undefined;
|
||||
return [{ latest, order: members?.length ? members : [latest.id] }];
|
||||
});
|
||||
// 스레드마다 최신 쪽의 몫 — 한 요청 상한 안에서 작은 스레드부터
|
||||
const shares = shareScan(
|
||||
rows.map((row) =>
|
||||
Math.min(row.order.length, CONVERSATION_ENGINE_THREAD_SCAN_MAX),
|
||||
),
|
||||
CONVERSATION_ENGINE_LIST_SCAN_MAX,
|
||||
);
|
||||
const windows = rows.map(
|
||||
(row, index) =>
|
||||
new Set(row.order.slice(row.order.length - shares[index])),
|
||||
);
|
||||
const toRead = [
|
||||
...new Set(windows.flatMap((window) => [...window])),
|
||||
].filter((id) => !latestById.has(id));
|
||||
const { emails: members, attempted } = await this.readMembers(
|
||||
user,
|
||||
toRead,
|
||||
budget,
|
||||
);
|
||||
|
||||
const items = rows.map(({ latest, order }, index): ConversationSummary => {
|
||||
const window = windows[index];
|
||||
/** 읽은 메일 — 몫 안에서 엔진에 물은 것(그사이 지운 것은 빠진다)과 마지막 메일 */
|
||||
const wasRead = (id: string) =>
|
||||
id === latest.id || (window.has(id) && attempted.has(id));
|
||||
const readEmails = order.flatMap((id) => {
|
||||
if (!wasRead(id)) return [];
|
||||
const email = id === latest.id ? latest : members.get(id);
|
||||
return email ? [email] : [];
|
||||
});
|
||||
if (!order.includes(latest.id)) readEmails.push(latest);
|
||||
const notRead = order.filter((id) => !wasRead(id)).length;
|
||||
const inMailbox = (email: JmapEmail) =>
|
||||
Boolean(email.mailboxIds?.[query.mailboxId]) &&
|
||||
!email.keywords?.[INTERNAL_SEND_KEYWORD];
|
||||
const kept = readEmails.filter((email) =>
|
||||
inConversation(email, excluded, inMailbox),
|
||||
);
|
||||
const here = kept.filter(inMailbox);
|
||||
const messageIds = (
|
||||
here.length > 0 ? here.map((email) => email.id).reverse() : [latest.id]
|
||||
).slice(0, CONVERSATION_MESSAGE_IDS_MAX);
|
||||
const shownHere = here.length > 0 ? here : [latest];
|
||||
return {
|
||||
id: latest.threadId ?? latest.id,
|
||||
latest: toSummary(latest, pendingSendOf(latest, held)),
|
||||
participants: participantsOf(kept, CONVERSATION_PARTICIPANTS_MAX),
|
||||
messageCount: Math.max(kept.length + notRead, 1),
|
||||
messageIds,
|
||||
pendingSendIds: messageIds.filter((id) => held.has(id)),
|
||||
isRead: shownHere.every((email) => email.keywords?.$seen === true),
|
||||
isFlagged: shownHere.some((email) => email.keywords?.$flagged === true),
|
||||
hasAttachment: shownHere.some((email) =>
|
||||
hasRealAttachment(email.attachments ?? []),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const start = result.position ?? 0;
|
||||
const total = typeof result.total === 'number' ? result.total : null;
|
||||
const hasNext =
|
||||
total !== null ? start + ids.length < total : ids.length === query.limit;
|
||||
const lastId = ids.at(-1);
|
||||
return {
|
||||
items,
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && lastId
|
||||
? encodeCursor(
|
||||
{ anchor: lastId, position: start + ids.length },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** 메일함 목록(역할) — 같은 대화에서만: 읽지 못하면 빈 목록(빼는 메일함 없이 — 대화가 넓어질 뿐, 경고를 남긴다) */
|
||||
private mailboxesOf(
|
||||
responses: JmapInvocation[],
|
||||
user: string,
|
||||
): JmapMailbox[] {
|
||||
const found = findResult(responses, 'm', 'Mailbox/get');
|
||||
if (!found.ok) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 대화의 메일함 역할을 읽지 못해 휴지통 · 스팸메일함을 빼지 못했습니다: user=${maskEmail(user)}, type=${found.error.type}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return (found.args as JmapGetResult<JmapMailbox>).list ?? [];
|
||||
}
|
||||
|
||||
/** 메일 여러 통을 값싼 속성으로 — 훑는 호출(마지막 요약 읽기 몫을 남긴 제한 시간), 엔진 장애는 그대로 던진다 */
|
||||
private async readEmails(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
properties: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<Map<string, JmapEmail>> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [...ids],
|
||||
properties: [...properties],
|
||||
...(properties.includes('attachments')
|
||||
? { bodyProperties: SUMMARY_BODY_PART_PROPERTIES }
|
||||
: {}),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
budget.scanOptions(),
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
return new Map(list.map((email) => [email.id, email]));
|
||||
}
|
||||
|
||||
/** 묶은 목록의 스레드 메일 — 500통씩, 시간이 남은 동안만(물은 id 를 함께 준다 — 묻지 못한 것은 상한에 걸린 줄의 읽지 못한 메일이다) */
|
||||
private async readMembers(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<{ emails: Map<string, JmapEmail>; attempted: Set<string> }> {
|
||||
const emails = new Map<string, JmapEmail>();
|
||||
const attempted = new Set<string>();
|
||||
for (const batch of chunks(ids, MEMBER_BATCH_SIZE)) {
|
||||
if (!budget.canScan()) break;
|
||||
let read: Map<string, JmapEmail>;
|
||||
try {
|
||||
read = await this.readEmails(user, batch, MEMBER_PROPERTIES, budget);
|
||||
} catch (error) {
|
||||
// 시간 때문에 끊긴 묶음 — 그 메일은 읽지 못한 것으로(어림 줄 — 503 이 아니다)
|
||||
if (budget.cutOff(error)) break;
|
||||
throw error;
|
||||
}
|
||||
for (const id of batch) attempted.add(id);
|
||||
for (const [id, email] of read) emails.set(id, email);
|
||||
}
|
||||
return { emails, attempted };
|
||||
}
|
||||
|
||||
/** 내보낼 메일의 요약 — 마지막 읽기(남은 시간을 다 쓴다). 요약 속성은 이 메일들만, 같은 요청에서 기다리는 발송도 (주어진 차례 · 없는 메일은 빠진다) */
|
||||
private async summaries(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
budget: ReadBudget,
|
||||
): Promise<MessagePage['items']> {
|
||||
if (ids.length === 0) return [];
|
||||
const now = Date.now();
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [...ids],
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
],
|
||||
budget.finalOptions(),
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
return ids.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './jmap.client';
|
||||
import type { JmapClient, JmapSession } from './jmap.client';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { JmapConversations } from './jmap-conversations';
|
||||
import { JmapFilterRunner } from './jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
|
||||
@@ -196,6 +197,7 @@ function fakeClient(
|
||||
overrides.settings ?? settings,
|
||||
new JmapFilterScriptStore(client as unknown as JmapClient),
|
||||
new JmapFilterRunner(client as unknown as JmapClient),
|
||||
new JmapConversations(client as unknown as JmapClient),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import type {
|
||||
import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
ConversationListQuery,
|
||||
ConversationSummaryPage,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
@@ -66,6 +68,7 @@ import type {
|
||||
SendHold,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
SummariesWithHeld,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from '@/modules/mail/mail.types';
|
||||
@@ -95,6 +98,7 @@ import {
|
||||
submissionEnvelope,
|
||||
} from './envelope';
|
||||
import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { JmapConversations } from './jmap-conversations';
|
||||
import { JmapFilterRunner } from './jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import {
|
||||
@@ -153,14 +157,13 @@ import type {
|
||||
JmapSetResult,
|
||||
} from './jmap.types';
|
||||
import { NEW_MAIL_PROPERTIES, selectNewMail } from './new-mail';
|
||||
import { INTERNAL_SEND_KEYWORD, excludedMailboxIdsOf } from './mail-scope';
|
||||
import {
|
||||
CANCELED_SEND_KEYWORD,
|
||||
HELD_SUBMISSIONS_MAX,
|
||||
SCHEDULED_SEND_KEYWORD,
|
||||
heldAmong,
|
||||
heldEmailsFilter,
|
||||
heldOverLimit,
|
||||
heldQueryFull,
|
||||
heldSendTimes,
|
||||
heldSubmissionFilter,
|
||||
holdParameters,
|
||||
@@ -169,6 +172,7 @@ import {
|
||||
readHeldEmails,
|
||||
readHeldSendTimes,
|
||||
submissionCalls,
|
||||
warnIfHeldQueryFull,
|
||||
submittedPendingSend,
|
||||
type HeldEmails,
|
||||
type HeldLookup,
|
||||
@@ -192,15 +196,6 @@ const BLOB_CAPABILITY = 'urn:ietf:params:jmap:blob';
|
||||
const FINGERPRINT_HEADER = 'X-Ara-Payload-Hash';
|
||||
const FINGERPRINT_PROPERTY = `header:${FINGERPRINT_HEADER}:asText`;
|
||||
|
||||
/**
|
||||
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
|
||||
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록은 이 키워드가 있는 메일을 보이지 않는다 —
|
||||
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
|
||||
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
|
||||
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
|
||||
*/
|
||||
const INTERNAL_SEND_KEYWORD = '$ara-sending';
|
||||
|
||||
/**
|
||||
* 임시 저장본이 답장하는 메일 id — 쓰기 화면이 이어 쓸 때 inReplyTo 로 돌려준다 (계약 2절 "임시 저장").
|
||||
* 초안 자체는 보내지 않으므로(보낼 때는 새 메일을 만든다) 이 헤더가 받는 사람에게 가지 않는다.
|
||||
@@ -283,9 +278,6 @@ const BULK_BATCH_SIZE = 500;
|
||||
const SOURCE_TYPE = 'message/rfc822';
|
||||
const SOURCE_DOWNLOAD_NAME = 'message.eml';
|
||||
|
||||
/** 메일함을 정하지 않은 검색에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
|
||||
const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set(['trash', 'junk']);
|
||||
|
||||
/** 조건 검색의 글 칸 → 엔진 필터 칸 (받는 사람은 참조도 — 둘 가운데 하나) — 14단계, 색인 준비 전의 엔진 방식 */
|
||||
const CONDITION_PROPERTIES: readonly (readonly [
|
||||
keyof Pick<SearchConditions, 'from' | 'to' | 'subject' | 'body'>,
|
||||
@@ -872,6 +864,8 @@ export class JmapMailProvider implements MailProvider {
|
||||
private readonly filterScripts: JmapFilterScriptStore,
|
||||
/** 이미 받은 메일에 필터 적용 (jmap-filter-run.ts — 7단계) */
|
||||
private readonly filterRuns: JmapFilterRunner,
|
||||
/** 엔진의 스레드로 본 대화 (jmap-conversations.ts — 14단계, 자체 색인을 다 만들기 전) */
|
||||
private readonly conversations: JmapConversations,
|
||||
) {}
|
||||
|
||||
verifyCredentials(email: string, password: string): Promise<CredentialCheck> {
|
||||
@@ -1562,7 +1556,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
const start = result.position ?? 0;
|
||||
const total = typeof result.total === 'number' ? result.total : null;
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
this.warnIfHeldQueryFull(responses, user);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
|
||||
// Email/get 의 결과 순서는 보장되지 않으므로 query 순서(받은 시각 내림차순)로 다시 놓는다
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
@@ -1628,15 +1622,65 @@ export class JmapMailProvider implements MailProvider {
|
||||
}
|
||||
return {
|
||||
mailboxId: null,
|
||||
excludedMailboxIds: mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id),
|
||||
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
|
||||
excludedKeywords: [INTERNAL_SEND_KEYWORD],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다(대화의 통 수 · 같은 대화).
|
||||
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001 — 엔진이 해석하지 못하는 id 도). 엔진 요청 한 번
|
||||
*/
|
||||
async conversationScope(
|
||||
user: string,
|
||||
mailboxId?: string,
|
||||
): Promise<SearchScope> {
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ accountId, ids: null, properties: ['id', 'role'] },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'MAIL_001' },
|
||||
);
|
||||
const { list: mailboxes = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
if (
|
||||
mailboxId !== undefined &&
|
||||
!mailboxes.some((mailbox) => mailbox.id === mailboxId)
|
||||
) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'mailbox-not-found' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
mailboxId: mailboxId ?? null,
|
||||
excludedMailboxIds: excludedMailboxIdsOf(mailboxes),
|
||||
excludedKeywords: [INTERNAL_SEND_KEYWORD],
|
||||
};
|
||||
}
|
||||
|
||||
getConversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage> {
|
||||
return this.conversations.conversation(user, emailId, page);
|
||||
}
|
||||
|
||||
listConversations(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage> {
|
||||
return this.conversations.list(user, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 여러 통 (14단계 — 색인으로 찾은 쪽) — 목록과 같은 속성 · 기다리는 발송. `ids` 차례대로, 없는 메일은 빠진다.
|
||||
* 엔진이 해석하지 못하는 id 는 받지 않는다(색인의 id 는 엔진이 준 것이다 — 형식이 틀리면 건너뛴다)
|
||||
@@ -1645,34 +1689,52 @@ export class JmapMailProvider implements MailProvider {
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<MessageSummary[]> {
|
||||
if (!ids.some((id) => MAIL_ID_PATTERN.test(id))) return [];
|
||||
return (await this.getSummariesWithHeld(user, ids)).summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식) — 같은 요청에서 계정의 기다리는 제출을 한 번 읽는다(보이기 경로 —
|
||||
* readHeldSendTimes, 읽지 못하면 빈 표). 요약이 없어도(ids 가 비거나 모두 형식이 틀림) 기다리는 제출은 읽는다
|
||||
*/
|
||||
async getSummariesWithHeld(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<SummariesWithHeld> {
|
||||
const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id));
|
||||
if (valid.length === 0) return [];
|
||||
const now = Date.now();
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: valid,
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
...(valid.length > 0
|
||||
? ([
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
accountId,
|
||||
ids: valid,
|
||||
properties: SUMMARY_PROPERTIES,
|
||||
bodyProperties: SUMMARY_BODY_PART_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
] satisfies JmapInvocation[])
|
||||
: []),
|
||||
...submissionCalls(accountId, heldSubmissionFilter(now), HELD_CALLS),
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
const list =
|
||||
valid.length > 0
|
||||
? (expectResult<JmapGetResult<JmapEmail>>(responses, 'g', 'Email/get')
|
||||
.list ?? [])
|
||||
: [];
|
||||
const held = readHeldSendTimes(responses, HELD_CALLS, now);
|
||||
this.warnIfHeldQueryFull(responses, user);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const byId = new Map(list.map((email) => [email.id, email]));
|
||||
return valid.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
|
||||
});
|
||||
return {
|
||||
summaries: valid.flatMap((id) => {
|
||||
const email = byId.get(id);
|
||||
return email ? [toSummary(email, pendingSendOf(email, held))] : [];
|
||||
}),
|
||||
heldIds: new Set(held.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2764,7 +2826,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
'g',
|
||||
'Email/get',
|
||||
);
|
||||
this.warnIfHeldQueryFull(responses, user);
|
||||
warnIfHeldQueryFull(this.logger, responses, HELD_CALLS, user);
|
||||
const times = heldSendTimes(submissions, now);
|
||||
return emails
|
||||
.flatMap((email) => {
|
||||
@@ -3774,11 +3836,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const excluded = mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id);
|
||||
const excluded = excludedMailboxIdsOf(mailboxes);
|
||||
return excluded.length > 0 ? { inMailboxOtherThan: excluded } : {};
|
||||
}
|
||||
|
||||
@@ -4037,17 +4095,6 @@ export class JmapMailProvider implements MailProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이기 경로(목록 · 예약 목록)의 계정 전체 제출 조회가 다 주지 못했다(상한 500 · 엔진이 줄인 limit · total 보다 적음 — pending-send.ts 의 heldQueryFull)
|
||||
* — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다 (경고만). 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX)
|
||||
*/
|
||||
private warnIfHeldQueryFull(responses: JmapInvocation[], user: string): void {
|
||||
if (!heldQueryFull(responses, HELD_CALLS)) return;
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 이 메일로 만든 제출(EmailSubmission) — 상태 · 보낼 시각 (메일 id 조건은 엔진의 색인을 탄다) */
|
||||
private async submissionsOf(
|
||||
user: string,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { JmapMailbox } from './jmap.types';
|
||||
|
||||
/**
|
||||
* 발송 중인 내부 초안 표시 (JMAP 키워드) — 발송 경로가 임시보관함에 잠깐 만드는 초안에 붙이고, 제출에 성공하면
|
||||
* 보낸편지함으로 옮기면서 뗀다(onSuccessUpdateEmail). 메일 목록 · 검색 · 대화는 이 키워드가 있는 메일을 보이지 않는다 —
|
||||
* 사용자의 임시 저장본과 섞이지 않게 (계약 2절 "임시 저장").
|
||||
* 헤더(X-Ara-Payload-Hash)로 거르지 않는 이유: Stalwart 는 헤더가 있는지로 거르는 필터(`header`)를 지원하지 않는다
|
||||
* (2026-10-27 개발 엔진으로 확인 — 아무것도 맞지 않는다). 이 표시 전에 생긴 내부 초안은 지문 헤더로 알아본다(isUserDraft).
|
||||
*/
|
||||
export const INTERNAL_SEND_KEYWORD = '$ara-sending';
|
||||
|
||||
/** 메일함을 정하지 않은 검색 · 같은 대화 · 대화의 통 수에서 빼는 메일함 역할 — 휴지통 · 스팸메일함 (계약 2절 "검색") */
|
||||
export const SEARCH_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'trash',
|
||||
'junk',
|
||||
]);
|
||||
|
||||
/** 빼는 역할(휴지통 · 스팸메일함)의 메일함 id */
|
||||
export function excludedMailboxIdsOf(
|
||||
mailboxes: readonly Pick<JmapMailbox, 'id' | 'role'>[],
|
||||
): string[] {
|
||||
return mailboxes
|
||||
.filter((mailbox) =>
|
||||
SEARCH_EXCLUDED_ROLES.has(mailbox.role?.toLowerCase() ?? ''),
|
||||
)
|
||||
.map((mailbox) => mailbox.id);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { PendingSend, SendHold } from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { toJmapUtcDate, toUtcIso } from './jmap.mapper';
|
||||
import { expectResult, findResult } from './jmap-results';
|
||||
import type {
|
||||
@@ -212,6 +216,22 @@ export function heldQueryFull(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이기 경로(목록 · 예약 목록 · 대화)의 계정 전체 제출 조회가 다 주지 못했으면(heldQueryFull) 경고만 남긴다 — 보낼 시각이 늦은 기다리는 발송은 표시가 빠질 수 있다.
|
||||
* 취소한 예약도 보낼 시각이 7일 안이면 조회에 든다 (HELD_SUBMISSIONS_MAX). 로그는 코드 · 추적 ID · 가린 주소만
|
||||
*/
|
||||
export function warnIfHeldQueryFull(
|
||||
logger: Logger,
|
||||
responses: JmapInvocation[],
|
||||
prefix: string,
|
||||
user: string,
|
||||
): void {
|
||||
if (!heldQueryFull(responses, prefix)) return;
|
||||
logger.warn(
|
||||
`[MAIL_001]${traceTag()} 보낼 시각이 지금 뒤인 제출을 엔진이 다 주지 않아(한 번에 ${HELD_SUBMISSIONS_MAX}개까지 · 엔진의 한도) 보낼 시각이 이른 것부터 받은 만큼만 봅니다 — 늦은 기다리는 발송은 표시가 빠질 수 있습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 붙잡힌 메일 — 막기 경로의 판정 (readHeldEmails · heldAmong) */
|
||||
export interface HeldEmails {
|
||||
/** 붙잡힌 제출이 있는 메일 id (보낼 시각을 읽지 못한 기다리는 제출도 — holdsForGuard) */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
ConversationListQuery,
|
||||
ConversationSummaryPage,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
@@ -41,6 +43,7 @@ import type {
|
||||
SavedDraft,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
SummariesWithHeld,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
@@ -261,11 +264,45 @@ export interface MailProvider {
|
||||
*/
|
||||
searchScope(user: string, mailboxId?: string): Promise<SearchScope>;
|
||||
|
||||
/**
|
||||
* 대화의 범위 (14단계 — 색인 방식의 같은 대화 · 묶은 목록) — 늘 휴지통 · 스팸메일함을 빼는 메일함으로 준다.
|
||||
* `mailboxId` 를 주면 그 메일함이 있는지 본다(없으면 RES_001)
|
||||
*/
|
||||
conversationScope(user: string, mailboxId?: string): Promise<SearchScope>;
|
||||
|
||||
/**
|
||||
* 요약 여러 통 — `ids` 차례대로, 없는 메일(그사이 지움)은 빠진다. 기다리는 발송(pendingSend)도 목록과 같이 (14단계 — 색인으로 찾은 쪽을 보일 때)
|
||||
*/
|
||||
getSummaries(user: string, ids: readonly string[]): Promise<MessageSummary[]>;
|
||||
|
||||
/**
|
||||
* 요약 여러 통 + 지금 붙잡힌 메일 id (14단계 3차 — 묶은 목록의 색인 방식: 줄의 messageIds 가운데 붙잡힌 메일 pendingSendIds).
|
||||
* getSummaries 와 같은 요청 하나 — 기다리는 제출은 한 번만 읽는다
|
||||
*/
|
||||
getSummariesWithHeld(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<SummariesWithHeld>;
|
||||
|
||||
/**
|
||||
* 같은 대화 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 오래된 차례, 휴지통 · 스팸메일함에만 든 메일과 발송 중인 내부 초안은 빼되
|
||||
* 지금 보는 메일(`emailId`)은 넣는다. 그 메일이 없으면 RES_001. 커서는 이 메일의 대화에서만 쓴다(다르면 VAL_001)
|
||||
*/
|
||||
getConversation(
|
||||
user: string,
|
||||
emailId: string,
|
||||
page: { readonly limit: number; readonly cursor?: string },
|
||||
): Promise<MessagePage>;
|
||||
|
||||
/**
|
||||
* 대화로 묶은 목록 — 엔진의 스레드로 (14단계 — 색인을 다 만들기 전). 그 메일함 안 마지막 받은 시각 최신부터. 없는 메일함은 RES_001.
|
||||
* 커서는 그 메일함의 묶은 목록에서만 쓴다(다르면 VAL_001)
|
||||
*/
|
||||
listConversations(
|
||||
user: string,
|
||||
query: ConversationListQuery,
|
||||
): Promise<ConversationSummaryPage>;
|
||||
|
||||
/**
|
||||
* 조회만 한다 — 읽음 처리하지 않는다. 기다리는 발송(pendingSend — 9단계)과 스팸메일함에 들었는지(isInJunkMailbox)를 함께 준다
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,11 @@ function fakeProvider(): ProviderMocks {
|
||||
watchMail: mockOf<'watchMail'>(),
|
||||
listMessages: mockOf<'listMessages'>(),
|
||||
searchScope: mockOf<'searchScope'>(),
|
||||
conversationScope: mockOf<'conversationScope'>(),
|
||||
getSummaries: mockOf<'getSummaries'>(),
|
||||
getSummariesWithHeld: mockOf<'getSummariesWithHeld'>(),
|
||||
getConversation: mockOf<'getConversation'>(),
|
||||
listConversations: mockOf<'listConversations'>(),
|
||||
getMessage: mockOf<'getMessage'>(),
|
||||
updateMessage: mockOf<'updateMessage'>(),
|
||||
updateMessages: mockOf<'updateMessages'>(),
|
||||
@@ -283,9 +287,11 @@ interface NinthStepMocks {
|
||||
function fakeNinthStep(): NinthStepMocks {
|
||||
return {
|
||||
userSettings: {
|
||||
get: jest
|
||||
.fn<Promise<MailUserSettings>, [string]>()
|
||||
.mockResolvedValue({ undoSendSeconds: 0, externalImages: 'ask' }),
|
||||
get: jest.fn<Promise<MailUserSettings>, [string]>().mockResolvedValue({
|
||||
undoSendSeconds: 0,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
}),
|
||||
},
|
||||
contacts: {
|
||||
hasEmail: jest
|
||||
@@ -2816,6 +2822,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 20,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto());
|
||||
expect(sentMessage()?.hold).toEqual({ kind: 'undo', seconds: 20 });
|
||||
@@ -2826,6 +2833,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 0,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto());
|
||||
expect(sentMessage()).not.toHaveProperty('hold');
|
||||
@@ -2851,6 +2859,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 10,
|
||||
externalImages: 'ask',
|
||||
groupByConversation: false,
|
||||
});
|
||||
await service.sendMessage(user, sendDto(), IDEMPOTENCY_KEY);
|
||||
const first = sentMessage();
|
||||
@@ -2933,6 +2942,7 @@ describe('MailService', () => {
|
||||
ninth.userSettings.get.mockResolvedValue({
|
||||
undoSendSeconds: 10,
|
||||
externalImages,
|
||||
groupByConversation: false,
|
||||
});
|
||||
/** 본문의 중계 주소 → 표를 푼 원래 주소 */
|
||||
const proxied = (html: string | null) =>
|
||||
|
||||
@@ -216,6 +216,55 @@ export interface MessagePage {
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 묶은 목록의 대화 하나 (14단계 — GET /api/conversations). 고르기 · 읽음 · 중요 · 이동 · 삭제는 `messageIds`(그 메일함 안)에 여러 통 API 로 한다
|
||||
*/
|
||||
export interface ConversationSummary {
|
||||
/** 대화 — 불투명 (색인이면 대화 키, 엔진이면 스레드 id) */
|
||||
readonly id: string;
|
||||
/** 그 메일함 안 마지막 메일 */
|
||||
readonly latest: MessageSummary;
|
||||
/**
|
||||
* 보낸 사람들 — 받은 차례, 주소마다 한 번, CONVERSATION_PARTICIPANTS_MAX 명까지 (대화 전체 — 엔진 방식에서 스레드가 상한을 넘으면 읽은 최신 메일에서만)
|
||||
*/
|
||||
readonly participants: MailAddress[];
|
||||
/**
|
||||
* 대화 전체의 메일 수 — 휴지통 · 스팸메일함에만 든 메일은 빼고(그 메일함의 메일은 넣는다). 엔진 방식에서 상한 때문에 읽지 못한 오래된 메일은 거르지 않고 더한다
|
||||
*/
|
||||
readonly messageCount: number;
|
||||
/** 그 메일함 안 그 대화의 메일 — 최신부터 CONVERSATION_MESSAGE_IDS_MAX 통까지 (엔진 방식에서 상한을 넘은 스레드는 읽은 최신 메일에서만) */
|
||||
readonly messageIds: string[];
|
||||
/** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약을 기다리는 — pendingSend 가 있는 메일) — 없으면 빈 배열. 화면은 여러 통 처리에서 뺀다 */
|
||||
readonly pendingSendIds: string[];
|
||||
/** 그 메일함 안 메일을 모두 읽었다 */
|
||||
readonly isRead: boolean;
|
||||
/** 그 메일함 안 메일 가운데 하나라도 중요 표시 */
|
||||
readonly isFlagged: boolean;
|
||||
/** 그 메일함 안 메일 가운데 하나라도 첨부가 있다 (목록의 첨부 표시와 같은 판정) */
|
||||
readonly hasAttachment: boolean;
|
||||
}
|
||||
|
||||
/** 요약 여러 통과 계정의 붙잡힌 메일 (14단계 3차 — 묶은 목록의 색인 방식, 기다리는 제출은 한 번만 읽는다) */
|
||||
export interface SummariesWithHeld {
|
||||
readonly summaries: MessageSummary[];
|
||||
/** 지금 붙잡힌 메일 id — 보이기 경로의 판정(보낼 시각이 아직 · pendingSend 와 같다), 계정 전체에서 보낼 시각이 이른 것부터 HELD_SUBMISSIONS_MAX 개까지 */
|
||||
readonly heldIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface ConversationSummaryPage {
|
||||
readonly items: ConversationSummary[];
|
||||
readonly hasNext: boolean;
|
||||
readonly nextCursor: string | null;
|
||||
readonly total: number | null;
|
||||
}
|
||||
|
||||
/** 묶은 목록 쿼리 (엔진 방식) */
|
||||
export interface ConversationListQuery {
|
||||
readonly mailboxId: string;
|
||||
readonly limit: number;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export interface MessageUpdate {
|
||||
readonly isRead?: boolean;
|
||||
readonly isFlagged?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user