export type IconName = keyof typeof ICONS
diff --git a/frontend/src/shared/constants/mail-conversation.ts b/frontend/src/shared/constants/mail-conversation.ts
index d48d192..6085855 100644
--- a/frontend/src/shared/constants/mail-conversation.ts
+++ b/frontend/src/shared/constants/mail-conversation.ts
@@ -1,4 +1,5 @@
// 같은 대화 — 읽기 화면의 "같은 대화" 칸 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "읽기 화면 — 같은 대화 (Thread14)")
+// 와 목록의 "대화로 묶어 보기" (같은 README › "목록 — 대화로 묶어 보기 (ListThreads14)" — 아래 MAIL_CONVERSATION_LIST_TEXT)
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
/** 한 번에 받는 수 — 계약의 처음 값이자 최대(100). 대화는 대개 한 쪽이다 */
@@ -52,3 +53,25 @@ export const MAIL_CONVERSATION_TEXT = {
retry: '다시 시도',
traceCode: (code: string) => `문의 코드 ${code}`,
} as const
+
+// ── 목록 — 대화로 묶어 보기 (디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)") ─────────
+
+/** 목록 머리 · 대화 줄 · 여러 통 처리의 글 (디자인 원본 ListThreads14 — 화면 문구는 README 「화면 문구 — 사용자에게 말하듯」) */
+export const MAIL_CONVERSATION_LIST_TEXT = {
+ /** 목록 머리 오른쪽의 눌림 단추 (aria-pressed) — 분할 보기에서는 아이콘만이라 title 도 이 글이다 */
+ toggle: '대화로 묶기',
+ /** 목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (안 읽은 메일이 없으면 뒤를 뺀다) */
+ conversationCount: (count: string) => `대화 ${count}개`,
+ messageCount: (count: string) => `메일 ${count}통`,
+ unreadCount: (count: string) => `안 읽은 메일 ${count}통`,
+ /** 대화 줄의 통 수 — 회색 글 (한 통이면 없다) */
+ rowCount: (count: string) => `${count}통`,
+ /** 여러 통 처리 도구 줄의 머리 — 고른 것은 대화다 */
+ selected: (count: string) => `대화 ${count}개 선택됨`,
+ /** 표 머리의 고르기 상자 */
+ selectAll: '불러온 대화 모두 고르기',
+ /** 목록 보기 표의 caption — 메일 목록은 "받은편지함 메일 목록" */
+ tableCaption: (mailbox: string) => `${mailbox} 대화 목록`,
+ /** 줄의 메일이 모두 보내기를 기다리는 메일일 때 별(중요 표시)의 까닭 — 고르기 칸은 메일 줄과 같은 글(SEND_LATER_TEXT.heldNotSelectable) */
+ heldFlagNote: '보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
+} as const
diff --git a/frontend/src/shared/constants/mail-settings.ts b/frontend/src/shared/constants/mail-settings.ts
index 14942eb..b61d8c0 100644
--- a/frontend/src/shared/constants/mail-settings.ts
+++ b/frontend/src/shared/constants/mail-settings.ts
@@ -8,6 +8,9 @@ export const UNDO_SEND_OPTIONS: readonly UndoSendSeconds[] = [0, 5, 10, 20, 30]
/** 외부 이미지 — 고르는 차례 그대로 */
export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask', 'trusted', 'always']
+/** 대화로 묶어 보기 (14단계) — 고르는 차례 그대로 (끔 · 켬 — 보내기 취소처럼 끔이 앞) */
+export const GROUP_BY_CONVERSATION_OPTIONS: readonly boolean[] = [false, true]
+
export const MAIL_SETTINGS_TEXT = {
title: '보내기 · 읽기',
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
@@ -31,4 +34,11 @@ export const MAIL_SETTINGS_TEXT = {
} satisfies Record
,
imagesNote:
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
+
+ /** 대화로 묶어 보기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다) */
+ groupTitle: '대화로 묶어 보기',
+ groupOff: '끔',
+ groupOn: '켬',
+ groupHint:
+ '답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
} as const
diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts
index e53735b..8957f05 100644
--- a/frontend/src/shared/constants/mail.ts
+++ b/frontend/src/shared/constants/mail.ts
@@ -240,6 +240,19 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
+
+/**
+ * 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)이 쓴다.
+ * 메일 줄(MessageListItem · MessageTableRow)도 같은 글이다 — 고칠 때는 함께
+ */
+export const MAIL_LIST_ROW_TEXT = {
+ /** 안 읽은 줄의 이름 자리 앞 (화면에는 보이지 않는다) */
+ unreadPrefix: '안 읽음, ',
+ /** 별 단추 */
+ flag: '중요 표시',
+ /** 첨부 클립 */
+ attachment: '첨부 있음',
+} as const
/** 보낸 사람이 없는 메일 — 목록 · 읽기 화면 · 같은 대화 칸의 이름 자리 */
export const MAIL_NO_SENDER = '(보낸 사람 없음)'
diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts
index 00cffca..c7f20e7 100644
--- a/frontend/src/shared/types/mail.ts
+++ b/frontend/src/shared/types/mail.ts
@@ -216,6 +216,44 @@ export interface ConversationPage extends PaginatedResult {
basis: ConversationBasis | null
}
+/**
+ * GET /conversations 의 한 줄 — 그 메일함 안의 대화 하나 (14단계 — 계약 2절 "대화로 묶어 보기").
+ * 고르기 · 읽음 · 중요 · 이동 · 삭제는 messageIds 에서 pendingSendIds 를 뺀 메일에 한다 (붙잡힌 메일은 취소로만 보낸편지함을 떠난다)
+ */
+export interface ConversationSummary {
+ /** 대화 id — 불투명(기준마다 다르다). 한 목록 안에서 줄을 가리는 데만 쓴다 */
+ id: string
+ /** 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · 붙잡은 발송, 누르면 연다 */
+ latest: MessageSummary
+ /** 대화 전체의 보낸 사람 — 받은 차례, 주소마다 한 번, 10명까지 (화면은 내 주소를 "나"로) */
+ participants: Address[]
+ /** 대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 뺀다. 엔진 방식에서 상한에 걸린 줄은 어림이다 */
+ messageCount: number
+ /** 그 메일함 안의 그 대화 메일 — 최신부터 500통까지 (useMail 이 마지막 메일을 늘 넣어 둔다) */
+ messageIds: string[]
+ /** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약) — 없으면 빈 배열 */
+ pendingSendIds: string[]
+ /** 그 메일함 안의 메일을 모두 읽었다 */
+ isRead: boolean
+ /** 그 메일함 안에 중요 표시한 메일이 하나라도 있다 */
+ isFlagged: boolean
+ /** 그 메일함 안에 첨부가 있는 메일이 하나라도 있다 */
+ hasAttachment: boolean
+}
+
+/** GET /conversations 쿼리 — 검색 쿼리(q · 조건)는 받지 않는다 */
+export interface ConversationListQuery extends CursorPaginationQuery {
+ mailboxId: string
+}
+
+/**
+ * GET /conversations 응답 — 그 메일함 안 마지막 받은 시각 최신부터. total 은 index 면 첫 쪽에서만(그 메일함의 대화 수), engine 이면 쪽마다.
+ * 모양이 틀린 줄은 뺀다 (useMail().listConversations)
+ */
+export interface ConversationListPage extends PaginatedResult {
+ basis: ConversationBasis | null
+}
+
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
export interface UpdateMessagePayload {
isRead?: boolean
@@ -334,10 +372,15 @@ export type UndoSendSeconds = 0 | 5 | 10 | 20 | 30
/** 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 */
export type ExternalImagesSetting = 'ask' | 'trusted' | 'always'
-/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 */
+/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 · 묶지 않음 */
export interface MailSettings {
undoSendSeconds: UndoSendSeconds
externalImages: ExternalImagesSetting
+ /**
+ * 메일함 목록을 대화로 묶어 보기 (14단계 — 계약 2절 "대화 — 14단계"). 서버는 저장해 돌려주기만 하고 묶은 목록을 부를지는 화면이 정한다.
+ * 필드가 없는 서버(14단계 앞)면 false 로 본다 (useMailSettings 가 맞춘다)
+ */
+ groupByConversation: boolean
}
// ── 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) ─────────────
diff --git a/frontend/src/shared/utils/mail-conversation-list.spec.ts b/frontend/src/shared/utils/mail-conversation-list.spec.ts
new file mode 100644
index 0000000..4d3a2a0
--- /dev/null
+++ b/frontend/src/shared/utils/mail-conversation-list.spec.ts
@@ -0,0 +1,327 @@
+import { describe, expect, it } from 'vitest'
+
+import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
+import type { PageInfo } from '@/shared/types/pagination'
+import {
+ appendConversationPage,
+ conversationActionIds,
+ conversationActionIdsOf,
+ conversationCountText,
+ conversationListPageOf,
+ conversationNeighborAfterRemoval,
+ conversationParticipantNames,
+ conversationRowIndexOf,
+ conversationWho,
+ isConversationHeld,
+ listHeadCountText,
+ mergeRefreshedConversations,
+ withConversationFlags,
+ withConversationFlagsOf,
+ withoutConversationMessages,
+} from './mail-conversation-list'
+
+// 대화로 묶어 보기 — 묶은 목록의 줄 (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 원본 ListThreads14)
+
+const ME = 'ara.kim@arasystem.test'
+const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
+const HR = { name: '인사팀', email: 'hr@arasystem.test' }
+const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
+
+function summary(id: string, partial: Partial = {}): MessageSummary {
+ return {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: ['inbox'],
+ from: DOYUN,
+ to: [{ name: '김아라', email: ME }],
+ subject: `제목 ${id}`,
+ preview: '',
+ receivedAt: '2026-11-05T01:00:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend: null,
+ ...partial,
+ }
+}
+
+function conversation(id: string, messageIds: string[], partial: Partial = {}): ConversationSummary {
+ return {
+ id,
+ latest: summary(messageIds[0] ?? `${id}-latest`),
+ participants: [DOYUN],
+ messageCount: messageIds.length,
+ messageIds,
+ pendingSendIds: [],
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ ...partial,
+ }
+}
+
+const ids = (rows: readonly { id: string }[]) => rows.map((row) => row.id)
+const INFO = (partial: Partial = {}): PageInfo => ({ hasNext: false, nextCursor: null, total: null, ...partial })
+
+describe('conversationListPageOf — 응답 맞추기', () => {
+ it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => {
+ const row = conversation('c1', ['m2', 'm1'], { participants: [DOYUN, { name: null, email: ME }] })
+ const page = conversationListPageOf({
+ items: [row],
+ pageInfo: { hasNext: true, nextCursor: 'next', total: 6 },
+ basis: 'engine',
+ })
+
+ expect(page.items).toEqual([row])
+ expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'next', total: 6 })
+ expect(page.basis).toBe('engine')
+ })
+
+ it('목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다 (id 없음 · 마지막 메일이 요약 모양이 아님)', () => {
+ expect(conversationListPageOf(null).items).toEqual([])
+ expect(conversationListPageOf({ items: 'x', pageInfo: {} }).items).toEqual([])
+
+ const page = conversationListPageOf({
+ items: [conversation('ok', ['m1']), { id: '', latest: summary('m2') }, { id: 'c3', latest: { id: 'm3' } }, 'c4'],
+ pageInfo: { hasNext: false, nextCursor: null, total: 4 },
+ basis: 'index',
+ })
+ expect(ids(page.items)).toEqual(['ok'])
+ })
+
+ it('마지막 메일은 늘 messageIds 에 들고, 틀린 칸은 맞춘다 — 통 수가 정수가 아니면 messageIds 의 수, 불리언이 아니면 마지막 메일의 값', () => {
+ const [row] = conversationListPageOf({
+ items: [
+ {
+ id: 'c1',
+ latest: summary('m3', { isRead: false, isFlagged: true, hasAttachment: true }),
+ participants: [DOYUN, { email: 3 }, { name: undefined, email: ME }],
+ messageCount: 2.5,
+ messageIds: ['m1', 7, ''],
+ pendingSendIds: 'x',
+ },
+ ],
+ pageInfo: { hasNext: true, nextCursor: '', total: -1 },
+ basis: 'thread',
+ }).items
+
+ expect(row?.messageIds).toEqual(['m3', 'm1'])
+ expect(row?.messageCount).toBe(2)
+ expect(row?.pendingSendIds).toEqual([])
+ expect(row?.participants).toEqual([DOYUN, { name: null, email: ME }])
+ expect([row?.isRead, row?.isFlagged, row?.hasAttachment]).toEqual([false, true, true])
+ })
+
+ it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 total 은 모르는 것, 모르는 basis 는 null', () => {
+ const page = conversationListPageOf({ items: [], pageInfo: { hasNext: true, nextCursor: '', total: -1 }, basis: 'thread' })
+ expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null })
+ expect(page.basis).toBeNull()
+ })
+})
+
+describe('줄에 하는 일 — 붙잡힌 메일은 뺀다', () => {
+ it('pendingSendIds 와 마지막 메일의 pendingSend 를 뺀 메일이 할 일이다', () => {
+ const row = conversation('c1', ['m3', 'm2', 'm1'], {
+ latest: summary('m3', { pendingSend: { sendAt: '2026-11-06T01:00:00Z', kind: 'undo' } }),
+ pendingSendIds: ['m2'],
+ })
+ expect(conversationActionIds(row)).toEqual(['m1'])
+ expect(isConversationHeld(row)).toBe(false)
+ })
+
+ it('줄의 메일이 모두 붙잡힌 메일이면 고를 수 없다', () => {
+ const row = conversation('c1', ['m2', 'm1'], { pendingSendIds: ['m2', 'm1'] })
+ expect(conversationActionIds(row)).toEqual([])
+ expect(isConversationHeld(row)).toBe(true)
+ })
+
+ it('여러 줄의 할 일은 겹치지 않게 모은다 (같은 id 를 두 번 보내면 서버가 거절한다)', () => {
+ const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m2', 'm1'], { pendingSendIds: ['m1'] })]
+ expect(conversationActionIdsOf(rows)).toEqual(['m3', 'm2'])
+ })
+
+ it('메일의 줄 — 마지막 메일이거나 줄에 든 메일, 없으면 -1', () => {
+ const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m1'])]
+ expect(conversationRowIndexOf(rows, 'm2')).toBe(0)
+ expect(conversationRowIndexOf(rows, 'm1')).toBe(1)
+ expect(conversationRowIndexOf(rows, 'zz')).toBe(-1)
+ })
+})
+
+describe('줄의 글', () => {
+ it('참여자 — 다른 사람은 받은 차례, 내 주소는 "나"로 끝에, 같은 이름은 한 번 (디자인 원본 ListThreads14 "인사팀, 김민수, 나")', () => {
+ const row = conversation('c1', ['m1'], {
+ // 내 주소는 대소문자 · 앞뒤 공백을 가리지 않는다
+ participants: [HR, { name: '김아라', email: ' Ara.Kim@Arasystem.TEST ' }, MINSU, { ...MINSU, email: 'm2@x.test' }],
+ })
+ expect(conversationParticipantNames(row, ME)).toEqual(['인사팀', '김민수', '나'])
+ })
+
+ it('이름 자리 — 받은편지함은 참여자를 쉼표로, 이름이 없으면 주소', () => {
+ const row = conversation('c1', ['m1'], { participants: [{ name: ME, email: ME }, { name: null, email: 'x@ext.test' }] })
+ expect(conversationWho(row, { myEmail: ME, role: 'inbox', view: 'split' })).toEqual({ text: 'x@ext.test, 나', placeholder: false })
+ })
+
+ it('보낸편지함 · 임시보관함에서 참여자가 나뿐이면 마지막 메일의 받는 사람 ("받는 사람: …")', () => {
+ const sentRow = conversation('c1', ['m1'], {
+ participants: [{ name: '김아라', email: ME }],
+ latest: summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME }, to: [DOYUN, MINSU] }),
+ })
+ expect(conversationWho(sentRow, { myEmail: ME, role: 'sent', view: 'table' }).text).toBe('받는 사람: 이도윤 외 1명')
+
+ const draftRow = conversation('c2', ['d1'], { participants: [], latest: summary('d1', { to: [] }) })
+ expect(conversationWho(draftRow, { myEmail: ME, role: 'drafts', view: 'split' })).toEqual({
+ text: '(받는 사람 없음)',
+ placeholder: true,
+ })
+
+ // 답장이 와서 다른 사람이 있으면 참여자 그대로
+ const replied = conversation('c3', ['m2', 'm1'], { participants: [{ name: '김아라', email: ME }, DOYUN] })
+ expect(conversationWho(replied, { myEmail: ME, role: 'sent', view: 'split' }).text).toBe('이도윤, 나')
+ })
+
+ it('받은편지함에서 참여자가 없으면 마지막 메일의 보낸 사람, 나뿐이면 "나"', () => {
+ expect(conversationWho(conversation('c1', ['m1'], { participants: [] }), { myEmail: ME, role: 'inbox', view: 'split' }).text).toBe(
+ '이도윤',
+ )
+ expect(
+ conversationWho(conversation('c2', ['m1'], { participants: [{ name: '김아라', email: ME }] }), {
+ myEmail: ME,
+ role: null,
+ view: 'split',
+ }).text,
+ ).toBe('나')
+ })
+
+ it('통 수 — 한 통이면 없고, 여러 통이면 "4통" (천 단위 쉼표)', () => {
+ expect(conversationCountText(conversation('c1', ['m1']))).toBeNull()
+ expect(conversationCountText(conversation('c2', ['m1'], { messageCount: 4 }))).toBe('4통')
+ expect(conversationCountText(conversation('c3', ['m1'], { messageCount: 1234 }))).toBe('1,234통')
+ })
+
+ it('목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (모르는 수 · 0 은 뺀다)', () => {
+ expect(listHeadCountText({ grouped: true, total: 6, unread: 2 })).toBe('대화 6개, 안 읽은 메일 2통')
+ expect(listHeadCountText({ grouped: false, total: 9, unread: 2 })).toBe('메일 9통, 안 읽은 메일 2통')
+ expect(listHeadCountText({ grouped: true, total: null, unread: 2 })).toBe('안 읽은 메일 2통')
+ expect(listHeadCountText({ grouped: true, total: 1500, unread: 0 })).toBe('대화 1,500개')
+ expect(listHeadCountText({ grouped: true, total: null, unread: 0 })).toBe('')
+ })
+})
+
+describe('이 탭이 바꾼 표시', () => {
+ it('줄의 메일을 모두 바꾸면 줄 전체가 읽음 · 중요 해제 — 마지막 메일의 표시도', () => {
+ const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
+ const read = withConversationFlags(row, new Set(['m2', 'm1']), { isRead: true, isFlagged: false })
+ expect([read.isRead, read.isFlagged]).toEqual([true, false])
+ expect([read.latest.isRead, read.latest.isFlagged]).toEqual([true, false])
+ })
+
+ it('일부만 바꾸면 읽음 · 중요 해제는 모른다(그대로) — 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다', () => {
+ const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
+ const partial = withConversationFlags(row, new Set(['m2']), { isRead: true, isFlagged: false })
+ expect([partial.isRead, partial.isFlagged]).toEqual([false, true])
+ expect(partial.latest.isRead).toBe(true)
+
+ const readRow = conversation('c2', ['m4', 'm3'])
+ const unread = withConversationFlags(readRow, new Set(['m3']), { isRead: false, isFlagged: true })
+ expect([unread.isRead, unread.isFlagged]).toEqual([false, true])
+ // 마지막 메일이 아닌 메일은 마지막 메일의 표시를 바꾸지 않는다
+ expect(unread.latest).toBe(readRow.latest)
+ })
+
+ it('읽음은 붙잡힌 메일을 빼고 센다 — 나머지를 모두 바꿨으면 줄 전체다. 든 메일이 없으면 같은 줄', () => {
+ const row = conversation('c1', ['m2', 'm1'], { isRead: false, pendingSendIds: ['m2'] })
+ expect(withConversationFlags(row, new Set(['m1']), { isRead: true }).isRead).toBe(true)
+ expect(withConversationFlags(row, new Set(['zz']), { isRead: true })).toBe(row)
+ })
+
+ it('중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만 줄 전체다 — 줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 (리뷰 L1)', () => {
+ const row = conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] })
+ expect(withConversationFlags(row, new Set(['m1']), { isFlagged: false }).isFlagged).toBe(true)
+ expect(withConversationFlags(row, new Set(['m2', 'm1']), { isFlagged: false }).isFlagged).toBe(false)
+ })
+
+ it('되돌리기 — 줄과(마지막 메일이 그대로면) 마지막 메일의 표시를 앞 값으로', () => {
+ const original = conversation('c1', ['m2', 'm1'], { isRead: false, latest: summary('m2', { isRead: false }) })
+ const changed = withConversationFlags(original, new Set(['m2', 'm1']), { isRead: true })
+ expect([changed.isRead, changed.latest.isRead]).toEqual([true, true])
+ const restored = withConversationFlagsOf(changed, original)
+ expect([restored.isRead, restored.latest.isRead]).toEqual([false, false])
+
+ const newLatest = { ...changed, latest: summary('m9', { isRead: true }) }
+ expect(withConversationFlagsOf(newLatest, original).latest.isRead).toBe(true)
+ })
+})
+
+describe('뺀 메일 · 이어서 열 메일', () => {
+ it('메일이 모두 빠진 줄은 빼고 그 수를 센다 — 남은 줄은 메일만 줄인다', () => {
+ const rows = [conversation('c1', ['m3', 'm2'], { pendingSendIds: ['m2'] }), conversation('c2', ['m1']), conversation('c3', ['m0'])]
+ const result = withoutConversationMessages(rows, new Set(['m3', 'm1']))
+ expect(ids(result.rows)).toEqual(['c1', 'c3'])
+ expect(result.rows[0]?.messageIds).toEqual(['m2'])
+ expect(result.rows[0]?.pendingSendIds).toEqual(['m2'])
+ expect(result.removedRows).toBe(1)
+ expect(result.rows[1]).toBe(rows[2])
+ })
+
+ it('이어서 열 메일 — 메일 목록과 같은 결: 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄의 마지막 메일 (리뷰 L3)', () => {
+ const rows = [conversation('c1', ['m6']), conversation('c2', ['m5', 'm4', 'm3']), conversation('c3', ['m2'])]
+ // 가운데 메일([이 메일만 열기]로 연 더 오래된 메일) — 다음 칸
+ expect(conversationNeighborAfterRemoval(rows, 'm4')).toBe('m3')
+ expect(conversationNeighborAfterRemoval(rows, 'm5')).toBe('m4')
+ // 줄의 끝 칸 — 앞 칸
+ expect(conversationNeighborAfterRemoval(rows, 'm3')).toBe('m4')
+ // 줄에 그 메일뿐 — 다음 줄, 마지막 줄이면 앞 줄의 마지막 메일
+ expect(conversationNeighborAfterRemoval(rows, 'm6')).toBe('m5')
+ expect(conversationNeighborAfterRemoval(rows, 'm2')).toBe('m5')
+ expect(conversationNeighborAfterRemoval([conversation('c1', ['m1'])], 'm1')).toBeNull()
+ expect(conversationNeighborAfterRemoval(rows, 'zz')).toBeNull()
+ })
+
+ it('이어서 열 메일 — 다음 맞추기 전의 옛 마지막 메일(이미 뺀 메일)이면 남은 메일의 첫 칸', () => {
+ const stale = conversation('c1', ['m3', 'm2'], { latest: summary('m9') })
+ expect(conversationNeighborAfterRemoval([stale, conversation('c2', ['m1'])], 'm9')).toBe('m3')
+ })
+})
+
+describe('쪽 붙이기 · 다시 받아 맞추기', () => {
+ it('붙일 때 이미 있는 줄은 건너뛰고, 다음 쪽의 total 이 없으면 아는 수를 남긴다', () => {
+ const merged = appendConversationPage([conversation('c1', ['m3'])], INFO({ total: 6 }), {
+ items: [conversation('c1', ['m3']), conversation('c2', ['m2'])],
+ pageInfo: INFO({ total: null }),
+ basis: 'index',
+ })
+ expect(ids(merged.rows)).toEqual(['c1', 'c2'])
+ expect(merged.pageInfo.total).toBe(6)
+ })
+
+ it('다시 받은 범위 밖의 옛 줄은 남기고 옛 커서를 쓴다 — 메일이 겹치는 옛 줄은 같은 대화로 본다(합쳐진 대화)', () => {
+ const current = [conversation('a', ['m9']), conversation('b', ['m8']), conversation('c', ['m7', 'm6']), conversation('d', ['m5'])]
+ const fetched = [conversation('x', ['m10']), conversation('a', ['m9']), conversation('b2', ['m8', 'm7'])]
+ const result = mergeRefreshedConversations({
+ current,
+ currentPageInfo: INFO({ hasNext: true, nextCursor: 'old', total: 10 }),
+ fetched,
+ fetchedPageInfo: INFO({ hasNext: true, nextCursor: 'new', total: 11 }),
+ })
+ // b 는 b2 와 메일이 겹치고(m8) c 는 m7 이 겹쳐 같은 대화로 본다 — 남는 옛 줄은 d
+ expect(ids(result.rows)).toEqual(['x', 'a', 'b2', 'd'])
+ expect(result.pageInfo).toEqual({ hasNext: true, nextCursor: 'old', total: 11 })
+ })
+
+ it('"새 메일 N통" — 맨 앞부터 새 줄은 한 통, 새 메일이 든 줄은 새 메일 수를 이어 세고, 바뀌지 않은 줄에서 멈춘다', () => {
+ const current = [conversation('a', ['m3', 'm2']), conversation('b', ['m1'])]
+ const fetched = [
+ conversation('new', ['m9', 'm0']),
+ conversation('b', ['m8', 'm7', 'm1']),
+ conversation('a', ['m3', 'm2']),
+ conversation('late', ['m6']),
+ ]
+ const result = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched, fetchedPageInfo: INFO() })
+ expect(result.addedAbove).toBe(3)
+
+ const unchanged = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched: current, fetchedPageInfo: INFO() })
+ expect(unchanged.addedAbove).toBe(0)
+ })
+})
diff --git a/frontend/src/shared/utils/mail-conversation-list.ts b/frontend/src/shared/utils/mail-conversation-list.ts
new file mode 100644
index 0000000..e682ab0
--- /dev/null
+++ b/frontend/src/shared/utils/mail-conversation-list.ts
@@ -0,0 +1,337 @@
+// 대화로 묶어 보기 — 묶은 목록(GET /conversations)의 줄을 다루는 순수 함수만 둔다
+// (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)")
+// - 응답 맞추기: 모양이 틀린 줄은 뺀다. 마지막 메일(latest)은 늘 messageIds 에 넣어 둔다 — 줄에 든 메일인지 messageIds 만 보면 되게
+// - 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)은 messageIds 에서 붙잡힌 메일(pendingSendIds · 마지막 메일의 pendingSend)을 뺀 메일에 한다 —
+// 붙잡힌 메일은 취소로만 보낸편지함을 떠난다. 남는 메일이 없으면 그 줄은 고를 수 없다
+// - 참여자: 다른 사람은 받은 차례로, 내 주소는 "나"로 끝에 (디자인 원본 ListThreads14 — "이도윤, 나" · "인사팀, 김민수, 나").
+// 보낸편지함 · 임시보관함에서 참여자가 나뿐이면 메일 목록처럼 "받는 사람: …" 이다 (나만 늘어선 줄이 되지 않게 — 보드에 없어 정했다)
+// - 이 탭이 바꾼 표시(읽음 · 중요): 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제가 된다 — 일부만이면 나머지를 몰라 그대로 두고
+// 다음 맞추기(새 메일 확인 · 실시간 소식)가 서버 값으로 고친다. 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다.
+// 읽음은 붙잡힌 메일(내가 보내는 메일 — 읽은 메일)을 빼고 세지만, 중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만이다
+// (줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 — 14단계 리뷰 L1)
+// - 뺀 메일: 줄의 메일이 모두 빠지면 줄도 뺀다. 남으면 줄은 그대로다(마지막 메일은 다음 맞추기까지 옛 것 — 부르는 쪽이 곧 다시 맞춘다).
+// 뺀 뒤 열 메일은 메일 목록과 같은 결 — 그 줄의 messageIds 에서 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄
+// - 다시 받아 맞추기: 메일 목록(mail-list-merge.ts)과 같은 규칙 — 다시 받은 범위 밖의 옛 줄은 남긴다. 메일이 겹치는 옛 줄은 같은 대화로 본다
+// (늦게 온 원본으로 두 대화가 합쳐지면 id 가 바뀐다 — 계약의 알려진 한계)
+
+import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
+import { MAIL_CONVERSATION_LIST_TEXT, MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
+import type {
+ Address,
+ ConversationBasis,
+ ConversationListPage,
+ ConversationSummary,
+ MailboxRole,
+ MessageSummary,
+} from '@/shared/types/mail'
+import type { PageInfo } from '@/shared/types/pagination'
+import { formatCount } from '@/shared/utils/format'
+import { isMyAddress, isSummaryLike, participantName } from '@/shared/utils/mail-conversation'
+import { rowWho, type RowWho } from '@/shared/utils/mail-list-row'
+import { isHeldMessage } from '@/shared/utils/pending-send'
+
+const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
+
+/** 이 탭이 줄의 메일에 바로 적는 표시 — 메일 목록(list.ts)의 LocalChanges 와 같은 칸 */
+export type ConversationFlagChanges = Partial>
+
+// ── 응답 맞추기 ─────────────────────────────────────────────
+
+function isAddressLike(value: unknown): value is Address {
+ if (typeof value !== 'object' || value === null) return false
+ const candidate = value as { name?: unknown; email?: unknown }
+ return (
+ typeof candidate.email === 'string' &&
+ (candidate.name === null || candidate.name === undefined || typeof candidate.name === 'string')
+ )
+}
+
+function idsOf(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ return value.filter((id): id is string => typeof id === 'string' && id !== '')
+}
+
+function booleanOr(value: unknown, fallback: boolean): boolean {
+ return typeof value === 'boolean' ? value : fallback
+}
+
+/** 줄 하나 — 줄이 읽는 칸의 모양이 틀리면 null (그 줄만 뺀다) */
+function conversationOf(value: unknown): ConversationSummary | null {
+ if (typeof value !== 'object' || value === null) return null
+ const candidate = value as Partial>
+ if (typeof candidate.id !== 'string' || candidate.id === '' || !isSummaryLike(candidate.latest)) return null
+ const latest = candidate.latest
+ const listed = idsOf(candidate.messageIds)
+ const messageIds = listed.includes(latest.id) ? listed : [latest.id, ...listed]
+ const count = candidate.messageCount
+ return {
+ id: candidate.id,
+ latest,
+ participants: Array.isArray(candidate.participants)
+ ? candidate.participants.filter(isAddressLike).map((address) => ({ name: address.name ?? null, email: address.email }))
+ : [],
+ messageCount: typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : messageIds.length,
+ messageIds,
+ pendingSendIds: idsOf(candidate.pendingSendIds),
+ isRead: booleanOr(candidate.isRead, latest.isRead),
+ isFlagged: booleanOr(candidate.isFlagged, latest.isFlagged),
+ hasAttachment: booleanOr(candidate.hasAttachment, latest.hasAttachment),
+ }
+}
+
+/**
+ * GET /conversations 의 응답 → ConversationListPage. 목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다.
+ * 커서가 없는 hasNext 는 다음 쪽이 없는 것으로, 음수 · 정수가 아닌 total 은 모르는 것(null)으로 본다 (같은 대화의 conversationPageOf 와 같다)
+ */
+export function conversationListPageOf(value: unknown): ConversationListPage {
+ const empty: ConversationListPage = { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
+ if (typeof value !== 'object' || value === null) return empty
+ const candidate = value as { items?: unknown; pageInfo?: unknown; basis?: unknown }
+ if (!Array.isArray(candidate.items) || typeof candidate.pageInfo !== 'object' || candidate.pageInfo === null) return empty
+ const info = candidate.pageInfo as { hasNext?: unknown; nextCursor?: unknown; total?: unknown }
+ const nextCursor = typeof info.nextCursor === 'string' && info.nextCursor !== '' ? info.nextCursor : null
+ const total = typeof info.total === 'number' && Number.isInteger(info.total) && info.total >= 0 ? info.total : null
+ const basis =
+ typeof candidate.basis === 'string' && CONVERSATION_BASES.includes(candidate.basis)
+ ? (candidate.basis as ConversationBasis)
+ : null
+ return {
+ items: candidate.items.map(conversationOf).filter((row): row is ConversationSummary => row !== null),
+ pageInfo: { hasNext: info.hasNext === true && nextCursor !== null, nextCursor, total },
+ basis,
+ }
+}
+
+// ── 줄에 하는 일 ─────────────────────────────────────────────
+
+/** 줄의 붙잡힌 메일 — pendingSendIds 와 마지막 메일의 pendingSend */
+function heldIdsOf(row: ConversationSummary): Set {
+ const held = new Set(row.pendingSendIds)
+ if (isHeldMessage(row.latest)) held.add(row.latest.id)
+ return held
+}
+
+/** 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)을 보낼 메일 — 붙잡힌 메일은 뺀다 (최신부터) */
+export function conversationActionIds(row: ConversationSummary): string[] {
+ const held = heldIdsOf(row)
+ return row.messageIds.filter((id) => !held.has(id))
+}
+
+/** 줄의 메일이 모두 붙잡힌 메일이다 — 고를 수 없고 별(중요 표시)도 바꿀 수 없다 */
+export function isConversationHeld(row: ConversationSummary): boolean {
+ return conversationActionIds(row).length === 0
+}
+
+/** 여러 줄의 할 일 메일 — 겹치지 않게 (같은 id 를 두 번 보내면 서버가 거절한다) */
+export function conversationActionIdsOf(rows: readonly ConversationSummary[]): string[] {
+ return [...new Set(rows.flatMap(conversationActionIds))]
+}
+
+/** 그 메일이 든 줄인가 — 마지막 메일은 늘 messageIds 에 있다(conversationListPageOf). 뺀 뒤 남은 옛 마지막 메일은 들지 않는다 */
+export function conversationHasMessage(row: ConversationSummary, messageId: string): boolean {
+ return row.messageIds.includes(messageId)
+}
+
+/** 그 메일의 줄 — 줄에 든 메일이거나 그 줄의 마지막 메일(다음 맞추기 전의 옛 것 포함). 없으면 -1 */
+export function conversationRowIndexOf(rows: readonly ConversationSummary[], messageId: string): number {
+ return rows.findIndex((row) => row.latest.id === messageId || conversationHasMessage(row, messageId))
+}
+
+// ── 줄의 글 ─────────────────────────────────────────────
+
+/** 참여자 이름 — 다른 사람은 받은 차례(같은 이름은 한 번), 나는 끝에 "나" */
+export function conversationParticipantNames(row: ConversationSummary, myEmail: string | null | undefined): string[] {
+ const others: string[] = []
+ let mine = false
+ for (const address of row.participants) {
+ if (isMyAddress(address, myEmail)) {
+ mine = true
+ continue
+ }
+ const name = participantName(address, myEmail)
+ if (name && !others.includes(name)) others.push(name)
+ }
+ return mine ? [...others, MAIL_CONVERSATION_TEXT.me] : others
+}
+
+export interface ConversationWhoContext {
+ myEmail: string | null | undefined
+ /** 목록의 메일함 역할 */
+ role: MailboxRole | null
+ view: 'split' | 'table'
+}
+
+/**
+ * 줄의 이름 자리 — 참여자를 쉼표로 ("이도윤, 나"). 보낸편지함 · 임시보관함에서 참여자가 나뿐(또는 없음)이면 마지막 메일의 받는 사람을
+ * "받는 사람: …" 으로(표 머리는 "보낸 사람"이라 앞말을 붙인다), 그 밖에서 참여자가 없으면 마지막 메일의 보낸 사람 규칙(mail-list-row.ts)
+ */
+export function conversationWho(row: ConversationSummary, context: ConversationWhoContext): RowWho {
+ const names = conversationParticipantNames(row, context.myEmail)
+ const onlyMe = names.length === 0 || (names.length === 1 && names[0] === MAIL_CONVERSATION_TEXT.me)
+ if (onlyMe && context.role && OUTGOING_MAILBOX_ROLES.includes(context.role)) {
+ return rowWho(row.latest, { role: context.role, mixed: true, view: context.view })
+ }
+ if (names.length === 0) return rowWho(row.latest, { role: context.role, mixed: false, view: context.view })
+ return { text: names.join(', '), placeholder: false }
+}
+
+/** 줄의 통 수 — "4통"(대화 전체 · 보낸편지함 포함), 한 통이면 null (보이지 않는다) */
+export function conversationCountText(row: ConversationSummary): string | null {
+ return row.messageCount > 1 ? MAIL_CONVERSATION_LIST_TEXT.rowCount(formatCount(row.messageCount)) : null
+}
+
+/**
+ * 목록 머리 글 — 대화로 묶었으면 "대화 6개, 안 읽은 메일 2통", 아니면 "메일 9통, 안 읽은 메일 2통".
+ * 대화 수는 목록의 total — 모르면(null) 그 부분을 빼고, 안 읽은 메일이 없으면 뒤를 뺀다. 둘 다 없으면 빈 글
+ */
+export function listHeadCountText(input: { grouped: boolean; total: number | null; unread: number }): string {
+ const parts: string[] = []
+ if (input.total !== null) {
+ const count = formatCount(input.total)
+ parts.push(
+ input.grouped ? MAIL_CONVERSATION_LIST_TEXT.conversationCount(count) : MAIL_CONVERSATION_LIST_TEXT.messageCount(count),
+ )
+ }
+ if (input.unread > 0) parts.push(MAIL_CONVERSATION_LIST_TEXT.unreadCount(formatCount(input.unread)))
+ return parts.join(', ')
+}
+
+// ── 이 탭이 바꾼 것 ─────────────────────────────────────────────
+
+/**
+ * 메일들의 표시를 바꾼 뒤의 줄 (머리말) — ids 에 든 메일이 없으면 그 줄 그대로.
+ * 줄의 메일(붙잡힌 메일은 빼고)을 모두 바꿨으면 읽음 · 중요 해제도 줄 전체에 적는다. 마지막 메일이면 그 메일의 표시 · 메일함도 바꾼다
+ */
+export function withConversationFlags(
+ row: ConversationSummary,
+ ids: ReadonlySet,
+ changes: ConversationFlagChanges,
+): ConversationSummary {
+ const latestHit = ids.has(row.latest.id)
+ if (!latestHit && !row.messageIds.some((id) => ids.has(id))) return row
+ const held = heldIdsOf(row)
+ // 읽음 — 붙잡힌 메일(내가 보내는 메일 · 읽은 메일)은 빼고 센다. 중요 해제 — 붙잡힌 메일의 표시는 몰라 그 메일까지 바꿨을 때만
+ const coversRead = row.messageIds.every((id) => ids.has(id) || held.has(id))
+ const coversAll = row.messageIds.every((id) => ids.has(id))
+ const next: ConversationSummary = { ...row }
+ if (latestHit) next.latest = { ...row.latest, ...changes }
+ if (changes.isRead === false) next.isRead = false
+ else if (changes.isRead === true && coversRead) next.isRead = true
+ if (changes.isFlagged === true) next.isFlagged = true
+ else if (changes.isFlagged === false && coversAll) next.isFlagged = false
+ return next
+}
+
+/** 줄의 읽음 · 중요 표시를 앞 값으로 — 보내지 못한 메일이 든 줄을 되돌린다(여러 통 처리). 마지막 메일이 그대로일 때만 그 메일의 표시도 */
+export function withConversationFlagsOf(row: ConversationSummary, original: ConversationSummary): ConversationSummary {
+ const latest =
+ row.latest.id === original.latest.id
+ ? { ...row.latest, isRead: original.latest.isRead, isFlagged: original.latest.isFlagged }
+ : row.latest
+ return { ...row, isRead: original.isRead, isFlagged: original.isFlagged, latest }
+}
+
+/** 메일을 뺀 뒤의 줄 (머리말) — 메일이 모두 빠진 줄은 빼고 그 수를 센다 */
+export function withoutConversationMessages(
+ rows: readonly ConversationSummary[],
+ ids: ReadonlySet,
+): { rows: ConversationSummary[]; removedRows: number } {
+ let removedRows = 0
+ const next: ConversationSummary[] = []
+ for (const row of rows) {
+ if (!row.messageIds.some((id) => ids.has(id))) {
+ next.push(row)
+ continue
+ }
+ const messageIds = row.messageIds.filter((id) => !ids.has(id))
+ if (messageIds.length === 0) {
+ removedRows += 1
+ continue
+ }
+ next.push({ ...row, messageIds, pendingSendIds: row.pendingSendIds.filter((id) => !ids.has(id)) })
+ }
+ return { rows: next, removedRows }
+}
+
+/**
+ * 그 메일을 뺀 뒤 이어서 열 메일 — 메일 목록의 neighborAfterRemoval 과 같은 결이다 (14단계 리뷰 L3).
+ * 그 줄의 messageIds(최신부터)에서 그 메일의 다음 칸(더 오래된 메일), 없으면 앞 칸. 줄에 그 메일뿐이면 다음 줄의 마지막 메일, 없으면 앞 줄의 마지막 메일.
+ * 줄의 옛 마지막 메일(다음 맞추기 전 — messageIds 에서 이미 뺐다)이면 남은 메일의 첫 칸. 목록에 없는 메일이면 null
+ */
+export function conversationNeighborAfterRemoval(rows: readonly ConversationSummary[], messageId: string): string | null {
+ const index = conversationRowIndexOf(rows, messageId)
+ if (index === -1) return null
+ const ids = rows[index]?.messageIds ?? []
+ const position = ids.indexOf(messageId)
+ const inRow = position === -1 ? ids[0] : (ids[position + 1] ?? ids[position - 1])
+ if (inRow !== undefined) return inRow
+ return rows[index + 1]?.latest.id ?? rows[index - 1]?.latest.id ?? null
+}
+
+// ── 쪽 붙이기 · 다시 받아 맞추기 ─────────────────────────────────────────────
+
+/** 받은 쪽을 뒤에 붙인다 — 이미 있는 줄은 건너뛴다. 다음 쪽의 total 이 없으면(색인 방식은 첫 쪽에서만 센다) 아는 수를 남긴다 */
+export function appendConversationPage(
+ rows: readonly ConversationSummary[],
+ pageInfo: PageInfo | null,
+ page: ConversationListPage,
+): { rows: ConversationSummary[]; pageInfo: PageInfo } {
+ const known = new Set(rows.map((row) => row.id))
+ const fresh = page.items.filter((row) => !known.has(row.id))
+ return {
+ rows: [...rows, ...fresh],
+ pageInfo: { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo?.total ?? null },
+ }
+}
+
+export interface ConversationMergeInput {
+ current: readonly ConversationSummary[]
+ currentPageInfo: PageInfo | null
+ fetched: readonly ConversationSummary[]
+ fetchedPageInfo: PageInfo
+}
+
+export interface ConversationMergeResult {
+ rows: ConversationSummary[]
+ pageInfo: PageInfo
+ /** 목록 맨 위의 새 메일 수 — 맨 앞부터 새로 생긴 줄(한 통으로 센다)과 새 메일이 든 줄(새 메일 수)이 이어지는 만큼 ("새 메일 N통") */
+ addedAbove: number
+}
+
+/** 불러온 만큼(최대 100줄)을 다시 받아 맞춘다 (머리말) — 다시 받은 범위는 서버 기준, 그 밖의 옛 줄은 그대로 */
+export function mergeRefreshedConversations(input: ConversationMergeInput): ConversationMergeResult {
+ const { current, fetched } = input
+ const fetchedIds = new Set(fetched.map((row) => row.id))
+ const fetchedMessages = new Set(fetched.flatMap((row) => row.messageIds))
+ const overlaps = (row: ConversationSummary) =>
+ fetchedIds.has(row.id) || row.messageIds.some((id) => fetchedMessages.has(id))
+
+ // 다시 받은 범위와 겹치는 마지막 옛 줄 — 그 뒤의 옛 줄은 다시 받지 않았다
+ let lastOverlap = -1
+ current.forEach((row, index) => {
+ if (overlaps(row)) lastOverlap = index
+ })
+ const tail = lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((row) => !overlaps(row))
+
+ // 남겨 둔 옛 줄이 있으면 "더 보기"는 그 뒤부터 — 옛 커서를 쓴다. 전체 수는 새 값
+ const pageInfo =
+ tail.length > 0 && input.currentPageInfo
+ ? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
+ : input.fetchedPageInfo
+
+ const previous = new Map(current.map((row) => [row.id, row]))
+ let addedAbove = 0
+ for (const row of fetched) {
+ const before = previous.get(row.id)
+ if (!before) {
+ addedAbove += 1
+ continue
+ }
+ const known = new Set(before.messageIds)
+ const fresh = row.messageIds.filter((id) => !known.has(id)).length
+ if (fresh === 0) break
+ addedAbove += fresh
+ }
+
+ return { rows: [...fetched, ...tail], pageInfo, addedAbove }
+}
diff --git a/frontend/src/shared/utils/mail-conversation.ts b/frontend/src/shared/utils/mail-conversation.ts
index f1952f4..cd05cd2 100644
--- a/frontend/src/shared/utils/mail-conversation.ts
+++ b/frontend/src/shared/utils/mail-conversation.ts
@@ -15,7 +15,7 @@ import type { PageInfo } from '@/shared/types/pagination'
import { formatConversationTime, initialOf } from '@/shared/utils/format'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
-import { messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
+import { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
@@ -25,8 +25,11 @@ function emptyConversationPage(): ConversationPage {
return { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
}
-/** 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다) */
-function isSummaryLike(value: unknown): value is MessageSummary {
+/**
+ * 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다).
+ * 대화로 묶은 목록의 마지막 메일(latest)도 이것으로 본다 (mail-conversation-list.ts)
+ */
+export function isSummaryLike(value: unknown): value is MessageSummary {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial>
return (
@@ -180,10 +183,12 @@ export function conversationMessageLocation(
): RouteLocationRaw {
if (isDraftMessage(message, context.mailboxes)) return { name: 'mail-compose', query: { draft: message.id } }
const { source } = context
- if (source?.kind === 'mailbox' && message.mailboxIds.includes(source.mailboxId)) {
+ // 메일함 목록(대화로 묶은 목록 포함 — 14단계)은 그 메일함에 든 메일이면 그 목록 안에서 연다
+ const listMailboxId = mailboxListIdOf(source)
+ if (source && listMailboxId !== null && message.mailboxIds.includes(listMailboxId)) {
return messageLocationOf(source, message.id)
}
- if (source && source.kind !== 'mailbox' && context.listedIds.has(message.id)) return messageLocationOf(source, message.id)
+ if (source && listMailboxId === null && context.listedIds.has(message.id)) return messageLocationOf(source, message.id)
const mailboxId = mailboxOfMessage(message, context.mailboxes)?.id ?? message.mailboxIds[0]
if (mailboxId) return messageLocationOf({ kind: 'mailbox', mailboxId }, message.id)
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
diff --git a/frontend/src/shared/utils/mail-list-source.spec.ts b/frontend/src/shared/utils/mail-list-source.spec.ts
index c50af5b..51eaa81 100644
--- a/frontend/src/shared/utils/mail-list-source.spec.ts
+++ b/frontend/src/shared/utils/mail-list-source.spec.ts
@@ -4,10 +4,12 @@ import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import {
exceedsSearchWords,
isAllMailboxSearch,
+ isConversationSource,
isSearchRouteName,
listKeyOf,
listLocationOf,
listQueryOf,
+ mailboxListIdOf,
messageLocationOf,
normalizeSearchText,
sameListSource,
@@ -163,3 +165,29 @@ describe('mail-list-source — 조건 검색 (14단계)', () => {
expect(listKeyOf(plain)).toBe('search:inbox:계약')
})
})
+
+describe('mail-list-source — 대화로 묶은 메일함 (14단계)', () => {
+ const THREADS: MailListSource = { kind: 'conversations', mailboxId: 'inbox' }
+
+ it('주소는 메일함 목록과 같다 — 줄을 누르면 그 메일함 안의 메일 주소', () => {
+ expect(listLocationOf(THREADS)).toEqual(listLocationOf(INBOX))
+ expect(messageLocationOf(THREADS, 'm1')).toEqual(messageLocationOf(INBOX, 'm1'))
+ })
+
+ it('가리키는 메일함은 그 메일함 — 메일함 목록처럼 다룬다(같은 대화 칸의 링크 · 다시 받기)', () => {
+ expect(scopeMailboxIdOf(THREADS)).toBe('inbox')
+ expect(mailboxListIdOf(THREADS)).toBe('inbox')
+ expect(mailboxListIdOf(INBOX)).toBe('inbox')
+ expect(mailboxListIdOf(SEARCH_INBOX)).toBeNull()
+ expect(isConversationSource(THREADS)).toBe(true)
+ expect(isConversationSource(INBOX)).toBe(false)
+ expect(isAllMailboxSearch(THREADS)).toBe(false)
+ })
+
+ it('같은 메일함이어도 묶은 목록과 메일 목록은 다른 목록이다 — 켜고 끄면 고른 것을 풀고 처음 쪽부터 받는다', () => {
+ expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'inbox' })).toBe(true)
+ expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'sent' })).toBe(false)
+ expect(sameListSource(THREADS, INBOX)).toBe(false)
+ expect(listKeyOf(THREADS)).not.toBe(listKeyOf(INBOX))
+ })
+})
diff --git a/frontend/src/shared/utils/mail-list-source.ts b/frontend/src/shared/utils/mail-list-source.ts
index 2c8634d..582c91c 100644
--- a/frontend/src/shared/utils/mail-list-source.ts
+++ b/frontend/src/shared/utils/mail-list-source.ts
@@ -1,5 +1,6 @@
-// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
-// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
+// 보이는 메일 목록의 출처 — 메일함 하나 · 대화로 묶은 메일함(14단계) · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
+// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송" · "대화 — 14단계").
+// 대화로 묶은 메일함은 주소가 메일함 목록과 같다 — 묶을지는 사용자 설정(groupByConversation)이 정하고, 줄을 누르면 그 메일함의 마지막 메일을 연다.
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
@@ -41,10 +42,16 @@ export interface ScheduledListSource {
kind: 'scheduled'
}
+/** 대화로 묶은 메일함 — GET /conversations?mailboxId= (14단계). 한 줄이 그 메일함의 대화 하나다 */
+export interface ConversationListSource {
+ kind: 'conversations'
+ mailboxId: string
+}
+
/** GET /messages 로 받는 목록 — 메일함 · 검색 */
export type MessageListSource = MailboxListSource | SearchListSource
-export type MailListSource = MessageListSource | ScheduledListSource
+export type MailListSource = MessageListSource | ScheduledListSource | ConversationListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
@@ -67,12 +74,26 @@ export function isScheduledRouteName(name: unknown): boolean {
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
if (!a || !b) return a === b
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
+ if (a.kind === 'conversations' && b.kind === 'conversations') return a.mailboxId === b.mailboxId
if (a.kind === 'search' && b.kind === 'search') {
return a.q === b.q && a.scope === b.scope && sameSearchConditions(a.conditions, b.conditions)
}
return a.kind === 'scheduled' && b.kind === 'scheduled'
}
+/** 대화로 묶은 메일함인가 (14단계) */
+export function isConversationSource(source: MailListSource | null | undefined): source is ConversationListSource {
+ return source?.kind === 'conversations'
+}
+
+/**
+ * 메일함 하나의 목록(묶었든 아니든)이면 그 메일함 id — 검색 결과 · 예약됨이면 null.
+ * "그 메일함에 든 메일이면 이 목록 안에서 연다" · "그 메일함을 보고 있으면 다시 받는다"를 가르는 데 쓴다
+ */
+export function mailboxListIdOf(source: MailListSource | null | undefined): string | null {
+ return source?.kind === 'mailbox' || source?.kind === 'conversations' ? source.mailboxId : null
+}
+
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) + 조건(14단계 — 빈 칸은 넣지 않는다) */
export function listQueryOf(source: MessageListSource): Omit {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
@@ -83,10 +104,10 @@ export function listQueryOf(source: MessageListSource): Omit?q=…&in=…, 예약됨은 /mail/scheduled/<메일 id> */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
- if (source.kind === 'mailbox') {
+ if (source.kind === 'mailbox' || source.kind === 'conversations') {
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
}
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_MESSAGE_ROUTE, params: { messageId } }
diff --git a/frontend/src/stores/mail-settings.store.spec.ts b/frontend/src/stores/mail-settings.store.spec.ts
new file mode 100644
index 0000000..59e69be
--- /dev/null
+++ b/frontend/src/stores/mail-settings.store.spec.ts
@@ -0,0 +1,149 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import type { MailSettings } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
+import { groupByConversationHintKey, useMailSettingsStore } from './mail-settings.store'
+
+// 보내기 · 읽기 설정의 묶어 보기 (14단계) — 설정을 받기 전의 짐작(사용자마다 · localStorage), 로그아웃이 보이는 값을 바꾸지 않음(리뷰 M1),
+// 받지 못했을 때도 고르기(리뷰 L4)
+
+const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
+const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
+const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
+
+describe('mail-settings.store — 묶어 보기 짐작 · 받지 못했을 때', () => {
+ let requests: MockRequest[]
+ let load: () => MockResult
+ let patch: (request: MockRequest) => MockResult
+
+ beforeEach(() => {
+ window.localStorage.clear()
+ setActivePinia(createPinia())
+ load = () => ok(SETTINGS)
+ patch = (request) => ok({ ...SETTINGS, ...(request.data as Partial) })
+ requests = installMockApi((request) => (request.method === 'GET' ? load() : patch(request)))
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
+
+ it('짐작은 사용자마다다 — 김아라가 켰어도 이도윤은 묶지 않는다(처음 값)', async () => {
+ window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
+ const auth = useAuthStore()
+ const store = useMailSettingsStore()
+
+ auth.markAuthenticated(ARA)
+ await flushPromises()
+ expect(store.groupByConversation).toBe(true)
+
+ auth.clearAuth()
+ await flushPromises()
+ auth.markAuthenticated(DOYUN)
+ await flushPromises()
+ expect(store.groupByConversation).toBe(false)
+ })
+
+ it('받으면 서버의 값이 이기고 그 사용자의 짐작으로 기억한다', async () => {
+ useAuthStore().markAuthenticated(DOYUN)
+ const store = useMailSettingsStore()
+ expect(store.groupByConversation).toBe(false)
+
+ await store.load()
+
+ expect(store.groupByConversation).toBe(true)
+ expect(window.localStorage.getItem(groupByConversationHintKey(DOYUN.email))).toBe('true')
+ })
+
+ it('로그아웃 · 세션 만료는 보이는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게 (리뷰 M1)', async () => {
+ const auth = useAuthStore()
+ auth.markAuthenticated(ARA)
+ const store = useMailSettingsStore()
+ await store.load()
+ expect(store.groupByConversation).toBe(true)
+
+ auth.clearAuth()
+ await flushPromises()
+ expect(store.settings).toBeNull()
+ expect(store.groupByConversation).toBe(true)
+
+ auth.markAuthenticated(ARA)
+ await store.load()
+ auth.expireSession()
+ await flushPromises()
+ expect(store.groupByConversation).toBe(true)
+ })
+
+ it('localStorage 를 읽지 · 쓰지 못해도 던지지 않는다 — 짐작은 처음 값(묶지 않음), 받은 값은 그대로 쓴다', async () => {
+ window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blocked')
+ })
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('blocked')
+ })
+ useAuthStore().markAuthenticated(ARA)
+ const store = useMailSettingsStore()
+ expect(store.groupByConversation).toBe(false)
+
+ await store.load()
+ expect(store.groupByConversation).toBe(true)
+ })
+
+ describe('받지 못했으면 짐작대로 두고 [대화로 묶기]는 고를 수 있다 (리뷰 L4)', () => {
+ beforeEach(() => {
+ window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
+ useAuthStore().markAuthenticated(ARA)
+ load = () => fail(503, 'SYS_001')
+ })
+
+ it('그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다', async () => {
+ const store = useMailSettingsStore()
+ await store.load()
+ expect([store.status, store.settings, store.groupByConversation]).toEqual(['error', null, true])
+
+ const saving = store.update('groupByConversation', false)
+ expect(store.groupByConversation).toBe(false)
+ expect(await saving).toBe(true)
+
+ expect(patches()).toEqual([{ groupByConversation: false }])
+ expect(store.status).toBe('ready')
+ expect(store.settings).toEqual({ ...SETTINGS, groupByConversation: false })
+ expect(store.groupByConversation).toBe(false)
+ expect(window.localStorage.getItem(groupByConversationHintKey(ARA.email))).toBe('false')
+ expect(store.saved).toBe(true)
+ })
+
+ it('저장도 실패하면 고르기 전의 짐작으로 되돌린다 — 알림은 인터셉터', async () => {
+ patch = () => fail(400, 'VAL_001')
+ const store = useMailSettingsStore()
+ await store.load()
+
+ expect(await store.update('groupByConversation', false)).toBe(false)
+
+ expect(store.groupByConversation).toBe(true)
+ expect(store.status).toBe('error')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
+ })
+
+ it('받은 값이 없으면 다른 칸(보내기 취소)은 고르지 않는다 — 받는 중에도 고르지 않는다', async () => {
+ const store = useMailSettingsStore()
+ await store.load()
+ expect(await store.update('undoSendSeconds', 5)).toBe(false)
+
+ load = () => ok(SETTINGS)
+ const loading = store.load()
+ expect(await store.update('groupByConversation', false)).toBe(false)
+ await loading
+ expect(patches()).toEqual([])
+ })
+ })
+})
diff --git a/frontend/src/stores/mail-settings.store.ts b/frontend/src/stores/mail-settings.store.ts
index b0335b0..5125c71 100644
--- a/frontend/src/stores/mail-settings.store.ts
+++ b/frontend/src/stores/mail-settings.store.ts
@@ -9,9 +9,40 @@ import type { LoadStatus } from '@/stores/mail.store'
type SettingKey = keyof MailSettings
+const GROUP_HINT_KEY_PREFIX = 'arasystem.mail.groupByConversation:'
+
+/** 묶어 보기의 짐작을 기억하는 localStorage 키 — 사용자 주소마다 따로 (보기 방식 mail-view.store 와 같은 결) */
+export function groupByConversationHintKey(email: string): string {
+ return `${GROUP_HINT_KEY_PREFIX}${email.trim().toLowerCase()}`
+}
+
+// 사생활 보호 모드 · 저장소 차단 등으로 localStorage 가 실패해도 짐작 없이(묶지 않음) 동작한다
+function readGroupHint(email: string | null): boolean {
+ if (!email) return false
+ try {
+ return window.localStorage.getItem(groupByConversationHintKey(email)) === 'true'
+ } catch {
+ return false
+ }
+}
+
+function writeGroupHint(email: string, grouped: boolean) {
+ try {
+ window.localStorage.setItem(groupByConversationHintKey(email), String(grouped))
+ } catch {
+ // 기억하지 못해도 이번 화면은 서버의 값으로 동작한다
+ }
+}
+
// 보내기 · 읽기 설정 스토어 — 계약 2절 "보내기 · 읽기 설정" (9단계 — 직원마다 서버 DB 에 하나, 어느 PC 에서나 같다)
// - 설정 › 메일 › 보내기 · 읽기는 들어올 때마다 새로 받는다(load) — 다른 PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
-// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다
+// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 세고,
+// 메일함 화면이 대화로 묶어 볼지(groupByConversation — 14단계) 정한다. 목록 머리의 [대화로 묶기]도 이 스토어의 update 로 저장한다.
+// 받기 전에는 이 브라우저가 마지막으로 안 값(localStorage — 사용자 주소마다)으로 짐작한다: 새로고침마다 메일 목록을 받았다가 버리고
+// 묶은 목록을 다시 받지 않게(엔진 조회 한 번을 아낀다). 받으면 서버의 값이 이기고 짐작을 고친다.
+// 받지 못했으면(error) 짐작대로 두고, 그때도 [대화로 묶기]는 고를 수 있다 — 그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다.
+// 실패하면 고르기 전의 짐작으로 되돌린다. 로그아웃 · 세션 만료(reset)는 화면이 보는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게
+// (2026-11-06 리뷰 M1). 다른 사람이 로그인하면 그 사람의 짐작으로 바뀐다
// - 고르는 대로 그 칸만 PATCH 한다(저장 단추 없음). 화면은 늘 마지막으로 고른 값을 보인다.
// **칸마다 저장은 한 번에 하나**(6단계 필터의 saving 과 같은 결) — 보내는 동안 다시 고르면 마지막으로 고른 것만 기억해 두고,
// 앞 저장이 끝난 뒤 그것이 서버의 답과 다르면 보낸다. 두 저장을 함께 보내면 서버가 나중에 처리한 쪽이 남는데, 응답 차례로는 그것을 알 수 없어
@@ -31,6 +62,10 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const saved = ref(false)
/** 저장하는 중인 칸 수 */
const pending = ref(0)
+ /** 설정을 받기 전의 묶어 보기 짐작 — 이 브라우저에서 이 사용자가 마지막으로 쓴 값 (위 머리말) */
+ const groupHint = ref(false)
+ /** 받지 못한 채(error) 고른 묶어 보기의 고르기 전 짐작 — 저장이 실패하면 이 값으로 되돌린다. 고른 저장이 없으면 null */
+ let hintBeforeChoice: boolean | null = null
/**
* 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
@@ -40,6 +75,14 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
status.value === 'ready' && settings.value ? settings.value.undoSendSeconds : null,
)
+ /**
+ * 메일함 목록을 대화로 묶어 보기 (14단계) — 받아 둔 설정의 값, 받기 전 · 받지 못했으면 짐작(이 브라우저가 이 사용자에게서 마지막으로 안 값,
+ * 처음이면 묶지 않음). 고르면 저장이 끝나기 전에 먼저 바뀐다(위 settings 와 같다) — 메일함 화면이 이 값으로 목록 출처를 정한다 (MailboxPage)
+ */
+ const groupByConversation = computed(() =>
+ settings.value ? settings.value.groupByConversation === true : groupHint.value,
+ )
+
/** 서버가 마지막으로 확인해 준 값 — 마지막으로 고른 것의 저장이 실패하면 그 칸을 이 값으로 되돌린다 */
let confirmed: MailSettings | null = null
/** 칸마다 마지막으로 고른 값 — 보내는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
@@ -67,6 +110,9 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
saved.value = false
// 저장하던 중에 떠났다 돌아왔으면 그 저장은 이 화면의 것이 아니다 — 세던 수가 남으면 "저장했습니다"가 다시 뜨지 않는다 (리뷰 #7)
forgetSaves()
+ // 받지 못한 채 고른 묶어 보기는 저장됐는지 모른다 — 짐작을 고르기 전으로 (받으면 서버의 값이 이긴다)
+ if (hintBeforeChoice !== null) groupHint.value = hintBeforeChoice
+ hintBeforeChoice = null
try {
const result = await settingsApi.getSettings({ silent: true })
if (current !== loadSeq) return settings.value
@@ -87,8 +133,28 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (status.value === 'idle' || status.value === 'error') void load()
}
+ /** 고를 수 있는가 — 받는 중 · 받기 전에는 못 한다. 받지 못했으면(error) 받아 둔 값이 있을 때, 없으면 짐작으로 보이는 묶어 보기만 */
+ function canChoose(key: SettingKey): boolean {
+ if (status.value === 'ready') return settings.value !== null
+ if (status.value !== 'error') return false
+ return settings.value !== null || key === 'groupByConversation'
+ }
+
+ /** 화면이 보이는 값 — 받아 둔 설정, 없으면(받지 못함) 묶어 보기는 짐작 */
+ function shownOf(key: K): MailSettings[K] | undefined {
+ if (settings.value) return settings.value[key]
+ return key === 'groupByConversation' ? (groupHint.value as MailSettings[K]) : undefined
+ }
+
function setKey(key: K, value: MailSettings[K]) {
if (settings.value) settings.value = { ...settings.value, [key]: value }
+ else if (key === 'groupByConversation') groupHint.value = value as boolean
+ }
+
+ /** 저장이 실패하면 되돌릴 값 — 서버가 마지막으로 확인해 준 값, 받지 못한 채 고른 묶어 보기는 고르기 전의 짐작 */
+ function fallbackOf(key: K): MailSettings[K] | undefined {
+ if (confirmed) return confirmed[key]
+ return key === 'groupByConversation' && hintBeforeChoice !== null ? (hintBeforeChoice as MailSettings[K]) : undefined
}
/** 그 칸의 저장을 보낸다 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
@@ -106,8 +172,15 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const result = await settingsApi.updateSettings({ [key]: value } as Partial)
if (!stillCurrent()) return false
succeeded = true
- confirmed = { ...(confirmed ?? result), [key]: result[key] }
const latest = wanted.get(key) as MailSettings[K]
+ // 받지 못한 채 고른 것이었으면 응답(저장한 뒤의 설정 전체)을 받은 설정으로 삼는다 — 고른 값은 그대로 보인다
+ if (!settings.value) {
+ settings.value = { ...result, [key]: latest }
+ status.value = 'ready'
+ error.value = null
+ }
+ hintBeforeChoice = null
+ confirmed = { ...(confirmed ?? result), [key]: result[key] }
// 그사이 다른 값을 골랐고 그것이 서버의 답과 다르면 이어 보낸다 — 아니면 서버의 답으로 맞추고 끝낸다
if (latest !== value && latest !== result[key]) next = latest
else setKey(key, result[key])
@@ -115,9 +188,10 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (!stillCurrent()) return false
succeeded = false
const latest = wanted.get(key) as MailSettings[K]
- // 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값으로 되돌린다
- if (latest !== value && latest !== confirmed?.[key]) next = latest
- else if (confirmed) setKey(key, confirmed[key])
+ const fallback = fallbackOf(key)
+ // 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값(받지 못했으면 고르기 전 짐작)으로 되돌린다
+ if (latest !== value && latest !== fallback) next = latest
+ else if (fallback !== undefined) setKey(key, fallback)
}
if (next === undefined) break
value = next
@@ -127,6 +201,7 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (stillCurrent()) {
wanted.delete(key)
chains.delete(key)
+ if (key === 'groupByConversation') hintBeforeChoice = null
lastSucceeded = succeeded
pending.value = Math.max(0, pending.value - 1)
if (pending.value === 0) saved.value = lastSucceeded
@@ -136,14 +211,15 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
/**
* 한 칸을 고른다 — 화면을 먼저 바꾸고 PATCH { key: value }. 그 칸의 저장이 이미 돌고 있으면 보내지 않고 고른 값만 기억한다
- * (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받은 설정이 없으면 보내지 않는다(false).
- * 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
+ * (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받는 중 · 받기 전이면 보내지 않는다(false). 받지 못했으면(error) 받아 둔 값이 없어도
+ * 묶어 보기는 짐작 위에서 고른다(canChoose). 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
*/
function update(key: K, value: MailSettings[K]): Promise {
- if (!settings.value || status.value !== 'ready') return Promise.resolve(false)
+ if (!canChoose(key)) return Promise.resolve(false)
const running = chains.get(key)
// 지금 값을 다시 눌렀다 — 보낼 것이 없다
- if (!running && settings.value[key] === value) return Promise.resolve(true)
+ if (!running && shownOf(key) === value) return Promise.resolve(true)
+ if (!running && !settings.value && key === 'groupByConversation') hintBeforeChoice = groupHint.value
setKey(key, value)
wanted.set(key, value)
saved.value = false
@@ -155,6 +231,9 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
function reset() {
loadSeq += 1
+ // 화면이 보는 묶어 보기는 그대로 둔다 — 로그아웃 · 세션 만료 직후 떠나는 메일 화면이 다른 목록을 부르지 않게 (리뷰 M1)
+ groupHint.value = groupByConversation.value
+ hintBeforeChoice = null
settings.value = null
confirmed = null
forgetSaves()
@@ -169,5 +248,36 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
() => reset(),
)
- return { settings, status, error, saved, pending, knownUndoSendSeconds, load, ensureLoaded, update, reset }
+ // 묶어 보기의 짐작 — 로그인한 사용자의 것을 읽고(로그아웃은 그대로 둔다 — reset), 받거나 고른 값이 생기면 기억해 둔다
+ watch(
+ () => authStore.user?.email ?? null,
+ (email) => {
+ if (email) groupHint.value = readGroupHint(email)
+ },
+ { immediate: true },
+ )
+ watch(
+ () => settings.value?.groupByConversation,
+ (grouped) => {
+ const email = authStore.user?.email
+ if (email && grouped !== undefined) {
+ groupHint.value = grouped
+ writeGroupHint(email, grouped)
+ }
+ },
+ )
+
+ return {
+ settings,
+ status,
+ error,
+ saved,
+ pending,
+ knownUndoSendSeconds,
+ groupByConversation,
+ load,
+ ensureLoaded,
+ update,
+ reset,
+ }
})
diff --git a/frontend/src/stores/mail.store.threads.spec.ts b/frontend/src/stores/mail.store.threads.spec.ts
new file mode 100644
index 0000000..de46c7c
--- /dev/null
+++ b/frontend/src/stores/mail.store.threads.spec.ts
@@ -0,0 +1,541 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+
+import type { ConversationSummary, Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import {
+ fail,
+ flushPromises,
+ installMockApi,
+ ok,
+ restoreApiAdapter,
+ type MockRequest,
+ type MockResult,
+} from '@/test-utils/mock-api'
+import { useMailStore } from './mail.store'
+
+// 14단계 3차 — 목록을 대화로 묶어 보기 (계약 2절 "대화로 묶어 보기"): 출처 · 늦은 응답 · 더 보기 · 맞추기 · 고르기 · 여러 통 처리
+
+const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
+
+function mailbox(id: string, role: Mailbox['role'], name: string, unreadCount = 0): Mailbox {
+ return { id, name, role, parentId: null, sortOrder: 0, totalCount: 10, unreadCount }
+}
+
+const MAILBOXES = [
+ mailbox('inbox', 'inbox', '받은편지함', 2),
+ mailbox('sent', 'sent', '보낸편지함'),
+ mailbox('archive', 'archive', '보관함'),
+ mailbox('trash', 'trash', '휴지통'),
+]
+
+function summary(id: string, partial: Partial = {}): MessageSummary {
+ return {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: ['inbox'],
+ from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
+ to: [],
+ subject: `제목 ${id}`,
+ preview: '',
+ receivedAt: '2026-11-05T01:00:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend: null,
+ ...partial,
+ }
+}
+
+function conversation(id: string, messageIds: string[], partial: Partial = {}): ConversationSummary {
+ return {
+ id,
+ latest: summary(messageIds[0] ?? id),
+ participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ messageCount: messageIds.length,
+ messageIds,
+ pendingSendIds: [],
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ ...partial,
+ }
+}
+
+function threadsPage(items: ConversationSummary[], nextCursor: string | null = null, total: number | null = items.length) {
+ return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
+}
+
+function messagesPage(items: MessageSummary[]) {
+ return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
+}
+
+/** 응답을 테스트가 정할 때까지 붙잡아 둔다 */
+function held() {
+ let resolve: (result: MockResult) => void = () => undefined
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+const rowIds = (rows: readonly { id: string }[]) => rows.map((row) => row.id)
+const threadRequests = (requests: MockRequest[]) => requests.filter((request) => request.url === '/conversations')
+const patches = (requests: MockRequest[]) =>
+ requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
+
+describe('mail.store — 대화로 묶어 보기 (14단계)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated(USER)
+ })
+
+ afterEach(() => {
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ /** 메일함 목록과 묶은 목록의 첫 쪽을 받은 상태로 — handler 가 undefined 를 주면 기본 응답 */
+ async function openThreads(
+ rows: ConversationSummary[],
+ handler?: (request: MockRequest) => MockResult | Promise | undefined,
+ ) {
+ const requests = installMockApi((request) => {
+ const custom = handler?.(request)
+ if (custom) return custom
+ if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
+ if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
+ if (request.url === '/conversations') return threadsPage(rows)
+ if (request.url === '/messages' && request.method === 'GET') return messagesPage([])
+ if (request.url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
+ if (request.url === '/messages' && request.method === 'PATCH') {
+ return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
+ }
+ return ok(null)
+ })
+ const store = useMailStore()
+ await store.loadMailboxes()
+ await store.openConversations('inbox')
+ return { store, requests }
+ }
+
+ describe('출처 · 늦은 응답', () => {
+ it('켜면 GET /conversations?mailboxId= 의 첫 쪽 — 한 줄이 대화 하나, 메일 목록은 비어 있고 가리키는 메일함은 그 메일함', async () => {
+ const { store, requests } = await openThreads([conversation('c1', ['m3', 'm2']), conversation('c2', ['m1'])])
+
+ expect(threadRequests(requests).map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
+ expect(threadRequests(requests)[0]?.config.silent).toBe(true)
+ expect(store.isConversationList).toBe(true)
+ expect(rowIds(store.conversations)).toEqual(['c1', 'c2'])
+ expect(store.messages).toEqual([])
+ expect(store.rowCount).toBe(2)
+ expect(store.currentMailboxId).toBe('inbox')
+ expect(store.listedSummaries.map((message) => message.id)).toEqual(['m3', 'm1'])
+ expect(store.pageInfo?.total).toBe(2)
+ })
+
+ it('켜고 끌 때 앞 목록의 응답을 버리고 처음 쪽부터 받는다 — 늦게 온 메일 목록이 묶은 목록을 덮지 않는다', async () => {
+ const messages = held()
+ const { store, requests } = await openThreads([conversation('c1', ['m1'])], (request) =>
+ request.url === '/messages' && request.method === 'GET' ? messages.promise : undefined,
+ )
+ const opening = store.openMailbox('inbox')
+ expect(store.listStatus).toBe('loading')
+ expect(store.conversations).toEqual([])
+
+ const reopened = store.openConversations('inbox')
+ messages.resolve(messagesPage([summary('late')]))
+ await Promise.all([opening, reopened])
+
+ expect(store.messages).toEqual([])
+ expect(rowIds(store.conversations)).toEqual(['c1'])
+ expect(threadRequests(requests)).toHaveLength(2)
+ })
+
+ it('끄면 메일 목록으로 — 보이는 채로 새로 고치던 묶은 목록의 늦은 응답은 버린다', async () => {
+ const threads = held()
+ let calls = 0
+ const { store } = await openThreads([conversation('c1', ['m1'])], (request) => {
+ if (request.url !== '/conversations') return undefined
+ calls += 1
+ return calls === 1 ? undefined : threads.promise
+ })
+ const refreshing = store.openConversations('inbox')
+ const switched = store.openMailbox('inbox')
+ threads.resolve(threadsPage([conversation('late', ['m9'])]))
+ await Promise.all([refreshing, switched])
+
+ expect(store.isConversationList).toBe(false)
+ expect(store.conversations).toEqual([])
+ })
+
+ it('버린 목록 요청은 끊는다 — 켜고 끌 때 · 메일함을 바꿀 때 · 세션이 끝날 때 (리뷰 L7)', async () => {
+ const messages = held()
+ const threads = held()
+ const { store, requests } = await openThreads([], (request) => {
+ if (request.url === '/messages' && request.method === 'GET') return messages.promise
+ if (request.url === '/conversations' && request.params.mailboxId === 'archive') return threads.promise
+ return undefined
+ })
+ const signals = (url: string) => requests.filter((request) => request.url === url).map((request) => request.config.signal)
+
+ // 요청이 나간 뒤 바꾼다 (나가기 전에 바꾸면 보내지도 않는다)
+ void store.openMailbox('inbox')
+ await flushPromises()
+ void store.openConversations('archive')
+ await flushPromises()
+ expect(signals('/messages').at(-1)?.aborted).toBe(true)
+ expect(signals('/conversations').at(-1)?.aborted).toBe(false)
+
+ useAuthStore().clearAuth()
+ await flushPromises()
+ expect(signals('/conversations').at(-1)?.aborted).toBe(true)
+ messages.resolve(messagesPage([summary('late')]))
+ threads.resolve(threadsPage([conversation('late', ['m9'])]))
+ await flushPromises()
+ expect(store.messages).toEqual([])
+ expect(store.conversations).toEqual([])
+ })
+
+ it('세션이 바뀌면(다른 계정) 늦게 온 묶은 목록을 버린다', async () => {
+ const threads = held()
+ const { store } = await openThreads([], (request) =>
+ request.url === '/conversations' && request.params.mailboxId === 'archive' ? threads.promise : undefined,
+ )
+ void store.openConversations('archive')
+ useAuthStore().markAuthenticated({ email: 'other@arasystem.test', name: '다른 사람' })
+ await flushPromises()
+ threads.resolve(threadsPage([conversation('late', ['m9'])]))
+ await flushPromises()
+
+ expect(store.conversations).toEqual([])
+ expect(store.listSource).toBeNull()
+ })
+ })
+
+ describe('더 보기 · 맞추기', () => {
+ it('더 보기 — 커서를 그대로 보내고 이미 있는 줄은 건너뛴다. 다음 쪽의 total 이 없으면 첫 쪽의 수를 남긴다', async () => {
+ const { store, requests } = await openThreads([], (request) => {
+ if (request.url !== '/conversations') return undefined
+ return request.params.cursor === 'p2'
+ ? threadsPage([conversation('c2', ['m2']), conversation('c3', ['m1'])], null, null)
+ : threadsPage([conversation('c1', ['m3']), conversation('c2', ['m2'])], 'p2', 6)
+ })
+ await store.openConversations('sent')
+ expect(store.hasMore).toBe(true)
+
+ await store.loadMore()
+
+ expect(threadRequests(requests).at(-1)?.params).toEqual({ mailboxId: 'sent', cursor: 'p2', limit: 20 })
+ expect(rowIds(store.conversations)).toEqual(['c1', 'c2', 'c3'])
+ expect(store.pageInfo?.total).toBe(6)
+ expect(store.hasMore).toBe(false)
+ })
+
+ it('새 메일 확인 — 불러온 줄 수만큼(최대 100줄) 조용히 다시 받고, 맨 위 새 메일 수를 알린다', async () => {
+ let rows = [conversation('c1', ['m2']), conversation('c2', ['m1'])]
+ const { store, requests } = await openThreads([], (request) => (request.url === '/conversations' ? threadsPage(rows) : undefined))
+ await store.openConversations('sent')
+
+ rows = [conversation('c2', ['m5', 'm4', 'm1']), conversation('c1', ['m2'])]
+ await store.syncVisibleList()
+
+ const last = threadRequests(requests).at(-1)
+ expect(last?.params).toEqual({ mailboxId: 'sent', limit: 20 })
+ expect(last?.config.silent).toBe(true)
+ expect(rowIds(store.conversations)).toEqual(['c2', 'c1'])
+ expect(store.listSync?.addedAbove).toBe(2)
+ })
+
+ it('맞추는 사이 이 탭이 줄을 바꿨으면 그 응답은 버리고 한 번 더 맞춘다 — 보내는 중인 읽음이 옛 값을 덮어쓴다', async () => {
+ const sync = held()
+ const patch = held()
+ let calls = 0
+ const unread = () => threadsPage([conversation('c1', ['m2', 'm1'], { isRead: false })])
+ const { store } = await openThreads([], (request) => {
+ if (request.url === '/conversations') {
+ calls += 1
+ return calls === 2 ? sync.promise : unread()
+ }
+ if (request.url === '/messages' && request.method === 'PATCH') return patch.promise
+ return undefined
+ })
+ expect(store.conversations[0]?.isRead).toBe(false)
+
+ const syncing = store.syncVisibleList()
+ store.toggleCheck('c1')
+ const marking = store.patchChecked({ isRead: true })
+ expect(store.conversations[0]?.isRead).toBe(true)
+
+ // 서버는 아직 옛 값(안 읽음)을 준다 — 이 탭이 바꾼 뒤라 버리고 한 번 더 받는다
+ sync.resolve(unread())
+ await syncing
+ expect(calls).toBe(3)
+ expect(store.conversations[0]?.isRead).toBe(true)
+
+ patch.resolve(ok({ updatedIds: ['m2', 'm1'], failedIds: [] }))
+ await marking
+ expect(store.conversations[0]?.isRead).toBe(true)
+ })
+ })
+
+ describe('고르기 — 붙잡힌 메일은 뺀다', () => {
+ const ROWS = [
+ conversation('c1', ['m4', 'm3'], { pendingSendIds: ['m4'] }),
+ conversation('held', ['m2'], { pendingSendIds: ['m2'] }),
+ conversation('c3', ['m1']),
+ ]
+
+ it('메일이 모두 붙잡힌 줄은 고를 수 없다 — 하나 · 모두 고르기가 건너뛴다', async () => {
+ const { store } = await openThreads(ROWS)
+
+ store.toggleCheck('held')
+ expect(store.checkedCount).toBe(0)
+ store.toggleCheckAll()
+ expect([...store.checkedIds]).toEqual(['c1', 'c3'])
+ expect(store.checkState).toBe('all')
+ })
+
+ it('여러 통 처리가 보낼 메일은 고른 줄의 메일에서 붙잡힌 메일을 뺀 것 — 도구 줄의 수는 줄 수다', async () => {
+ const { store } = await openThreads(ROWS)
+
+ store.toggleCheckAll()
+ expect(store.checkedCount).toBe(2)
+ expect(store.checkedActionIds).toEqual(['m3', 'm1'])
+ expect(store.checkedMessages).toEqual([])
+ expect(rowIds(store.checkedConversations)).toEqual(['c1', 'c3'])
+ })
+
+ it('켜고 끄면 고른 것을 푼다', async () => {
+ const { store } = await openThreads(ROWS)
+ store.toggleCheck('c3')
+ await store.openMailbox('inbox')
+ expect(store.checkedCount).toBe(0)
+ })
+ })
+
+ describe('여러 통 처리', () => {
+ it('읽음 표시 — 고른 줄의 메일을 100통씩 PATCH /messages, 줄을 먼저 바꾸고 끝나면 메일함 개수를 다시 받는다', async () => {
+ const many = Array.from({ length: 150 }, (_, index) => `m${index}`)
+ const { store, requests } = await openThreads([conversation('big', many, { isRead: false })])
+ const mailboxLoads = requests.filter((request) => request.url === '/mailboxes').length
+
+ store.toggleCheck('big')
+ const marking = store.patchChecked({ isRead: true })
+ expect(store.conversations[0]?.isRead).toBe(true)
+ await marking
+ await flushPromises()
+
+ expect(patches(requests).map((patch) => [patch.ids.length, patch.isRead])).toEqual([
+ [100, true],
+ [50, true],
+ ])
+ expect(patches(requests).flatMap((patch) => patch.ids)).toEqual(many)
+ expect(requests.filter((request) => request.url === '/mailboxes').length).toBe(mailboxLoads + 1)
+ // 고른 것은 그대로 둔다
+ expect(store.checkedCount).toBe(1)
+ })
+
+ it('서버가 바꾸지 못한 메일이 든 줄은 앞 값으로 되돌리고 목록을 다시 맞춘다', async () => {
+ const sync = held()
+ let calls = 0
+ const { store, requests } = await openThreads(
+ [conversation('c1', ['m2', 'm1'], { isRead: false }), conversation('c2', ['m3'], { isRead: false })],
+ (request) => {
+ if (request.url === '/conversations' && ++calls > 1) return sync.promise
+ return request.url === '/messages' && request.method === 'PATCH'
+ ? ok({ updatedIds: ['m2', 'm3'], failedIds: ['m1'] })
+ : undefined
+ },
+ )
+
+ store.toggleCheckAll()
+ await store.patchChecked({ isRead: true })
+ await flushPromises()
+
+ // 이 탭이 먼저 되돌린다 — m1 을 바꾸지 못한 c1 만
+ expect(store.conversations.map((row) => [row.id, row.isRead])).toEqual([
+ ['c1', false],
+ ['c2', true],
+ ])
+ // 그리고 서버 기준으로 다시 맞춘다
+ expect(threadRequests(requests)).toHaveLength(2)
+ sync.resolve(threadsPage([conversation('c1', ['m2', 'm1'], { isRead: false }), conversation('c2', ['m3'])]))
+ await flushPromises()
+ expect(store.conversations.map((row) => [row.id, row.isRead])).toEqual([
+ ['c1', false],
+ ['c2', true],
+ ])
+ })
+
+ it('옮기기 — 고른 줄의 메일을 보내고 다 옮긴 줄은 빼며 대화 수를 줄인다. 되돌리기는 그 메일함으로, 끝나면 목록을 다시 맞춘다', async () => {
+ const sync = held()
+ let calls = 0
+ const { store, requests } = await openThreads(
+ [conversation('c1', ['m4', 'm3'], { pendingSendIds: ['m4'] }), conversation('c2', ['m2', 'm1'])],
+ (request) => (request.url === '/conversations' && ++calls > 1 ? sync.promise : undefined),
+ )
+
+ store.toggleCheckAll()
+ const result = await store.moveChecked('archive')
+ await flushPromises()
+
+ expect(patches(requests)).toEqual([{ ids: ['m3', 'm2', 'm1'], mailboxId: 'archive' }])
+ // 붙잡힌 메일(m4)이 남은 줄은 남는다
+ expect(rowIds(store.conversations)).toEqual(['c1'])
+ expect(store.conversations[0]?.messageIds).toEqual(['m4'])
+ expect(store.pageInfo?.total).toBe(1)
+ expect(result.origins).toEqual([{ mailboxId: 'inbox', ids: ['m3', 'm2', 'm1'] }])
+ expect(store.checkedCount).toBe(0)
+ // 남은 줄의 마지막 메일 · 통 수를 서버 값으로 — 목록을 다시 맞춘다
+ expect(threadRequests(requests)).toHaveLength(2)
+ sync.resolve(threadsPage([conversation('c1', ['m4'], { pendingSendIds: ['m4'], messageCount: 2 })]))
+ await flushPromises()
+ expect(store.conversations.map((row) => [row.id, row.messageCount])).toEqual([['c1', 2]])
+ })
+
+ it('영구 삭제 — 고른 줄의 메일을 POST /message-deletions', async () => {
+ const { store, requests } = await openThreads([conversation('c1', ['m2', 'm1']), conversation('c2', ['m0'])])
+
+ store.toggleCheck('c1')
+ await store.purgeChecked()
+
+ expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
+ { ids: ['m2', 'm1'] },
+ ])
+ expect(rowIds(store.conversations)).toEqual(['c2'])
+ })
+
+ it('줄의 별 — 중요 표시한 메일이 있으면 모두 풀고 없으면 모두 표시한다. 메일이 모두 붙잡힌 줄은 보내지 않는다', async () => {
+ const { store, requests } = await openThreads([
+ conversation('c1', ['m3', 'm2'], { pendingSendIds: ['m3'] }),
+ conversation('c2', ['m1'], { isFlagged: true }),
+ conversation('held', ['m0'], { pendingSendIds: ['m0'] }),
+ ])
+
+ await store.toggleConversationFlag('c1')
+ await store.toggleConversationFlag('c2')
+ await store.toggleConversationFlag('held')
+
+ expect(patches(requests)).toEqual([
+ { ids: ['m2'], isFlagged: true },
+ { ids: ['m1'], isFlagged: false },
+ ])
+ expect(store.conversations.map((row) => row.isFlagged)).toEqual([true, false, false])
+ })
+ })
+
+ describe('별 — 줄의 중요 표시가 붙잡힌 메일에만 있을 때 (리뷰 L1)', () => {
+ it('붙잡힌 메일이 든 줄을 풀면 줄을 미리 풀지 않고 서버 기준으로 한 번 맞춘다 — 그래도 중요 표시면 heldFlagged', async () => {
+ let rows = [conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] })]
+ const { store, requests } = await openThreads([], (request) => (request.url === '/conversations' ? threadsPage(rows) : undefined))
+ await store.openConversations('sent')
+ const syncs = threadRequests(requests).length
+
+ const result = await store.toggleConversationFlag('c1')
+
+ expect(patches(requests)).toEqual([{ ids: ['m1'], isFlagged: false }])
+ expect(threadRequests(requests).length).toBe(syncs + 1)
+ expect(result).toEqual({ heldFlagged: true })
+ expect(store.conversations[0]?.isFlagged).toBe(true)
+
+ // 붙잡힌 메일에는 표시가 없었다 — 다시 맞추면 풀린다
+ rows = [conversation('c1', ['m2', 'm1'], { isFlagged: false, pendingSendIds: ['m2'] })]
+ store.conversations.splice(0, 1, conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] }))
+ expect(await store.toggleConversationFlag('c1')).toEqual({ heldFlagged: false })
+ expect(store.conversations[0]?.isFlagged).toBe(false)
+ })
+
+ it('붙잡힌 메일이 없는 줄은 다시 맞추지 않는다', async () => {
+ const { store, requests } = await openThreads([conversation('c1', ['m2', 'm1'], { isFlagged: true })])
+ const syncs = threadRequests(requests).length
+
+ expect(await store.toggleConversationFlag('c1')).toEqual({ heldFlagged: false })
+ expect(store.conversations[0]?.isFlagged).toBe(false)
+ expect(threadRequests(requests).length).toBe(syncs)
+ })
+
+ it('읽기 화면의 별(한 통)이 실패하면 그 메일이 든 줄을 앞 값 그대로 되돌린다', async () => {
+ const { store, requests } = await openThreads(
+ [conversation('c1', ['m3', 'm2'], { isFlagged: false, latest: summary('m3', { isFlagged: false }) })],
+ (request) => (request.method === 'PATCH' && request.url === '/messages/m3' ? fail(503, 'MAIL_001') : undefined),
+ )
+
+ const toggling = store.toggleFlag('m3')
+ expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, true])
+ await toggling
+
+ expect(requests.filter((request) => request.url === '/messages/m3')).toHaveLength(1)
+ expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([false, false])
+ })
+
+ it('읽기 화면의 별(한 통)로 줄의 다른 메일이 중요인 줄을 풀어도 줄은 중요 그대로다 — 실패하면 앞 값', async () => {
+ const { store } = await openThreads(
+ [conversation('c1', ['m3', 'm2'], { isFlagged: true, latest: summary('m3', { isFlagged: true }) })],
+ (request) => (request.method === 'PATCH' && request.url === '/messages/m3' ? fail(503, 'MAIL_001') : undefined),
+ )
+
+ const toggling = store.toggleFlag('m3')
+ expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, false])
+ await toggling
+ expect([store.conversations[0]?.isFlagged, store.conversations[0]?.latest.isFlagged]).toEqual([true, true])
+ })
+ })
+
+ describe('읽기 화면과 함께', () => {
+ it('연 메일을 읽음으로 — 한 통뿐인 줄은 줄도 읽음, 여러 통인 줄은 다른 메일을 몰라 그대로(다음 맞추기가 고친다)', async () => {
+ const { store } = await openThreads(
+ [
+ conversation('one', ['m1'], { isRead: false, latest: summary('m1', { isRead: false }) }),
+ conversation('two', ['m3', 'm2'], { isRead: false, latest: summary('m3', { isRead: false }) }),
+ ],
+ (request) => {
+ if (request.method === 'PATCH' && request.url.startsWith('/messages/')) {
+ return ok(summary(request.url.slice('/messages/'.length), { isRead: true }))
+ }
+ return undefined
+ },
+ )
+
+ await store.markRead({ id: 'm1', isRead: false, mailboxIds: ['inbox'] })
+ await store.markRead({ id: 'm3', isRead: false, mailboxIds: ['inbox'] })
+
+ expect(store.conversations.map((row) => [row.id, row.isRead, row.latest.isRead])).toEqual([
+ ['one', true, true],
+ ['two', false, true],
+ ])
+ })
+
+ it('줄 기준의 도우미 — 메일이 든 줄 · 그 줄을 누르면 열 메일 · 목록에 든 메일 · 뺀 뒤 이어서 열 메일', async () => {
+ const { store } = await openThreads([conversation('c1', ['m5']), conversation('c2', ['m4', 'm3']), conversation('c3', ['m2'])])
+
+ expect(store.rowIndexOfMessage('m3')).toBe(1)
+ expect(store.rowMessageIdAt(2)).toBe('m2')
+ expect(store.rowMessageIdAt(3)).toBeNull()
+ expect(store.isListedMessage('m3')).toBe(true)
+ expect(store.neighborAfterRemoval('m4')).toBe('m3')
+ expect(store.neighborAfterRemoval('m5')).toBe('m4')
+ })
+
+ it('읽기 화면의 삭제(한 통) — 줄에 메일이 남으면 줄은 남고 그 메일만 빠진다(목록에 든 메일이 아니다), 목록을 다시 맞춘다', async () => {
+ const sync = held()
+ let calls = 0
+ const { store, requests } = await openThreads([conversation('c1', ['m4', 'm3'])], (request) =>
+ request.url === '/conversations' && ++calls > 1 ? sync.promise : undefined,
+ )
+
+ await store.moveMessages([summary('m4')], 'trash')
+ await flushPromises()
+
+ expect(rowIds(store.conversations)).toEqual(['c1'])
+ expect(store.conversations[0]?.messageIds).toEqual(['m3'])
+ expect(store.isListedMessage('m4')).toBe(false)
+ expect(threadRequests(requests)).toHaveLength(2)
+ sync.resolve(threadsPage([conversation('c1', ['m3'])]))
+ await flushPromises()
+ expect(store.conversations[0]?.latest.id).toBe('m3')
+ })
+ })
+})
diff --git a/frontend/src/stores/mail.store.ts b/frontend/src/stores/mail.store.ts
index 959a754..859a8d6 100644
--- a/frontend/src/stores/mail.store.ts
+++ b/frontend/src/stores/mail.store.ts
@@ -17,7 +17,7 @@ export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptio
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
export type { ConversationDetailState } from './mail/conversation'
-// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
+// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
@@ -42,7 +42,7 @@ export const useMailStore = defineStore('mail', () => {
const list = createListState({ mailApi, mailboxes: mailboxState, opened })
const selection = createSelection(list)
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
- const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
+ const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection, opened })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
const scheduled = createScheduledState({
mailApi,
@@ -170,6 +170,14 @@ export const useMailStore = defineStore('mail', () => {
searchOrigin: list.searchOrigin,
searchIndexStatus: list.searchIndexStatus,
messages: list.messages,
+ // 대화로 묶은 메일함 (14단계) — 줄 기준의 도우미는 메일 목록에서도 그대로 쓴다
+ conversations: list.conversations,
+ isConversationList: list.isConversationList,
+ rowCount: list.rowCount,
+ listedSummaries: list.listedSummaries,
+ rowIndexOfMessage: list.rowIndexOfMessage,
+ rowMessageIdAt: list.rowMessageIdAt,
+ isListedMessage: list.isListedMessage,
pageInfo: list.pageInfo,
listStatus: list.listStatus,
listError: list.listError,
@@ -179,6 +187,7 @@ export const useMailStore = defineStore('mail', () => {
hasMore: list.hasMore,
neighborAfterRemoval: list.neighborAfterRemoval,
openMailbox: list.openMailbox,
+ openConversations: list.openConversations,
openScheduled: list.openScheduled,
openSearch: list.openSearch,
rememberSearchOrigin: list.rememberSearchOrigin,
@@ -189,6 +198,8 @@ export const useMailStore = defineStore('mail', () => {
checkedIds: selection.checkedIds,
checkedCount: selection.checkedCount,
checkedMessages: selection.checkedMessages,
+ checkedConversations: selection.checkedConversations,
+ checkedActionIds: selection.checkedActionIds,
checkState: selection.checkState,
toggleCheck: selection.toggleCheck,
checkRangeTo: selection.checkRangeTo,
@@ -240,6 +251,7 @@ export const useMailStore = defineStore('mail', () => {
retryConversationDetail: conversation.retryConversationDetail,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
+ toggleConversationFlag: bulk.toggleConversationFlag,
moveMessages: bulk.moveMessages,
moveChecked: bulk.moveChecked,
purgeMessages: bulk.purgeMessages,
diff --git a/frontend/src/stores/mail/bulk.ts b/frontend/src/stores/mail/bulk.ts
index 1c04be5..d33eca5 100644
--- a/frontend/src/stores/mail/bulk.ts
+++ b/frontend/src/stores/mail/bulk.ts
@@ -3,8 +3,11 @@
// (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다).
// 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있으면(sourceSeq) 그 목록 · 고른 것은 건드리지 않고, 개수와
// (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞춘다 (settleStale).
+// 대화로 묶은 메일함(14단계 — 계약 2절 "대화로 묶어 보기")은 고른 줄의 메일에서 붙잡힌 메일을 뺀 것(selection 의 checkedActionIds)을
+// 같은 여러 통 API 로 100통씩 보낸다. 줄마다 어느 메일이 안 읽었는지 몰라 메일함 개수는 끝난 뒤 서버 기준으로 맞추고,
+// 옮기거나 지운 뒤에는 목록을 다시 맞춘다 — 메일이 남은 줄의 마지막 메일 · 통 수를 서버 값으로 (list.syncVisibleList)
import { MAIL_BULK_MAX_IDS } from '@/shared/constants/mail'
-import type { MessageSummary } from '@/shared/types/mail'
+import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import {
emptyBulkResult,
groupByOrigin,
@@ -13,9 +16,11 @@ import {
type BulkOrigin,
type BulkResult,
} from '@/shared/utils/mail-bulk'
+import { conversationActionIds, conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
+import type { OpenedMessage } from './message'
import type { SelectionState } from './selection'
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult, MoveOptions } from './types'
@@ -24,8 +29,12 @@ interface BulkDeps {
mailboxes: MailboxesState
list: ListState
selection: SelectionState
+ opened: OpenedMessage
}
+/** 옮기기에 쓰는 메일 — id 와 되돌릴 곳을 정할 메일함만 본다 (대화 목록은 줄의 메일 id 만 안다) */
+type MoveTarget = Pick
+
/** 요청 하나의 ids 한도(100)로 나눠 차례로 보낸다 */
function sendBulk(ids: readonly string[], send: BulkChunkSender): Promise {
return sendInChunks(ids, MAIL_BULK_MAX_IDS, send)
@@ -35,7 +44,7 @@ function hasLeftovers(result: BulkResult): boolean {
return result.skippedIds.length > 0 || result.unsentIds.length > 0
}
-export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkDeps) {
+export function createBulkActions({ mailApi, mailboxes, list, selection, opened }: BulkDeps) {
/** 지금 목록이 시작할 때의 목록인지 — 아니면 끝난 일을 그 목록에 적용하지 않는다 */
function captureList(): () => boolean {
const identity = list.sourceSeq.value
@@ -57,6 +66,7 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
* 고른 것은 그대로 둔다. 알림은 띄우지 않는다 (목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
*/
async function patchChecked(changes: FlagChanges): Promise {
+ if (list.isConversationList.value) return patchConversations(selection.checkedConversations.value, changes)
const targets = selection.checkedMessages.value
if (targets.length === 0) return { ...emptyBulkResult(), stale: false }
const isStale = captureList()
@@ -93,8 +103,68 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
return { ...result, stale: false }
}
+ /**
+ * 대화 줄의 읽음 · 중요 표시 (14단계) — 줄의 메일(붙잡힌 메일은 빼고)에 보낸다. 화면을 먼저 바꾸고, 서버가 바꾸지 못했거나 보내지 못한
+ * 메일이 든 줄은 앞 값으로 되돌린 뒤 목록을 서버 기준으로 다시 맞춘다(줄의 다른 메일은 바뀌었을 수 있다). 고른 것은 그대로 둔다.
+ * 안 읽은 수는 줄마다 몰라 미리 고치지 않고 끝난 뒤 메일함 목록을 다시 받는다. 줄의 별(중요 표시)도 이 길이다 (toggleConversationFlag)
+ */
+ async function patchConversations(
+ rows: readonly ConversationSummary[],
+ changes: FlagChanges,
+ ): Promise {
+ const ids = conversationActionIdsOf(rows)
+ if (ids.length === 0) return { ...emptyBulkResult(), stale: false }
+ const isStale = captureList()
+ const targets = new Set(ids)
+ // 줄은 바꿀 때마다 새 객체로 바꿔 끼우므로(withConversationFlags) 받은 그대로가 앞 값이다
+ const originals = [...rows]
+ const current = opened.selected.value
+ const openedBefore =
+ current && targets.has(current.id) ? { id: current.id, isRead: current.isRead, isFlagged: current.isFlagged } : null
+
+ list.setFlagsOf(targets, changes)
+ list.beginPending(ids, changes)
+ const result = await sendBulk(ids, async (chunkIds) => {
+ const patched = await mailApi.updateMessages({ ids: chunkIds, ...changes })
+ return { done: patched.updatedIds, skipped: patched.failedIds }
+ })
+ list.endPending(ids, changes)
+
+ if (isStale()) {
+ settleStale()
+ return { ...result, stale: true }
+ }
+ const failed = new Set([...result.skippedIds, ...result.unsentIds])
+ if (failed.size > 0) {
+ list.restoreConversationFlags(originals.filter((row) => conversationActionIds(row).some((id) => failed.has(id))))
+ const shown = opened.selected.value
+ if (openedBefore && shown?.id === openedBefore.id && failed.has(openedBefore.id)) {
+ opened.selected.value = { ...shown, isRead: openedBefore.isRead, isFlagged: openedBefore.isFlagged }
+ }
+ void list.syncVisibleList()
+ }
+ if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
+ return { ...result, stale: false }
+ }
+
+ /**
+ * 대화 줄의 별 — 줄에 중요 표시한 메일이 있으면 모두 풀고, 없으면 모두 표시한다 (메일이 모두 붙잡힌 줄은 바꾸지 않는다).
+ * 붙잡힌 메일은 빼고 보내므로(계약) 그 메일이 든 줄을 풀었으면 서버 기준으로 한 번 맞춰 본다 — 줄의 중요 표시가 붙잡힌 메일에만 있었으면
+ * 줄은 그대로 중요 표시이고, heldFlagged 로 알린다(화면이 그 까닭을 알린다 — 메일을 열어 바꾼다)
+ */
+ async function toggleConversationFlag(rowId: string): Promise<{ heldFlagged: boolean }> {
+ const row = list.conversations.value.find((entry) => entry.id === rowId)
+ if (!row || isConversationHeld(row)) return { heldFlagged: false }
+ const flag = !row.isFlagged
+ const result = await patchConversations([row], { isFlagged: flag })
+ const hasHeld = conversationActionIds(row).length < row.messageIds.length
+ if (flag || !hasHeld || result.stale || result.unsentIds.length > 0) return { heldFlagged: false }
+ await list.syncVisibleList()
+ return { heldFlagged: list.conversations.value.find((entry) => entry.id === rowId)?.isFlagged === true }
+ }
+
/** 되돌리기에 쓸 원래 메일함 — 목록이 가리키는 메일함, 없으면(전체 검색) 옮길 곳이 아닌 첫 메일함 */
- function originMailboxOf(message: MessageSummary, targetId: string): string | null {
+ function originMailboxOf(message: MoveTarget, targetId: string): string | null {
const scopeId = scopeMailboxIdOf(list.listSource.value)
if (scopeId && message.mailboxIds.includes(scopeId)) return scopeId
return message.mailboxIds.find((id) => id !== targetId) ?? message.mailboxIds[0] ?? null
@@ -125,9 +195,10 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
* 스팸 신고 · 스팸 아님은 스팸 표시(isJunk)를 같은 요청에 함께 보낸다 (계약 2절 "스팸 · 원문").
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
+ * (대화 목록은 고른 줄의 메일 — moveChecked). 대화 목록은 늘 다시 맞춘다 — 메일이 남은 줄의 마지막 메일 · 통 수가 바뀐다
*/
async function moveMessages(
- targets: readonly MessageSummary[],
+ targets: readonly MoveTarget[],
targetId: string,
options: MoveOptions = {},
): Promise {
@@ -152,15 +223,22 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
settleStale()
} else {
applyMoved(result.doneIds, targetId)
- if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
+ if (hasLeftovers(result) || list.isConversationList.value) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale, ...junk }
}
+ /** 고른 줄의 옮길 메일 — 메일 목록은 고른 메일, 대화 목록은 고른 줄의 메일(그 메일함에 든 것 — 되돌리기는 그 메일함으로) */
+ function checkedMoveTargets(): MoveTarget[] {
+ if (!list.isConversationList.value) return selection.checkedMessages.value
+ const mailboxId = scopeMailboxIdOf(list.listSource.value)
+ return selection.checkedActionIds.value.map((id) => ({ id, mailboxIds: mailboxId ? [mailboxId] : [] }))
+ }
+
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
async function moveChecked(targetId: string, options: MoveOptions = {}): Promise {
- const result = await moveMessages(selection.checkedMessages.value, targetId, options)
+ const result = await moveMessages(checkedMoveTargets(), targetId, options)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
@@ -182,15 +260,15 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
settleStale()
} else {
list.removeFromList(new Set(result.doneIds))
- if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
+ if (hasLeftovers(result) || list.isConversationList.value) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, stale }
}
- /** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 */
+ /** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 (대화 목록은 고른 줄의 메일) */
async function purgeChecked(): Promise {
- const result = await purgeMessages(selection.checkedMessages.value.map((message) => message.id))
+ const result = await purgeMessages(selection.checkedActionIds.value)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
@@ -220,5 +298,13 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
return total
}
- return { patchChecked, moveMessages, moveChecked, purgeMessages, purgeChecked, restoreMoved }
+ return {
+ patchChecked,
+ toggleConversationFlag,
+ moveMessages,
+ moveChecked,
+ purgeMessages,
+ purgeChecked,
+ restoreMoved,
+ }
}
diff --git a/frontend/src/stores/mail/conversation.ts b/frontend/src/stores/mail/conversation.ts
index d57379a..79f6b59 100644
--- a/frontend/src/stores/mail/conversation.ts
+++ b/frontend/src/stores/mail/conversation.ts
@@ -403,7 +403,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
function knownRead(id: string, fallback: boolean): boolean {
const current = opened.selected.value
if (current?.id === id) return current.isRead
- const listed = list.messages.value.find((message) => message.id === id)
+ const listed = list.listedSummary(id)
if (listed) return listed.isRead
return markedRead.has(id) || fallback
}
diff --git a/frontend/src/stores/mail/list.ts b/frontend/src/stores/mail/list.ts
index 29b1626..1d0bf31 100644
--- a/frontend/src/stores/mail/list.ts
+++ b/frontend/src/stores/mail/list.ts
@@ -1,7 +1,14 @@
-// 메일 스토어 모듈 — 보이는 목록(메일함 또는 검색 결과): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것 (mail.store.ts 가 조립한다)
+// 메일 스토어 모듈 — 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것
+// (mail.store.ts 가 조립한다)
+//
+// 대화로 묶은 메일함 (14단계 — 계약 2절 "대화로 묶어 보기"): 출처가 conversations 면 줄은 conversations(대화 하나가 한 줄)이고 messages 는 비어 있다.
+// 열기 · 더 보기 · 맞추기는 GET /conversations 로 같은 순번 규칙을 따른다. 이 탭이 바꾼 표시 · 뺀 메일은 메일 id 로 받아 그 메일이 든 줄에 적는다
+// (mail-conversation-list.ts — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제). 출처를 몰라도 되게 줄 기준의 도우미를 둔다:
+// rowCount · listedSummaries(줄마다 마지막 메일) · rowIndexOfMessage · rowMessageIdAt · isListedMessage · listedSummary
//
// 늦게 온 응답 막기:
-// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다
+// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다.
+// 그 요청은 끊기도 한다(listAbort — 짐작이 틀림 · 빠르게 켜고 끔 · 메일함 바꿈, 엔진 방식의 묶은 목록은 한 요청이 15초까지 — 14단계 리뷰 L7)
// - sourceSeq: 목록의 출처(메일함 · 검색)가 바뀔 때마다 — 고른 것을 풀고(selection.ts), 끝난 여러 통 처리가
// 아직 같은 목록인지 본다(bulk.ts — 다른 목록이면 그 목록을 건드리지 않는다)
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
@@ -19,16 +26,35 @@ import { toApiError, type ApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
import { MAIL_SEARCH_FILL_MAX_REQUESTS } from '@/shared/constants/mail-search'
-import type { Mailbox, MessageListItem, MessageSummary, SearchIndexStatus } from '@/shared/types/mail'
+import type {
+ ConversationListPage,
+ ConversationSummary,
+ Mailbox,
+ MessageListItem,
+ MessageSummary,
+ SearchIndexStatus,
+} from '@/shared/types/mail'
import { DEFAULT_PAGE_SIZE, type PageInfo, type PaginatedResult } from '@/shared/types/pagination'
+import {
+ appendConversationPage,
+ conversationHasMessage,
+ conversationNeighborAfterRemoval,
+ conversationRowIndexOf,
+ mergeRefreshedConversations,
+ withConversationFlags,
+ withConversationFlagsOf,
+ withoutConversationMessages,
+} from '@/shared/utils/mail-conversation-list'
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
import {
+ isConversationSource,
isSearchTooLong,
listQueryOf,
sameListSource,
scopeMailboxIdOf,
SCHEDULED_LIST_SOURCE,
searchSourceOf,
+ type ConversationListSource,
type MailListSource,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
@@ -48,7 +74,7 @@ interface ListDeps {
}
export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
- /** 목록의 출처 — 메일함 하나 또는 검색 결과 (mail-list-source.ts) */
+ /** 목록의 출처 — 메일함 하나 · 대화로 묶은 메일함 · 검색 결과 · 예약됨 (mail-list-source.ts) */
const listSource = ref(null)
/** 목록의 출처가 바뀐 횟수 (위 설명) */
const sourceSeq = ref(0)
@@ -75,6 +101,21 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
*/
const searchIndexStatus = ref(null)
const messages = ref([])
+ /** 대화로 묶은 메일함의 줄 (14단계) — 그 출처일 때만 차고, 그때 messages 는 비어 있다 */
+ const conversations = ref([])
+ /** 대화로 묶은 메일함을 보이는 중 */
+ const isConversationList = computed(() => isConversationSource(listSource.value))
+ /** 보이는 줄 수 — 메일 목록은 메일, 대화 목록은 대화 */
+ const rowCount = computed(() =>
+ isConversationList.value ? conversations.value.length : messages.value.length,
+ )
+ /**
+ * 보이는 목록의 메일 요약 — 메일 목록은 그 메일들, 대화 목록은 줄마다 마지막 메일 (붙잡은 발송 지켜보기 · 같은 대화 칸의 링크 등
+ * 줄 모양을 몰라도 되는 곳이 쓴다)
+ */
+ const listedSummaries = computed(() =>
+ isConversationList.value ? conversations.value.map((row) => row.latest) : messages.value,
+ )
const pageInfo = ref(null)
const listStatus = ref('idle')
const listError = ref(null)
@@ -94,6 +135,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
let searchIndexSeq = 0
/** 지금 이어 받는 일 (runLoading) — 그사이 누른 [다음] · [더 찾기]가 끝나기를 기다린다 */
let loading: Promise | null = null
+ /**
+ * 지금 목록의 요청을 끊는 손잡이 — 목록을 새로 열거나 비우면 앞 목록의 요청(첫 쪽 · 더 보기 · 이어 찾기 · 맞추기)을 끊는다.
+ * 그 응답은 순번(listSeq)으로도 버리지만 서버 · 연결을 붙잡지 않게 (끊은 요청은 알리지 않는다 — useApi)
+ */
+ let listAbort = new AbortController()
/**
* 보내는 중인 읽음 · 중요 표시 — 그사이 도착한 목록(새 메일 확인)이 옛 값으로 되돌려 보이지 않게 덮어쓴다.
* 화면에 그리지 않으므로 반응형이 아니다
@@ -111,6 +157,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
{ flush: 'sync' },
)
+ /** 앞 목록의 요청을 끊고 새 손잡이를 준다 — 목록을 새로 열 때 · 비울 때 */
+ function renewListAbort(): AbortSignal {
+ listAbort.abort()
+ listAbort = new AbortController()
+ return listAbort.signal
+ }
+
/** 같은 출처를 새 목록으로 다시 연다 (같은 검색어로 다시 찾기) — 출처가 바뀐 것과 같게 다룬다 */
function renewSource() {
sourceSeq.value += 1
@@ -124,39 +177,82 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
function setLocalFlags(id: string, changes: LocalChanges) {
- messages.value = messages.value.map((message) => (message.id === id ? { ...message, ...changes } : message))
- if (opened.selected.value?.id === id) opened.selected.value = { ...opened.selected.value, ...changes }
+ setFlagsOf(new Set([id]), changes)
+ }
+
+ /**
+ * 여러 메일의 표시를 한 번에 — 목록 · 열린 메일 · 대화 줄(그 메일이 든 줄 — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제다,
+ * mail-conversation-list.ts 의 withConversationFlags)
+ */
+ function setFlagsOf(ids: ReadonlySet, changes: LocalChanges) {
+ if (ids.size === 0) return
+ messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, ...changes } : message))
+ if (conversations.value.length > 0) {
+ conversations.value = conversations.value.map((row) => withConversationFlags(row, ids, changes))
+ }
+ const current = opened.selected.value
+ if (current && ids.has(current.id)) opened.selected.value = { ...current, ...changes }
+ bumpLocalChange()
+ }
+
+ /** 대화 줄의 읽음 · 중요 표시를 앞 값으로 되돌린다 — 보내지 못한 메일이 든 줄 (여러 통 처리 — bulk.ts) */
+ function restoreConversationFlags(originals: readonly ConversationSummary[]) {
+ if (originals.length === 0) return
+ const byId = new Map(originals.map((row) => [row.id, row]))
+ conversations.value = conversations.value.map((row) => {
+ const original = byId.get(row.id)
+ return original ? withConversationFlagsOf(row, original) : row
+ })
bumpLocalChange()
}
/**
- * 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계)
+ * 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계).
+ * 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다
*/
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => {
if (message.id !== summary.id) return message
return message.searchHighlight === undefined ? summary : { ...summary, searchHighlight: message.searchHighlight }
})
+ if (conversations.value.length > 0) {
+ const ids = new Set([summary.id])
+ const flags = { isRead: summary.isRead, isFlagged: summary.isFlagged }
+ conversations.value = conversations.value.map((row) => {
+ if (row.latest.id !== summary.id && !conversationHasMessage(row, summary.id)) return row
+ return withConversationFlags(row.latest.id === summary.id ? { ...row, latest: summary } : row, ids, flags)
+ })
+ }
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
bumpLocalChange()
}
- /** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 */
+ /** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 (대화 줄은 메일이 모두 빠진 줄만 빠지고 그 수를 센다) */
function removeFromList(ids: ReadonlySet) {
if (ids.size === 0) return
const before = messages.value.length
messages.value = messages.value.filter((message) => !ids.has(message.id))
- const removed = before - messages.value.length
+ let removed = before - messages.value.length
+ if (conversations.value.length > 0) {
+ const result = withoutConversationMessages(conversations.value, ids)
+ conversations.value = result.rows
+ removed += result.removedRows
+ }
if (removed > 0 && pageInfo.value && pageInfo.value.total !== null) {
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - removed) }
}
bumpLocalChange()
}
- /** 목록에 남긴 채 메일함만 바꾼다 (전체 메일함 검색 결과에서 옮긴 메일) */
+ /** 목록에 남긴 채 메일함만 바꾼다 (전체 메일함 검색 결과에서 옮긴 메일 · 이 목록의 메일함으로 옮긴 메일) */
function relocate(ids: ReadonlySet, mailboxId: string) {
if (ids.size === 0) return
messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, mailboxIds: [mailboxId] } : message))
+ if (conversations.value.length > 0) {
+ conversations.value = conversations.value.map((row) =>
+ ids.has(row.latest.id) ? { ...row, latest: { ...row.latest, mailboxIds: [mailboxId] } } : row,
+ )
+ }
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, mailboxIds: [mailboxId] }
bumpLocalChange()
@@ -194,6 +290,29 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
})
}
+ /**
+ * 대화 줄에 보내는 중인 표시를 덮어쓴다 — 같은 값으로 보내는 메일끼리 모아 적는다(줄의 메일을 모두 보내는 중이면 줄 전체가 그 값 —
+ * 여러 통 처리가 도는 사이 새 메일 확인이 옛 값으로 줄을 되돌리지 않게)
+ */
+ function withPendingConversationFlags(rows: readonly ConversationSummary[]): ConversationSummary[] {
+ if (pendingFlags.size === 0) return [...rows]
+ const groups = new Map; changes: FlagChanges }>()
+ for (const [id, pending] of pendingFlags) {
+ for (const [key, value] of Object.entries(pending) as [keyof FlagChanges, boolean | undefined][]) {
+ if (value === undefined) continue
+ const name = `${key}:${String(value)}`
+ const group = groups.get(name) ?? { ids: new Set(), changes: { [key]: value } }
+ group.ids.add(id)
+ groups.set(name, group)
+ }
+ }
+ return rows.map((row) => {
+ let next = row
+ for (const group of groups.values()) next = withConversationFlags(next, group.ids, group.changes)
+ return next
+ })
+ }
+
/**
* 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함 · 붙잡은 발송)를 맞춘다 — 엔진이 붙잡은 발송을 풀었으면(9단계)
* 읽기 화면의 예약 안내 줄 · 보내지 않은 메일의 삭제 확인도 함께 내려간다
@@ -220,13 +339,49 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
}
- /** 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null) */
+ /**
+ * 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null).
+ * 대화 목록은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄의 마지막 메일 (mail-conversation-list.ts)
+ */
function neighborAfterRemoval(id: string): string | null {
+ if (isConversationList.value) return conversationNeighborAfterRemoval(conversations.value, id)
const index = messages.value.findIndex((message) => message.id === id)
if (index === -1) return null
return messages.value[index + 1]?.id ?? messages.value[index - 1]?.id ?? null
}
+ // ── 줄 기준의 도우미 (출처를 몰라도 되게 — 머리말) ───────────────────
+
+ /** 그 메일이 든 줄의 차례 — 대화 목록은 그 줄의 마지막 메일이거나 줄에 든 메일. 목록 밖이면 -1 */
+ function rowIndexOfMessage(id: string): number {
+ if (isConversationList.value) return conversationRowIndexOf(conversations.value, id)
+ return messages.value.findIndex((message) => message.id === id)
+ }
+
+ /** 그 줄을 누르면 열 메일 — 대화 목록은 그 줄의 마지막 메일. 없는 줄이면 null */
+ function rowMessageIdAt(index: number): string | null {
+ if (index < 0) return null
+ if (isConversationList.value) return conversations.value[index]?.latest.id ?? null
+ return messages.value[index]?.id ?? null
+ }
+
+ /** 보이는 목록에 든 메일인가 — 뺀 뒤 다음 메일로 넘어갈지 가른다 (대화 목록은 줄에 든 메일 — 뺀 옛 마지막 메일은 아니다) */
+ function isListedMessage(id: string): boolean {
+ if (isConversationList.value) return conversations.value.some((row) => conversationHasMessage(row, id))
+ return messages.value.some((message) => message.id === id)
+ }
+
+ /** 그 메일이 든 대화 줄 — 지금 값 그대로 (한 통의 표시가 실패하면 이 값으로 되돌린다 — message.ts). 메일 목록이면 빈 배열 */
+ function conversationsWith(id: string): ConversationSummary[] {
+ return conversations.value.filter((row) => row.latest.id === id || conversationHasMessage(row, id))
+ }
+
+ /** 보이는 목록이 아는 그 메일의 요약 — 대화 목록은 줄의 마지막 메일만 안다. 모르면 null */
+ function listedSummary(id: string): MessageSummary | null {
+ if (isConversationList.value) return conversations.value.find((row) => row.latest.id === id)?.latest ?? null
+ return messages.value.find((message) => message.id === id) ?? null
+ }
+
// ── 목록 열기 · 맞추기 ───────────────────────────────
/**
@@ -234,7 +389,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* cursor 는 해석하지 않고 그대로 넘긴다
*/
function fetchPage(
- source: MailListSource,
+ source: Exclude,
page: { cursor?: string; limit: number },
options?: MailRequestOptions,
): Promise> {
@@ -242,6 +397,15 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
return mailApi.listMessages({ ...listQueryOf(source), ...page }, options)
}
+ /** 대화로 묶은 메일함의 한 쪽 — GET /conversations (14단계). cursor 는 해석하지 않고 그대로 넘긴다 */
+ function fetchConversations(
+ source: ConversationListSource,
+ page: { cursor?: string; limit: number },
+ options?: MailRequestOptions,
+ ): Promise {
+ return mailApi.listConversations({ mailboxId: source.mailboxId, ...page }, options)
+ }
+
/** 예약됨 목록의 전체 수를 받았다 — 메일함 패널의 「예약됨」 개수가 이 값을 쓴다 (scheduled.ts) */
let onScheduledTotal: ((total: number | null) => void) | null = null
@@ -286,7 +450,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
while (seq === listSeq && sent < MAIL_SEARCH_FILL_MAX_REQUESTS && wantsMoreResults(source, total)) {
const cursor = pageInfo.value?.nextCursor
if (!cursor) return
- const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
+ const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal: listAbort.signal })
sent += 1
if (seq !== listSeq) return
total += appendPage(page)
@@ -312,17 +476,20 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
* 검색 결과가 한 쪽에 못 미치고 서버에 더 있으면 이어 받는다 (머리말 "검색 결과 이어 받기") — 첫 쪽이 비었으면 보이던 채로 다시 찾을 때도 "찾는 중"(뼈대).
* 검색어와 조건을 합친 낱말이 서버 한도를 넘는 검색(손으로 고친 주소)은 묻지 않는다 — 목록 틀이 그 까닭을 보인다 (isSearchTooLong)
+ * 대화로 묶은 메일함(14단계)은 GET /conversations 의 첫 쪽이다 — 줄 모양만 다르고 같은 순번 · 상태를 쓴다
*/
async function openList(next: MailListSource): Promise {
const keepVisible = sameListSource(listSource.value, next) && listStatus.value === 'ready'
listSource.value = next
listSeq += 1
const seq = listSeq
- // 앞 목록을 이어 받던 일은 이제 쓰지 않는다 (그 응답은 순번으로 버린다)
+ // 앞 목록의 요청은 끊는다 — 이어 받던 일도 이제 쓰지 않는다 (그 응답은 순번으로도 버린다)
+ const signal = renewListAbort()
isLoadingMore.value = false
if (isSearchTooLong(next)) {
messages.value = []
+ conversations.value = []
pageInfo.value = null
listError.value = null
isRefreshing.value = false
@@ -334,25 +501,46 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
isRefreshing.value = true
} else {
messages.value = []
+ conversations.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
}
- let page: PaginatedResult
- try {
- page = await fetchPage(
- next,
- { limit: DEFAULT_PAGE_SIZE },
- // 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
- { silent: keepVisible ? undefined : true },
- )
- } catch (error) {
+ // 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
+ const firstOptions: MailRequestOptions = { silent: keepVisible ? undefined : true, signal }
+
+ /** 첫 쪽을 받지 못했다 — 보이는 채로 새로 고치던 것이면 그대로 두고, 아니면 오류 상태 */
+ const failFirst = (error: unknown) => {
if (seq !== listSeq) return
isRefreshing.value = false
if (keepVisible) return
listStatus.value = 'error'
listError.value = toApiError(error)
+ }
+
+ if (next.kind === 'conversations') {
+ let rows: ConversationListPage
+ try {
+ rows = await fetchConversations(next, { limit: DEFAULT_PAGE_SIZE }, firstOptions)
+ } catch (error) {
+ failFirst(error)
+ return
+ }
+ if (seq !== listSeq) return
+ conversations.value = rows.items
+ pageInfo.value = rows.pageInfo
+ listError.value = null
+ isRefreshing.value = false
+ listStatus.value = 'ready'
+ return
+ }
+
+ let page: PaginatedResult
+ try {
+ page = await fetchPage(next, { limit: DEFAULT_PAGE_SIZE }, firstOptions)
+ } catch (error) {
+ failFirst(error)
return
}
if (seq !== listSeq) return
@@ -376,6 +564,14 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
await openList({ kind: 'mailbox', mailboxId })
}
+ /**
+ * 대화로 묶은 메일함 열기 (14단계) — 같은 목록으로 돌아오면 보이던 줄을 둔 채 새로 고친다.
+ * 묶기를 켜고 끄면 출처가 바뀌어 앞 목록의 응답을 버리고 처음 쪽부터 받는다 (메일함 화면이 설정에 따라 부른다)
+ */
+ async function openConversations(mailboxId: string): Promise {
+ await openList({ kind: 'conversations', mailboxId })
+ }
+
/** 예약됨 열기 (9단계) — 같은 목록으로 돌아오면 보이던 목록을 둔 채 새로 고친다 (보낸 메일은 저절로 빠진다) */
async function openScheduled(): Promise {
await openList(SCHEDULED_LIST_SOURCE)
@@ -444,8 +640,19 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
if (!cursor || !source || isRefreshing.value) return
const seq = listSeq
+ const signal = listAbort.signal
+ if (source.kind === 'conversations') {
+ await runLoading(seq, async () => {
+ const page = await fetchConversations(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal })
+ if (seq !== listSeq) return
+ const merged = appendConversationPage(conversations.value, pageInfo.value, page)
+ conversations.value = merged.rows
+ pageInfo.value = merged.pageInfo
+ })
+ return
+ }
await runLoading(seq, async () => {
- const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
+ const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE }, { signal })
if (seq !== listSeq) return
const added = appendPage(page)
reportTotal(source, page.pageInfo)
@@ -458,7 +665,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다.
- * 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다
+ * 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다.
+ * 대화로 묶은 메일함(14단계)은 불러온 줄 수만큼(최대 100줄) GET /conversations 로 같은 규칙으로 맞춘다 (mail-conversation-list.ts)
*/
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise {
const source = listSource.value
@@ -467,14 +675,35 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const seq = listSeq
const generation = localGeneration
- const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
+ const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, rowCount.value))
+
+ /** 받은 응답을 쓸까 — 그사이 목록을 새로 열었으면 버리고, 이 탭이 목록을 바꿨으면 버리고 한 번 더 맞춘다 */
+ async function accepts(): Promise {
+ if (seq !== listSeq || listStatus.value !== 'ready') return false
+ if (generation === localGeneration) return true
+ if (attempt === 0) await syncVisibleList(options, attempt + 1)
+ return false
+ }
+
try {
- const page = await fetchPage(source, { limit }, { silent: true })
- if (seq !== listSeq || listStatus.value !== 'ready') return
- if (generation !== localGeneration) {
- if (attempt === 0) await syncVisibleList(options, attempt + 1)
+ if (source.kind === 'conversations') {
+ const rows = await fetchConversations(source, { limit }, { silent: true, signal: listAbort.signal })
+ if (!(await accepts())) return
+ const mergedRows = mergeRefreshedConversations({
+ current: conversations.value,
+ currentPageInfo: pageInfo.value,
+ fetched: withPendingConversationFlags(rows.items),
+ fetchedPageInfo: rows.pageInfo,
+ })
+ conversations.value = mergedRows.rows
+ pageInfo.value = mergedRows.pageInfo
+ refreshSelectedFrom(mergedRows.rows.map((row) => row.latest))
+ listSyncSeq += 1
+ listSync.value = { seq: listSyncSeq, addedAbove: mergedRows.addedAbove }
return
}
+ const page = await fetchPage(source, { limit }, { silent: true, signal: listAbort.signal })
+ if (!(await accepts())) return
const merged = mergeRefreshedList({
current: messages.value,
currentPageInfo: pageInfo.value,
@@ -494,11 +723,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
function reset() {
listSeq += 1
+ renewListAbort()
searchIndexSeq += 1
listSource.value = null
searchOrigin.value = null
searchIndexStatus.value = null
messages.value = []
+ conversations.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
@@ -517,6 +748,10 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
searchOrigin,
searchIndexStatus,
messages,
+ conversations,
+ isConversationList,
+ rowCount,
+ listedSummaries,
pageInfo,
listStatus,
listError,
@@ -525,6 +760,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
listSync,
hasMore,
setLocalFlags,
+ setFlagsOf,
+ restoreConversationFlags,
applySummary,
removeFromList,
relocate,
@@ -532,8 +769,14 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
endPending,
pendingFlagsOf,
neighborAfterRemoval,
+ rowIndexOfMessage,
+ rowMessageIdAt,
+ isListedMessage,
+ listedSummary,
+ conversationsWith,
openList,
openMailbox,
+ openConversations,
openScheduled,
openSearch,
rememberSearchOrigin,
diff --git a/frontend/src/stores/mail/message.ts b/frontend/src/stores/mail/message.ts
index 7e1cfe4..0e30be5 100644
--- a/frontend/src/stores/mail/message.ts
+++ b/frontend/src/stores/mail/message.ts
@@ -8,6 +8,7 @@ import { ref } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MessageLargeFile, ShareLink } from '@/shared/types/large-file'
import type { MessageDetail, MessageHighlightQuery } from '@/shared/types/mail'
+import { mailboxListIdOf } from '@/shared/utils/mail-list-source'
import { messageHighlightKey } from '@/shared/utils/mail-search'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
@@ -169,14 +170,17 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
}
}
- /** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
+ /**
+ * 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다. 대화로 묶은 목록(14단계)은 한 통만 바꾸면 줄의 표시를 다 알 수 없어
+ * (다른 메일 · 붙잡힌 메일에도 표시가 있을 수 있다) 실패하면 그 메일이 든 줄을 앞 값 그대로 되돌린다 — 여러 통 처리와 같은 결 (리뷰 L1)
+ */
async function toggleFlag(id: string): Promise {
- const current =
- list.messages.value.find((message) => message.id === id) ??
- (opened.selected.value?.id === id ? opened.selected.value : null)
+ // 대화로 묶은 목록은 줄의 마지막 메일만 안다 — 모르면 열린 메일의 값으로 (14단계)
+ const current = list.listedSummary(id) ?? (opened.selected.value?.id === id ? opened.selected.value : null)
if (!current) return
const changes: FlagChanges = { isFlagged: !current.isFlagged }
+ const rowsBefore = list.conversationsWith(id)
list.setLocalFlags(id, changes)
list.beginPending([id], changes)
try {
@@ -186,6 +190,7 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
} catch {
list.endPending([id], changes)
list.setLocalFlags(id, { isFlagged: current.isFlagged })
+ list.restoreConversationFlags(rowsBefore)
}
}
@@ -203,7 +208,9 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
mailboxes.syncCounts()
const source = list.listSource.value
const trashId = mailboxes.mailboxByRole('trash')?.id ?? null
- if (source?.kind === 'mailbox' && (source.mailboxId === mailboxId || source.mailboxId === trashId)) {
+ // 메일함 목록(대화로 묶은 목록 포함 — 14단계)이 옮긴 곳이나 휴지통이면 다시 받는다
+ const listMailboxId = mailboxListIdOf(source)
+ if (source && listMailboxId !== null && (listMailboxId === mailboxId || listMailboxId === trashId)) {
void list.openList(source)
}
return true
diff --git a/frontend/src/stores/mail/scheduled.ts b/frontend/src/stores/mail/scheduled.ts
index 10edba0..0a5bcad 100644
--- a/frontend/src/stores/mail/scheduled.ts
+++ b/frontend/src/stores/mail/scheduled.ts
@@ -12,7 +12,7 @@ import { toApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
-import { sameListSource } from '@/shared/utils/mail-list-source'
+import { mailboxListIdOf, sameListSource } from '@/shared/utils/mail-list-source'
import { messagePendingSend, pendingSendOf, samePendingSend } from '@/shared/utils/pending-send'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
@@ -83,7 +83,7 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
* 모두 알리지 않는다. 다 끝나면 돌아온다
*/
async function refreshPendingSends(): Promise {
- const includeSearch = list.messages.value.some((message) => messagePendingSend(message) !== null)
+ const includeSearch = list.listedSummaries.value.some((message) => messagePendingSend(message) !== null)
await Promise.all([list.syncVisibleList({ includeSearch }), refreshOpenedPendingSend(), loadScheduledCount()])
}
@@ -105,8 +105,11 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
}
// 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다 (스토어도 이미 비웠다)
if (sessionGeneration() !== session) return result
- // 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다
- if (sameListSource(list.listSource.value, source) && (source?.kind === 'scheduled' || source?.kind === 'mailbox')) {
+ // 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다 (대화로 묶은 메일함도 메일함이다 — 14단계)
+ if (
+ sameListSource(list.listSource.value, source) &&
+ (source?.kind === 'scheduled' || mailboxListIdOf(source) !== null)
+ ) {
list.removeFromList(new Set([id]))
}
const current = opened.selected.value
diff --git a/frontend/src/stores/mail/selection.ts b/frontend/src/stores/mail/selection.ts
index bf954b7..2117761 100644
--- a/frontend/src/stores/mail/selection.ts
+++ b/frontend/src/stores/mail/selection.ts
@@ -1,11 +1,14 @@
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
-// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
+// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면 · 대화로 묶기를 켜고 끄면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
// 아직 보내지 않은 메일(붙잡은 발송 — 보내는 중 · 예약, 9단계)은 고를 수 없다: 옮기거나 지워도 엔진은 그 시각에 보낸다 — 취소해야만 보낸편지함을 떠난다.
// 하나 고르기 · 범위 · 모두 고르기가 모두 건너뛰고, 고른 메일(checkedMessages — 여러 통 처리가 쓰는 것)에도 들지 않는다 (pending-send.ts 의 isHeldMessage)
+// 대화로 묶은 메일함(14단계 — 계약 2절 "대화로 묶어 보기")은 줄(대화 id)을 고른다. 여러 통 처리는 고른 줄의 메일에서 붙잡힌 메일을 뺀 것에 한다
+// (checkedActionIds), 줄의 메일이 모두 붙잡힌 메일이면 그 줄은 고를 수 없다 (mail-conversation-list.ts 의 isConversationHeld)
import { computed, shallowRef, watch } from 'vue'
-import type { MessageSummary } from '@/shared/types/mail'
+import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
+import { conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import {
checkAllState,
checkRange,
@@ -16,24 +19,44 @@ import {
import { isHeldMessage } from '@/shared/utils/pending-send'
import type { ListState } from './list'
-export function createSelection(list: Pick) {
- /** 고른 메일 id — 바꿀 때는 새 모음으로 바꿔 끼운다 */
+export function createSelection(
+ list: Pick,
+) {
+ /** 고른 줄 id(메일 목록은 메일 id, 대화 목록은 대화 id) — 바꿀 때는 새 모음으로 바꿔 끼운다 */
const checkedIds = shallowRef>(new Set())
- /** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
+ /** Shift + 클릭 범위의 기준 — 마지막으로 누른 줄 */
let checkAnchorId: string | null = null
- /** 고를 수 있는 메일 id — 목록 순서 그대로, 붙잡은 발송은 뺀다 */
+ /** 고를 수 있는 줄 id — 목록 순서 그대로, 붙잡은 발송(대화 목록은 메일이 모두 붙잡힌 줄)은 뺀다 */
const selectableIds = computed(() =>
- list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
+ list.isConversationList.value
+ ? list.conversations.value.filter((row) => !isConversationHeld(row)).map((row) => row.id)
+ : list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
)
+ /** 고른 줄 수 — 대화 목록은 대화 수 */
const checkedCount = computed(() => checkedIds.value.size)
- /** 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다) */
+ /**
+ * 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다).
+ * 대화 목록에서는 비어 있다 — 고른 대화(checkedConversations)와 그 메일(checkedActionIds)을 본다
+ */
const checkedMessages = computed(() =>
- checkedIds.value.size === 0
+ checkedIds.value.size === 0 || list.isConversationList.value
? []
: list.messages.value.filter((message) => checkedIds.value.has(message.id) && !isHeldMessage(message)),
)
- /** 표 머리 고르기 상자의 상태 — 고를 수 있는 메일 기준 */
+ /** 고른 대화 — 목록 순서 그대로 (메일이 모두 붙잡힌 줄은 넣지 않는다). 메일 목록에서는 비어 있다 */
+ const checkedConversations = computed(() =>
+ checkedIds.value.size === 0 || !list.isConversationList.value
+ ? []
+ : list.conversations.value.filter((row) => checkedIds.value.has(row.id) && !isConversationHeld(row)),
+ )
+ /** 여러 통 처리가 보낼 메일 — 메일 목록은 고른 메일, 대화 목록은 고른 줄의 메일(붙잡힌 메일은 빼고 겹치지 않게) */
+ const checkedActionIds = computed(() =>
+ list.isConversationList.value
+ ? conversationActionIdsOf(checkedConversations.value)
+ : checkedMessages.value.map((message) => message.id),
+ )
+ /** 표 머리 고르기 상자의 상태 — 고를 수 있는 줄 기준 */
const checkState = computed(() => checkAllState(checkedIds.value, selectableIds.value))
function isSelectable(id: string): boolean {
@@ -45,27 +68,27 @@ export function createSelection(list: Pick)
checkAnchorId = null
}
- /** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 메일이면 아무것도 하지 않는다 */
+ /** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 줄이면 아무것도 하지 않는다 */
function toggleCheck(id: string) {
if (!isSelectable(id)) return
checkedIds.value = toggleChecked(checkedIds.value, id)
checkAnchorId = id
}
- /** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 (고를 수 없는 메일은 건너뛴다) */
+ /** Shift + 클릭 — 마지막으로 누른 줄부터 이 줄까지 (고를 수 없는 줄은 건너뛴다) */
function checkRangeTo(id: string) {
if (!isSelectable(id)) return
checkedIds.value = checkRange(checkedIds.value, selectableIds.value, checkAnchorId, id)
checkAnchorId = id
}
- /** 표 머리 상자 — 고를 수 있는 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
+ /** 표 머리 상자 — 고를 수 있는 줄을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
function toggleCheckAll() {
if (checkState.value === 'all') clearChecked()
else checkedIds.value = new Set(selectableIds.value)
}
- // 목록에서 빠진 메일 · 고를 수 없게 된 메일은 고른 것에서도 뺀다
+ // 목록에서 빠진 줄 · 고를 수 없게 된 줄은 고른 것에서도 뺀다
watch(
selectableIds,
(ids) => {
@@ -78,7 +101,18 @@ export function createSelection(list: Pick)
// 다른 목록으로 바뀌면 모두 푼다
watch(list.sourceSeq, clearChecked, { flush: 'sync' })
- return { checkedIds, checkedCount, checkedMessages, checkState, clearChecked, toggleCheck, checkRangeTo, toggleCheckAll }
+ return {
+ checkedIds,
+ checkedCount,
+ checkedMessages,
+ checkedConversations,
+ checkedActionIds,
+ checkState,
+ clearChecked,
+ toggleCheck,
+ checkRangeTo,
+ toggleCheckAll,
+ }
}
export type SelectionState = ReturnType
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 9980f95..589118c 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -190,7 +190,8 @@
* 검색 칸 안의 [조건] 높이 32(--size-compact-control) ·
* 「조건으로 찾기」 패널의 칸(보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 날짜) · [조건 지우기] · [찾기] 높이 40(--size-filter-control) ·
* 결과 머리 아래 조건 줄 높이 40(--size-row-min) · 줄 안의 [조건 지우기] 36(--size-notice-action) ·
- * 읽기 화면 "같은 대화" 칸에서 펼친 줄의 [답장] · [이 메일만 열기] 36(--size-notice-action — 캔버스 Thread14)
+ * 읽기 화면 "같은 대화" 칸에서 펼친 줄의 [답장] · [이 메일만 열기] 36(--size-notice-action — 캔버스 Thread14) ·
+ * 목록 머리의 [대화로 묶기] 높이 36(--size-notice-action — 캔버스 ListThreads14, 분할 보기는 아이콘만 36×36)
*/
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */