diff --git a/frontend/src/components/mail/ConversationMessagePanel.vue b/frontend/src/components/mail/ConversationMessagePanel.vue new file mode 100644 index 0000000..35c1cf0 --- /dev/null +++ b/frontend/src/components/mail/ConversationMessagePanel.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/frontend/src/components/mail/ConversationMessageRow.vue b/frontend/src/components/mail/ConversationMessageRow.vue new file mode 100644 index 0000000..c984aeb --- /dev/null +++ b/frontend/src/components/mail/ConversationMessageRow.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/src/components/mail/MessageConversation.spec.ts b/frontend/src/components/mail/MessageConversation.spec.ts new file mode 100644 index 0000000..64ed869 --- /dev/null +++ b/frontend/src/components/mail/MessageConversation.spec.ts @@ -0,0 +1,782 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { CLOCK, type Clock } from '@/composables/useClock' +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import { MAIL_BODY_NOTICES } from '@/shared/constants/mail' +import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation' +import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail' +import type { MailListSource } from '@/shared/utils/mail-list-source' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useToastStore } from '@/stores/toast.store' +import { createManualClock, type ManualClock } from '@/test-utils/manual-clock' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MessageReader from './MessageReader.vue' + +// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화" · 계약 2절 "대화 — 14단계") +// 시각은 지역 시각으로 만든다 — 시간대(KST · UTC)와 상관없이 같은 글이 된다. 시간은 손으로 돌리는 시계로 넘긴다 + +const USER = { email: 'ara.kim@arasystem.test', name: '김아라' } +const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 } +const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 } +const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 } +const MAILBOXES = [INBOX, SENT, DRAFTS] +/** 2026년 11월 6일(금) 오후 3시 — 브라우저 시간대 */ +const NOW = new Date(2026, 10, 6, 15).getTime() +const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString() + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: 't1', + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: USER.email }], + subject: '회신: 유지보수 계약 갱신 건', + preview: '네, 확인했습니다. 단가와 기간을 정리해서 보내 드리겠습니다.', + receivedAt: at(2026, 9, 29, 9, 30), + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + pendingSend: null, + ...partial, + } +} + +function detail(base: MessageSummary, partial: Partial = {}): MessageDetail { + return { + ...base, + cc: [], + bcc: [], + replyTo: [], + sentAt: null, + messageId: null, + references: [], + htmlBody: null, + textBody: `${base.id} 본문`, + hasBlockedImages: false, + attachments: [], + ...partial, + } +} + +/** 내가 보낸 처음 메일 · 이도윤의 회신(안 읽음) · 지금 보는 메일 */ +const ORIGINAL = summary('m1', { + mailboxIds: ['sent'], + from: { name: '김아라', email: USER.email }, + to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }], + subject: '유지보수 계약 갱신 건', + preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.', + receivedAt: at(2026, 9, 27, 14, 10), +}) +const REPLY = summary('m2', { isRead: false }) +const CURRENT = summary('m3', { + subject: 'Re: 유지보수 계약 갱신 건', + receivedAt: at(2026, 10, 6, 10, 42), + hasAttachment: true, +}) +const THREAD = [ORIGINAL, REPLY, CURRENT] + +function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult { + return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' }) +} + +function deferred() { + let resolve: (result: MockResult) => void = () => undefined + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +interface Server { + conversation?: (request: MockRequest) => MockResult | Promise + detail?: (request: MockRequest) => MockResult | Promise +} + +describe('MessageConversation — 읽기 화면의 같은 대화', () => { + let pinia: Pinia + let clock: ManualClock + let requests: MockRequest[] + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated(USER) + clock = createManualClock(NOW) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + function serve(server: Server = {}) { + requests = installMockApi((request) => { + if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD) + if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true }) + if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } }) + if (request.url.startsWith('/messages/')) { + if (server.detail) return server.detail(request) + const id = request.url.split('/').pop() ?? '' + return ok(detail(THREAD.find((item) => item.id === id) ?? summary(id))) + } + return ok(null) + }) + } + + async function mountReader( + options: { + path?: string + source?: MailListSource + current?: MessageSummary + layout?: 'split' | 'list' + /** 손으로 돌리는 시계 대신 줄 시계 (시계가 뒤로 가는 경우) */ + clock?: Clock + } = {}, + ) { + const store = useMailStore() + const current = options.current ?? CURRENT + store.mailboxes = MAILBOXES + store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' } + store.messages = [REPLY, CURRENT] + store.pageInfo = { hasNext: false, nextCursor: null, total: 2 } + store.listStatus = 'ready' + store.selected = detail(current, { + attachments: [{ id: 'a1', name: '유지보수_계약서_수정본.docx', type: 'application/msword', size: 52_000 }], + }) + store.selectedId = current.id + store.selectedStatus = 'ready' + const router = createTestRouter() + await router.push(options.path ?? `/mail/inbox/${current.id}`) + await router.isReady() + const wrapper = mount(MessageReader, { + props: { layout: options.layout ?? 'split' }, + attachTo: document.body, + global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: options.clock ?? clock } }, + }) + mounted.push(wrapper) + await flushPromises() + return { wrapper, router, store } + } + + const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation')) + const section = (wrapper: VueWrapper) => wrapper.get('[data-conversation]') + const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`) + + describe('칸 모양 (디자인 원본 Thread14)', () => { + it('본문 · 첨부 아래 "같은 대화 3통" — 테두리 상자 안의 줄, 오래된 차례', async () => { + serve() + const { wrapper } = await mountReader() + + expect(conversationRequests().map((request) => [request.url, request.params])).toEqual([ + ['/messages/m3/conversation', { limit: MAIL_CONVERSATION_PAGE_SIZE }], + ]) + // 본문 · 첨부 다음이 칸 — 화면 낭독용 상태 영역은 늘 그 뒤에 있다(보이지 않는다) + const content = wrapper.get('.reader__content') + const order = [...content.element.children].map((child) => child.className.split(' ')[0]) + expect(order.slice(-4)).toEqual(['mail-body-frame', 'attachments', 'conversation', 'visually-hidden']) + expect(content.element.lastElementChild?.getAttribute('role')).toBe('status') + + expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통') + expect(section(wrapper).get('h3').attributes('tabindex')).toBe('-1') + expect(section(wrapper).attributes('aria-labelledby')).toBe(section(wrapper).get('h3').attributes('id')) + expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([ + 'm1', + 'm2', + 'm3', + ]) + }) + + it('내가 보낸 메일은 "나" + 회색 "보낸편지함"(머리글자도 회색) · 다른 줄은 이름 · 미리 보기 한 줄 · 시각', async () => { + serve() + const { wrapper } = await mountReader() + + const mine = rowOf(wrapper, 'm1') + expect(mine.get('.conversation-row__who').text()).toBe('나') + expect(mine.get('.conversation-row__mailbox').text()).toBe('보낸편지함') + expect(mine.get('.conversation-row__avatar').text()).toBe('김') + expect(mine.get('.conversation-row__avatar').classes()).toContain('conversation-row__avatar--mine') + expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview) + expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10') + + const other = rowOf(wrapper, 'm2') + expect(other.get('.conversation-row__who').text()).toBe('이도윤') + expect(other.find('.conversation-row__mailbox').exists()).toBe(false) + expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine') + expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30') + // 강조 · 뱃지 없이 글로만 + expect(section(wrapper).findAll('mark')).toHaveLength(0) + }) + + it('지금 보는 메일의 줄은 "지금 보고 있는 메일"(aria-current)이고 누를 수 없다 — 다른 줄은 접힌 단추(aria-expanded="false")', async () => { + serve() + const { wrapper } = await mountReader() + + const current = rowOf(wrapper, 'm3') + expect(current.find('button').exists()).toBe(false) + expect(current.get('[aria-current="true"]').text()).toContain('지금 보고 있는 메일') + expect(current.get('time').text()).toBe('오늘 오전 10:42') + for (const id of ['m1', 'm2']) { + const button = rowOf(wrapper, id).get('button') + expect(button.attributes('aria-expanded')).toBe('false') + expect(button.attributes('aria-controls')).toBeUndefined() + } + }) + + it('한 통뿐이면 칸이 없다', async () => { + serve({ conversation: () => page([CURRENT]) }) + const { wrapper } = await mountReader() + + expect(conversationRequests()).toHaveLength(1) + expect(wrapper.find('[data-conversation]').exists()).toBe(false) + expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false) + }) + + it('통 수를 모르면(남은 쪽이 있고 total null) 수 없이 "같은 대화" — 상자 아래 [더 보기]', async () => { + const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`)) + serve({ + conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)), + }) + const { wrapper } = await mountReader() + + expect(section(wrapper).get('h3').text()).toBe('같은 대화') + const more = wrapper.get('[data-conversation-more]') + expect(more.text()).toBe('더 보기') + + await more.trigger('click') + expect(more.attributes('aria-busy')).toBe('true') + expect(more.text()).toBe('불러오는 중…') + await flushPromises() + + expect(conversationRequests().at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' }) + expect(wrapper.find('[data-conversation-more]').exists()).toBe(false) + expect(section(wrapper).get('h3').text()).toBe(`같은 대화 ${MAIL_CONVERSATION_PAGE_SIZE + 1}통`) + }) + + it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중"', async () => { + const sendAt = new Date(2026, 10, 7, 9, 0).toISOString() + serve({ + conversation: () => + page([ + ...THREAD, + summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } }), + summary('s2', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'undo', sendAt } }), + ]), + }) + const { wrapper } = await mountReader() + + expect(rowOf(wrapper, 's1').get('time').text()).toBe('예약 · 11월 7일 오전 9:00') + expect(rowOf(wrapper, 's1').get('time').classes()).toContain('is-pending') + expect(rowOf(wrapper, 's2').get('time').text()).toBe('보내는 중') + expect(rowOf(wrapper, 's2').get('time').attributes('datetime')).toBe(sendAt) + }) + }) + + describe('펼치기 · 접기', () => { + it('다른 줄을 누르면 그 자리에서 펼친다 — 받는 사람 · 제목 · 본문(강조 쿼리 없이) · [답장] · [이 메일만 열기], 안 읽은 메일이면 읽음으로', async () => { + serve() + const { wrapper, store } = await mountReader() + const button = rowOf(wrapper, 'm2').get('button') + + await button.trigger('click') + expect(button.attributes('aria-expanded')).toBe('true') + const panelId = button.attributes('aria-controls') + expect(panelId).toBe('conversation-panel-m2') + expect(rowOf(wrapper, 'm2').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true') + await flushPromises() + + const panel = wrapper.get(`#${panelId}`) + expect(panel.findAll('.conversation-panel__meta').map((line) => line.text())).toEqual([ + '받는 사람 김아라 ', + '제목 회신: 유지보수 계약 갱신 건', + ]) + const frame = panel.get('iframe') + expect(frame.attributes('srcdoc')).toContain('m2 본문') + expect(frame.attributes('title')).toBe('메일 본문: 이도윤, 10월 29일 (목) 오전 9:30') + expect(panel.get('[data-conversation-reply]').attributes('href')).toBe('/mail/compose?replyTo=m2') + expect(panel.get('[data-conversation-reply]').text()).toBe('답장') + expect(panel.get('[data-conversation-open]').attributes('href')).toBe('/mail/inbox/m2') + expect(panel.get('[data-conversation-open]').text()).toBe('이 메일만 열기') + + expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({}) + expect(requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data])).toEqual([ + ['/messages/m2', { isRead: true }], + ]) + // 목록의 그 줄도 읽음으로 — 읽기 화면에서 연 것과 같은 길 + expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true) + + await button.trigger('click') + expect(button.attributes('aria-expanded')).toBe('false') + expect(wrapper.find(`#${panelId}`).exists()).toBe(false) + }) + + it('내가 보낸 메일의 [이 메일만 열기]는 그 메일이 든 보낸편지함으로 — 누르면 기록에 쌓아 옮긴다', async () => { + serve() + const { wrapper, router } = await mountReader() + const push = vi.spyOn(router, 'push') + + await rowOf(wrapper, 'm1').get('button').trigger('click') + await flushPromises() + const open = rowOf(wrapper, 'm1').get('[data-conversation-open]') + expect(open.attributes('href')).toBe('/mail/sent/m1') + + await open.trigger('click') + await flushPromises() + expect(push).toHaveBeenCalled() + expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m1') + }) + + it('임시 저장본 · 예약한 메일에는 [답장]이 없다 — 임시 저장본의 [이 메일만 열기]는 쓰기 화면에서 이어 쓴다', async () => { + const sendAt = new Date(2026, 10, 7, 9, 0).toISOString() + const draft = summary('d1', { mailboxIds: ['drafts'], from: USER }) + const scheduled = summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } }) + serve({ conversation: () => page([...THREAD, draft, scheduled]) }) + const { wrapper } = await mountReader() + + expect(rowOf(wrapper, 'd1').get('.conversation-row__mailbox').text()).toBe('임시보관함') + await rowOf(wrapper, 'd1').get('button').trigger('click') + await rowOf(wrapper, 's1').get('button').trigger('click') + await flushPromises() + + expect(rowOf(wrapper, 'd1').find('[data-conversation-reply]').exists()).toBe(false) + expect(rowOf(wrapper, 'd1').get('[data-conversation-open]').attributes('href')).toBe('/mail/compose?draft=d1') + expect(rowOf(wrapper, 's1').find('[data-conversation-reply]').exists()).toBe(false) + expect(rowOf(wrapper, 's1').get('[data-conversation-open]').attributes('href')).toBe('/mail/sent/s1') + }) + + it('본문을 못 받으면 줄 안에 사전 문구 + [다시 시도] — 알리지 않는다', async () => { + let failing = true + serve({ detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))) }) + const { wrapper } = await mountReader() + + await rowOf(wrapper, 'm2').get('button').trigger('click') + await flushPromises() + const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]') + expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.') + expect(useToastStore().toasts).toEqual([]) + // 읽지 못했으니 읽음으로 바꾸지 않는다 + expect(requests.some((request) => request.method === 'PATCH')).toBe(false) + + failing = false + await error.get('button').trigger('click') + await flushPromises() + expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true) + }) + }) + + describe('받지 못했을 때 — 읽기 화면은 그대로', () => { + it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => { + let failing = true + serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : page(THREAD)) }) + const { wrapper } = await mountReader() + + expect(wrapper.find('#reader-subject').exists()).toBe(true) + const notice = wrapper.get('[data-conversation-failed]') + expect(notice.text()).toContain('같은 대화를 불러오지 못했습니다.') + expect(notice.text()).toContain('문의 코드 3f1c0a2e') + expect(useToastStore().toasts).toEqual([]) + // 화면 낭독용 상태 영역은 늘 있고 글만 바뀐다 — 안내 줄 자체에는 role 이 없다(두 번 읽지 않게) + expect(notice.attributes('role')).toBeUndefined() + const status = wrapper.get('[data-conversation-status]') + expect(status.attributes('role')).toBe('status') + expect(status.text()).toBe('같은 대화를 불러오지 못했습니다.') + + failing = false + await notice.get('[data-conversation-retry]').trigger('click') + await flushPromises() + expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false) + expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통') + expect(wrapper.get('[data-conversation-status]').text()).toBe('') + }) + + it('없는 메일(404 RES_001 — 이 경로가 없는 옛 서버 포함) · 모양이 틀린 응답이면 칸 없이', async () => { + serve({ conversation: () => fail(404, 'RES_001') }) + const missing = await mountReader() + expect(missing.wrapper.find('[data-conversation]').exists()).toBe(false) + expect(missing.wrapper.find('[data-conversation-failed]').exists()).toBe(false) + missing.wrapper.unmount() + + setActivePinia((pinia = createPinia())) + useAuthStore().markAuthenticated(USER) + serve({ conversation: () => ok({ usedBytes: 0 }) }) + const odd = await mountReader() + expect(odd.wrapper.find('[data-conversation]').exists()).toBe(false) + expect(odd.wrapper.find('[data-conversation-failed]').exists()).toBe(false) + expect(useToastStore().toasts).toEqual([]) + }) + + it('보호 화면을 쓸 수 없는 세션(로그인 전 · 제한된 세션)에는 묻지 않는다', async () => { + useAuthStore().clearAuth() + serve() + await mountReader() + expect(conversationRequests()).toEqual([]) + }) + + it('제한된 세션(새 비밀번호를 정하기 전 — 13단계)이면 묻지 않고, 제한이 풀리면 그때 받는다', async () => { + const auth = useAuthStore() + auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null }) + serve() + const { wrapper } = await mountReader() + expect(auth.canUseApp).toBe(false) + expect(conversationRequests()).toEqual([]) + + auth.markAuthenticated(USER) + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통') + }) + }) + + describe('다시 받기 — 바뀜 소식은 모아서 · 돌아오면 바로', () => { + it(`다시 받아 달라는 소식이 오면 다시 받는다 — 마지막으로 받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안이면 그때까지 모아 한 번`, async () => { + let thread = THREAD + serve({ conversation: () => page(thread) }) + const { wrapper, store } = await mountReader() + expect(conversationRequests()).toHaveLength(1) + + clock.advance(1000) + store.requestConversationRefresh() + await flushPromises() + store.requestConversationRefresh() + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + + thread = [...THREAD, summary('m4', { mailboxIds: ['sent'], from: USER, receivedAt: at(2026, 10, 6, 14, 0) })] + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1000) + await flushPromises() + expect(conversationRequests()).toHaveLength(2) + expect(conversationRequests()[1]?.config.silent).toBe(true) + expect(section(wrapper).get('h3').text()).toBe('같은 대화 4통') + + // 간격이 지난 뒤의 소식은 바로 + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + store.requestConversationRefresh() + await flushPromises() + expect(conversationRequests()).toHaveLength(3) + }) + + it('받아 둔 대화로 돌아오면(쓰기 화면에서 답장을 보내고) 보이는 채로 바로 다시 받는다', async () => { + serve() + const first = await mountReader() + first.wrapper.unmount() + expect(conversationRequests()).toHaveLength(1) + + const back = mount(MessageReader, { + attachTo: document.body, + global: { plugins: [pinia, first.router], provide: { [CLOCK as symbol]: clock } }, + }) + mounted.push(back) + expect(back.find('[data-conversation]').exists()).toBe(true) + await flushPromises() + + expect(conversationRequests()).toHaveLength(2) + expect(conversationRequests()[1]?.config.silent).toBe(true) + }) + + it('메일을 바꾸면 그 메일로 새로 받고, 앞 메일의 늦은 응답은 버린다', async () => { + const late = deferred() + serve({ + conversation: ({ url }) => + url === '/messages/m3/conversation' ? late.promise : page([summary('x1'), summary('x2', { subject: '다른 대화' })]), + }) + const { wrapper, store } = await mountReader() + + store.selected = detail(summary('x2', { subject: '다른 대화' })) + store.selectedId = 'x2' + await flushPromises() + late.resolve(page(THREAD)) + await flushPromises() + + expect(conversationRequests().map((request) => request.url)).toEqual([ + '/messages/m3/conversation', + '/messages/x2/conversation', + ]) + expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual(['x1', 'x2']) + }) + }) + + describe('검색 결과에서 연 메일 (/mail/search/:id)', () => { + it('칸이 보이지만 강조가 없다 — 대화 · 펼친 본문 모두 검색 쿼리 없이 받는다', async () => { + serve() + const { wrapper, store } = await mountReader({ + path: `/mail/search/m3?q=${encodeURIComponent('계약')}&in=all`, + source: { kind: 'search', q: '계약', scope: 'all' }, + }) + store.selectedHighlight = { q: '계약' } + + expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통') + expect(conversationRequests()[0]?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE }) + + await rowOf(wrapper, 'm2').get('button').trigger('click') + await flushPromises() + expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({}) + expect(section(wrapper).findAll('mark')).toHaveLength(0) + // 받아 둔 결과에 있는 메일은 검색 결과 안에서 연다 + expect(rowOf(wrapper, 'm2').get('[data-conversation-open]').attributes('href')).toBe( + `/mail/search/m2?q=${encodeURIComponent('계약')}&in=all`, + ) + }) + }) + + describe('초점 — 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면', () => { + const focusOn = (element: Element) => (element as HTMLElement).focus() + + it('칸의 [다시 시도] — 또 못 받으면 새로 그린 [다시 시도]로, 받으면(받는 동안은 기다렸다가) 칸 제목(h3)으로', async () => { + let failing = true + const retried = deferred() + serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : retried.promise) }) + const { wrapper } = await mountReader() + + focusOn(wrapper.get('[data-conversation-retry]').element) + await wrapper.get('[data-conversation-retry]').trigger('click') + await flushPromises() + expect(document.activeElement).toBe(wrapper.get('[data-conversation-retry]').element) + + failing = false + focusOn(wrapper.get('[data-conversation-retry]').element) + await wrapper.get('[data-conversation-retry]').trigger('click') + await flushPromises() + expect(document.activeElement).toBe(document.body) + + retried.resolve(page(THREAD)) + await flushPromises() + expect(document.activeElement).toBe(section(wrapper).get('h3').element) + }) + + it('[더 보기] — 받는 동안은 그 단추에 남고(aria-busy), 마지막 쪽을 받아 단추가 사라지면 칸 제목으로', async () => { + const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`)) + serve({ + conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)), + }) + const { wrapper } = await mountReader() + const more = wrapper.get('[data-conversation-more]') + + focusOn(more.element) + await more.trigger('click') + expect(document.activeElement).toBe(more.element) + await flushPromises() + + expect(wrapper.find('[data-conversation-more]').exists()).toBe(false) + expect(document.activeElement).toBe(section(wrapper).get('h3').element) + }) + + it('펼친 줄의 [다시 시도] — 본문을 받으면 그 줄의 펼침 단추로', async () => { + let failing = true + serve({ + detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))), + }) + const { wrapper } = await mountReader() + await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click') + await flushPromises() + + failing = false + const retry = rowOf(wrapper, 'm2').get('[data-conversation-detail-error] button') + focusOn(retry.element) + await retry.trigger('click') + await flushPromises() + + expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true) + expect(document.activeElement).toBe(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element) + }) + + it('다시 받아 초점이 있던 줄이 빠지면 칸 제목으로, 칸이 없어지면(한 통만 남음) 읽기 화면의 본문으로', async () => { + let thread = THREAD + serve({ conversation: () => page(thread) }) + const { wrapper, store } = await mountReader() + async function refreshWith(next: MessageSummary[]) { + thread = next + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + store.requestConversationRefresh() + await flushPromises() + } + + focusOn(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element) + await refreshWith([ORIGINAL, CURRENT]) + expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false) + expect(document.activeElement).toBe(section(wrapper).get('h3').element) + + focusOn(rowOf(wrapper, 'm1').get('[data-conversation-toggle]').element) + await refreshWith([CURRENT]) + expect(wrapper.find('[data-conversation]').exists()).toBe(false) + expect(document.activeElement).toBe(wrapper.get('.reader__content iframe').element) + }) + + it('초점이 칸 밖에 있으면 다시 받아 줄이 빠져도 건드리지 않는다', async () => { + let thread = THREAD + serve({ conversation: () => page(thread) }) + const { wrapper, store } = await mountReader() + const star = wrapper.get('.reader__star') + focusOn(star.element) + + thread = [ORIGINAL, CURRENT] + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + store.requestConversationRefresh() + await flushPromises() + + expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false) + expect(document.activeElement).toBe(star.element) + }) + }) + + describe('다시 받기의 때 — 받는 중에 온 소식 · 시계가 뒤로 감', () => { + it('처음 받는 중에 온 소식은 버리지 않는다 — 받기가 끝난 때부터 간격을 세어 한 번 더 받는다', async () => { + const first = deferred() + let calls = 0 + serve({ + conversation: () => { + calls += 1 + return calls === 1 ? first.promise : page(THREAD) + }, + }) + const { store } = await mountReader() + store.requestConversationRefresh() + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + + // 받는 동안 흐른 시간은 세지 않는다 + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + first.resolve(page(THREAD)) + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1) + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + clock.advance(1) + await flushPromises() + expect(conversationRequests()).toHaveLength(2) + }) + + it('이 PC 시계가 뒤로 가도 간격보다 오래 기다리지 않는다', async () => { + let offset = 0 + const skewed: Clock = { + now: () => clock.now() + offset, + setTimeout: (callback, ms) => clock.setTimeout(callback, ms), + clearTimeout: (timer) => clock.clearTimeout(timer), + } + serve() + const { store } = await mountReader({ clock: skewed }) + + offset = -60 * 60 * 1000 + store.requestConversationRefresh() + await flushPromises() + expect(conversationRequests()).toHaveLength(1) + + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + await flushPromises() + expect(conversationRequests()).toHaveLength(2) + }) + }) + + describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => { + it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => { + serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) }) + const { wrapper } = await mountReader() + + await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click') + await flushPromises() + + const row = rowOf(wrapper, 'm2') + expect(row.findAll('[data-notice]').map((notice) => [notice.attributes('data-notice'), notice.text()])).toEqual([ + ['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted], + ['truncated', MAIL_BODY_NOTICES.truncated], + ]) + expect(row.find('iframe').exists()).toBe(true) + }) + + it('본문이 404 RES_001(그사이 지워짐)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 알리고, 대화를 다시 받아 그 줄을 뺀다', async () => { + const refreshed = deferred() + let calls = 0 + serve({ + conversation: () => { + calls += 1 + return calls === 1 ? page(THREAD) : refreshed.promise + }, + detail: () => fail(404, 'RES_001'), + }) + const { wrapper } = await mountReader() + + await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click') + await flushPromises() + const row = rowOf(wrapper, 'm2') + expect(row.get('[data-conversation-detail-error]').text()).toContain(ERROR_CODE_MESSAGES.RES_001) + expect(row.find('[data-conversation-detail-error] button').exists()).toBe(false) + expect(row.find('[data-conversation-reply]').exists()).toBe(false) + expect(row.find('[data-conversation-open]').exists()).toBe(false) + expect(row.get('[data-conversation-detail-status]').text()).toBe(ERROR_CODE_MESSAGES.RES_001) + expect(conversationRequests()).toHaveLength(2) + + refreshed.resolve(page([ORIGINAL, CURRENT])) + await flushPromises() + expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false) + expect(section(wrapper).get('h3').text()).toBe('같은 대화 2통') + expect(useToastStore().toasts).toEqual([]) + }) + }) + + describe('펼친 본문 안의 Ctrl+P — 그 펼친 메일의 인쇄 화면', () => { + it('펼친 본문 안에서 누른 Ctrl+P 는 읽기 화면의 메일이 아니라 펼친 메일의 인쇄 화면을 연다 — 다른 글쇠와 함께면 브라우저에 맡긴다', async () => { + const opened: unknown[][] = [] + vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => { + opened.push(args) + return null + }) + serve() + const { wrapper } = await mountReader() + await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click') + await flushPromises() + + const frame = rowOf(wrapper, 'm2').get('iframe').element as HTMLIFrameElement + frame.dispatchEvent(new Event('load')) + const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true, cancelable: true }) + frame.contentDocument?.dispatchEvent(press) + expect(press.defaultPrevented).toBe(true) + expect(opened).toEqual([['/mail/print/m2', '_blank', 'noopener']]) + + const withShift = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }) + frame.contentDocument?.dispatchEvent(withShift) + expect(withShift.defaultPrevented).toBe(false) + expect(opened).toHaveLength(1) + }) + }) + + describe('[이 메일만 열기] — 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다', () => { + it.each([ + ['split', 'push'], + ['list', 'replace'], + ] as const)('%s 보기 → router.%s', async (layout, method) => { + serve() + const { wrapper, router } = await mountReader({ layout }) + const push = vi.spyOn(router, 'push') + const replace = vi.spyOn(router, 'replace') + await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click') + await flushPromises() + + await rowOf(wrapper, 'm2').get('[data-conversation-open]').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2') + expect(method === 'push' ? push : replace).toHaveBeenCalled() + expect(method === 'push' ? replace : push).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/components/mail/MessageConversation.vue b/frontend/src/components/mail/MessageConversation.vue new file mode 100644 index 0000000..ec756fd --- /dev/null +++ b/frontend/src/components/mail/MessageConversation.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index b94bd16..9ff6da6 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -636,8 +636,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 await items[1]?.trigger('click') await flushPromises() - // 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다 + // 읽기 화면은 열릴 때 같은 대화를 받는다(14단계). 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다 expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + 'GET /messages/m1/conversation', 'GET /mail-filters', 'GET /contacts', 'GET /contacts', diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index dca0637..8148a58 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -15,6 +15,7 @@ // 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다). // 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와 // 서식 본문 안의 . 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계") +// 본문 · 첨부 아래에 "같은 대화 N통"(14단계 — MessageConversation, 한 통뿐이면 없다). 칸 안에는 검색 강조가 없다 import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' @@ -25,13 +26,14 @@ import MailLoadError from '@/components/mail/MailLoadError.vue' import MailToolButton from '@/components/mail/MailToolButton.vue' import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue' import MessageAttachments from '@/components/mail/MessageAttachments.vue' +import MessageConversation from '@/components/mail/MessageConversation.vue' import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue' import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue' import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue' import MessageSpamButton from '@/components/mail/MessageSpamButton.vue' import { useMailNavigation } from '@/composables/useMailNavigation' import { useMailPrint } from '@/composables/useMailPrint' -import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail' +import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail' import { formatMailDateTime, initialOf } from '@/shared/utils/format' import { displayName, formatAddressList } from '@/shared/utils/mail-address' import { listedAttachments } from '@/shared/utils/mail-attachment' @@ -67,7 +69,7 @@ const searchMailboxName = computed(() => { if (!mailStore.search || !message.value) return null return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null }) -const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)') +const fromName = computed(() => displayName(message.value?.from) || MAIL_NO_SENDER) const fromEmail = computed(() => message.value?.from?.email ?? '') const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value)) const subject = computed(() => message.value?.subject || '(제목 없음)') @@ -98,7 +100,7 @@ function onPrintKey(event: KeyboardEvent) { print.openPrint(current) } -/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */ +/** 안내 줄의 단추가 줄과 함께 사라졌다 · 초점이 있던 같은 대화 칸이 없어졌다(14단계) — 초점을 잃었으면 본문으로 */ function onNoticeSettled() { const active = document.activeElement if (active !== null && active !== document.body) return @@ -286,6 +288,13 @@ onBeforeUnmount(() => { :large-files="largeFiles" :name-highlights="message.searchHighlight?.attachments ?? null" /> + + + diff --git a/frontend/src/composables/useConversationSync.ts b/frontend/src/composables/useConversationSync.ts new file mode 100644 index 0000000..3461f12 --- /dev/null +++ b/frontend/src/composables/useConversationSync.ts @@ -0,0 +1,112 @@ +import { onScopeDispose, watch } from 'vue' + +import { systemClock, type Clock, type ClockTimer } from './useClock' +import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' + +// 읽기 화면의 "같은 대화" 칸을 언제 받을지 (14단계 — 계약 2절 "대화 — 14단계"). 칸(MessageConversation)이 시작하고, 칸이 사라지면 멈춘다 +// - 메일이 바뀌면 그 메일의 대화를 받는다(스토어의 loadConversation — 같은 대화의 다른 메일이면 보이던 줄을 둔 채) +// - 그 메일의 대화를 이미 받아 두었으면(쓰기 화면에서 답장을 보내고 돌아옴 · 보기 방식을 바꿈) 보이는 채로 바로 다시 받는다 +// - 다시 받아 달라는 소식(conversationRefreshSeq — 메일함들의 통 수가 바뀌었을 때만 오른다, mail.store.ts)이 오면 다시 받는다 — +// 마지막으로 받기가 끝난 지 MAIL_CONVERSATION_REFRESH_GAP_MS 가 지나지 않았으면 그때까지 모았다가 한 번에 (잦지 않게). +// 끝난 때는 스토어가 실제로 물었을 때만 센다(conversationBusy 가 꺼질 때). 묻는 중에 온 소식은 끝난 뒤 한 번 더(그때부터 간격) +// - 기다림은 간격보다 길지 않다 — 이 PC 시계가 뒤로 가도 오래 멈추지 않게 +// - 보호 화면을 쓸 수 있는 세션(canUseApp)일 때만 묻는다 — 로그인 전 · 제한된 세션(13단계)에는 묻지 않는다 +// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts) + +export interface ConversationSyncOptions { + /** 읽기 화면에 연 메일 */ + messageId: () => string + clock?: Clock +} + +export function useConversationSync(options: ConversationSyncOptions): void { + const mailStore = useMailStore() + const authStore = useAuthStore() + const clock = options.clock ?? systemClock + + /** 마지막으로 받기가 끝난 때 — 이 칸이 뜬 뒤 실제로 물은 것만 */ + let lastFinishedAt: number | null = null + let timer: ClockTimer | null = null + /** 묻는 중에 온 소식 — 끝난 뒤 한 번 더 */ + let pending = false + let stopped = false + + function cancelTimer() { + if (timer !== null) clock.clearTimeout(timer) + timer = null + } + + /** 지금 다시 받는다 — 묻는 중이면 끝난 뒤로 미룬다 */ + function refreshNow() { + if (stopped || !authStore.canUseApp) return + if (mailStore.conversationBusy) { + pending = true + return + } + void mailStore.refreshConversation() + } + + /** + * 다시 받아 달라 — 묻는 중이면 끝난 뒤로 미루고, 간격이 지났으면 바로, 아니면 간격이 끝날 때 한 번 (그사이 온 요청은 함께). + * 간격이 끝나면 다시 재지 않고 받는다 — 시계가 뒤로 가도 간격보다 오래 기다리지 않게 + */ + function requestRefresh() { + if (stopped || !authStore.canUseApp) return + if (mailStore.conversationBusy) { + pending = true + return + } + if (timer !== null) return + const wait = + lastFinishedAt === null + ? 0 + : Math.min(MAIL_CONVERSATION_REFRESH_GAP_MS, lastFinishedAt + MAIL_CONVERSATION_REFRESH_GAP_MS - clock.now()) + if (wait <= 0) { + refreshNow() + return + } + timer = clock.setTimeout(() => { + timer = null + refreshNow() + }, wait) + } + + // 받기가 끝날 때마다(받기 · 다시 받기 · [더 보기] · 붙잡힌 메일 다시 보기) 그때부터 간격을 센다 — 묻는 중에 온 소식은 그때 한 번 더 + watch( + () => mailStore.conversationBusy, + (busy, wasBusy) => { + if (stopped || busy || !wasBusy) return + lastFinishedAt = clock.now() + if (!pending) return + pending = false + requestRefresh() + }, + ) + + watch( + [() => options.messageId(), () => authStore.canUseApp], + ([messageId, usable]) => { + cancelTimer() + pending = false + if (!usable) return + if (mailStore.conversationMessageId === messageId && mailStore.conversationStatus === 'ready') { + requestRefresh() + return + } + void mailStore.loadConversation(messageId) + }, + { immediate: true }, + ) + + watch( + () => mailStore.conversationRefreshSeq, + () => requestRefresh(), + ) + + onScopeDispose(() => { + stopped = true + cancelTimer() + }) +} diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts index 399bd69..22df3bb 100644 --- a/frontend/src/composables/useMail.ts +++ b/frontend/src/composables/useMail.ts @@ -10,8 +10,10 @@ import { MAIL_SEND_TIMEOUT_MS, MAIL_UPLOAD_TIMEOUT_MS, } from '@/shared/constants/mail' +import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation' import type { Attachment, + ConversationPage, Draft, DraftPayload, DraftSaved, @@ -49,6 +51,7 @@ import type { UpdateMessagePayload, } from '@/shared/types/mail' import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination' +import { conversationPageOf } from '@/shared/utils/mail-conversation' import { pendingSendOf } from '@/shared/utils/pending-send' /** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */ @@ -273,6 +276,24 @@ export function useMail() { }) } + /** + * 그 메일의 같은 대화 — 오래된 차례, 한 통뿐이면 그 메일 하나 (14단계 — 계약 2절 "대화 — 14단계"). + * hasNext 인데 항목이 적거나 0개인 쪽이 올 수 있다(엔진 방식) — 이어 묻는 것은 부르는 쪽(스토어의 conversation.ts)이다. + * 커서는 그 메일에서만 통한다. 응답은 모양을 맞춰 돌려준다 — 틀리면 빈 대화(칸이 없다) + */ + async function getConversation( + id: string, + query: CursorPaginationQuery, + options?: MailRequestOptions, + ): Promise { + const page = await api.get(`/messages/${segment(id)}/conversation`, { + timeout: MAIL_CONVERSATION_TIMEOUT_MS, + ...options, + params: query, + }) + return conversationPageOf(page) + } + function updateMessage( id: string, changes: UpdateMessagePayload, @@ -478,6 +499,7 @@ export function useMail() { listMessages, getSearchIndex, getMessage, + getConversation, updateMessage, updateMessages, deleteMessages, diff --git a/frontend/src/composables/usePendingSendRefresh.spec.ts b/frontend/src/composables/usePendingSendRefresh.spec.ts index 25ca67b..e2f38ed 100644 --- a/frontend/src/composables/usePendingSendRefresh.spec.ts +++ b/frontend/src/composables/usePendingSendRefresh.spec.ts @@ -160,4 +160,30 @@ describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', ( expect(listRequests()).toEqual([]) expect(clock.pendingCount()).toBe(0) }) + + it('읽기 화면 "같은 대화" 칸에만 있는 붙잡힌 메일도 지켜본다 — 보낼 시각 + 2초에 그 대화를 알리지 않고 다시 받아 "보내는 중"이 내려간다 (14단계)', async () => { + let thread = [summary('m3', null), summary('u1', { kind: 'undo', sendAt: at(10_000) })] + requests = installMockApi((request) => { + if (request.url === '/scheduled-messages') return page([]) + if (request.url.endsWith('/conversation')) { + return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' }) + } + return ok({}) + }) + const store = useMailStore() + await store.loadConversation('m3') + scope.run(() => usePendingSendRefresh({ clock })) + const conversations = () => requests.filter((request) => request.url.endsWith('/conversation')) + expect(conversations()).toHaveLength(1) + + thread = [summary('m3', null), summary('u1', null)] + await advance(11_999) + expect(conversations()).toHaveLength(1) + await advance(1) + + expect(conversations()).toHaveLength(2) + expect(conversations()[1]?.config.silent).toBe(true) + expect(store.conversationItems.find((item) => item.id === 'u1')?.pendingSend).toBeNull() + expect(clock.pendingCount()).toBe(0) + }) }) diff --git a/frontend/src/composables/usePendingSendRefresh.ts b/frontend/src/composables/usePendingSendRefresh.ts index e2b21ef..456df8f 100644 --- a/frontend/src/composables/usePendingSendRefresh.ts +++ b/frontend/src/composables/usePendingSendRefresh.ts @@ -17,8 +17,9 @@ import { useMailStore } from '@/stores/mail.store' // 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작) // 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각", // 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰). -// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다 -// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다) +// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄) · 읽기 화면 "같은 대화" 칸의 줄(14단계)의 +// pendingSend.sendAt — 같은 메일은 하나로 본다 +// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수 · 붙잡힌 메일이 든 같은 대화, 알리지 않는다) // - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다 // (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다 // 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts) @@ -40,7 +41,12 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) { const givenUp = new Set() const watches = computed(() => - pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]), + pendingWatchesOf([ + ...mailStore.messages, + mailStore.selected, + mailStore.nextScheduled, + ...mailStore.conversationItems, + ]), ) /** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */ diff --git a/frontend/src/pages/mail/MailLayout.conversation.spec.ts b/frontend/src/pages/mail/MailLayout.conversation.spec.ts new file mode 100644 index 0000000..e1eaac5 --- /dev/null +++ b/frontend/src/pages/mail/MailLayout.conversation.spec.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { h } from 'vue' +import { createMemoryHistory, createRouter, RouterView } from 'vue-router' + +import { CLOCK } from '@/composables/useClock' +import { routes } from '@/router/routes' +import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail' +import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation' +import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useToastStore } from '@/stores/toast.store' +import { createManualClock, type ManualClock } from '@/test-utils/manual-clock' +import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api' + +// 메일 화면의 바뀜 소식(1분 확인 · 실시간 — MailLayout 의 onChange) → 메일함 개수 맞추기 → 같은 대화 칸 다시 받기 (14단계). +// 읽음 · 중요 표시만 바뀐 소식(메일함들의 통 수가 그대로)으로는 대화를 다시 받지 않는다 — 대화 경로의 요청 수 한도를 아끼려고(리뷰 중간 2). +// 실제 메일 화면(MailLayout → MailboxPage → 읽기 화면 → 같은 대화 칸)을 띄우고 손으로 돌리는 시계로 1분 확인을 넘긴다 + +const USER = { email: 'ara.kim@arasystem.test', name: '김아라' } + +function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount } +} + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: 't1', + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: USER.email }], + subject: '회신: 유지보수 계약 갱신 건', + preview: `미리 보기 ${id}`, + receivedAt: new Date(2026, 10, 5, 9, 30).toISOString(), + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + pendingSend: null, + ...partial, + } +} + +function detail(base: MessageSummary): MessageDetail { + return { + ...base, + cc: [], + bcc: [], + replyTo: [], + sentAt: null, + messageId: null, + references: [], + htmlBody: null, + textBody: `${base.id} 본문`, + hasBlockedImages: false, + attachments: [], + } +} + +const page = (items: unknown[], total = items.length) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } }) + +describe('MailLayout — 바뀜 소식으로 같은 대화 칸을 다시 받는다 (메일함들의 통 수가 바뀔 때만)', () => { + let pinia: Pinia + let clock: ManualClock + let requests: MockRequest[] + let state: string + let inboxTotal: number + let inboxUnread: number + let thread: MessageSummary[] + const mounted: VueWrapper[] = [] + + beforeEach(() => { + window.localStorage.clear() + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated(USER) + clock = createManualClock(new Date(2026, 10, 6, 15).getTime()) + state = 's1' + inboxTotal = 2 + inboxUnread = 1 + thread = [summary('m1', { mailboxIds: ['sent'], from: USER }), summary('m3')] + requests = installMockApi(({ url, method }) => { + if (url === '/mail-state') return ok({ state }) + if (url === '/mailboxes') { + return page([mailbox('inbox', 'inbox', '받은편지함', inboxTotal, inboxUnread), mailbox('sent', 'sent', '보낸편지함', 1, 0)]) + } + if (url === '/mail-quota') { + return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }) + } + if (url === '/mail-signature') { + return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null }) + } + if (url === '/mail-vacation') { + return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true }) + } + if (url === '/mail-blocks') return ok({ senders: [], isActive: true }) + if (url === '/mail-settings') return ok({ undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }) + if (url === '/scheduled-messages') return page([]) + if (url === '/messages' && method === 'GET') return page([summary('m3'), summary('m2', { threadId: 't2' })]) + if (url.endsWith('/conversation')) return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' }) + if (url === '/messages/m3') return ok(detail(summary('m3'))) + return ok(null) + }) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + document.title = '' + }) + + async function settle() { + for (let round = 0; round < 4; round += 1) await flushPromises() + } + + async function mountMail() { + const router = createRouter({ history: createMemoryHistory(), routes }) + await router.push('/mail/inbox/m3') + await router.isReady() + const wrapper = mount( + { render: () => h(RouterView) }, + { attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } }, + ) + mounted.push(wrapper) + await settle() + return wrapper + } + + const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation')) + const mailboxRequests = () => requests.filter((request) => request.url === '/mailboxes') + + /** 1분 확인을 넘긴다 — 바뀐 state 면 메일 화면이 메일함 개수 · 보이는 목록을 맞춘다(onChange) */ + async function nextPoll(nextState: string) { + state = nextState + clock.advance(MAIL_STATE_POLL_INTERVAL_MS) + await settle() + } + + it('읽음 표시만 바뀐 소식(통 수 그대로)으로는 다시 받지 않고, 새 메일로 통 수가 바뀌면 다시 받는다', async () => { + const wrapper = await mountMail() + expect(wrapper.find('[data-conversation]').exists()).toBe(true) + expect(conversationRequests()).toHaveLength(1) + + // 다른 곳에서 메일 한 통을 읽었다 — 안 읽은 수만 바뀐다 + const mailboxLoads = mailboxRequests().length + inboxUnread = 0 + await nextPoll('s2') + expect(mailboxRequests().length).toBeGreaterThan(mailboxLoads) + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + await settle() + expect(conversationRequests()).toHaveLength(1) + + // 같은 대화에 새 메일이 왔다 — 받은편지함의 통 수가 바뀐다 + inboxTotal = 3 + thread = [...thread, summary('m4', { receivedAt: new Date(2026, 10, 6, 15, 2).toISOString() })] + await nextPoll('s3') + expect(conversationRequests()).toHaveLength(2) + expect(conversationRequests()[1]?.config.silent).toBe(true) + expect(wrapper.get('[data-conversation] h3').text()).toBe('같은 대화 3통') + }) + + it(`받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안에 온 소식은 그때까지 모았다가 한 번 받는다`, async () => { + await mountMail() + expect(conversationRequests()).toHaveLength(1) + + // 처음 받기가 끝난 바로 뒤 — 실시간 소식처럼 곧바로 메일함 개수를 맞춘 것으로 본다 + inboxTotal = 3 + useMailStore().syncCounts() + await settle() + expect(conversationRequests()).toHaveLength(1) + + clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS) + await settle() + expect(conversationRequests()).toHaveLength(2) + }) +}) diff --git a/frontend/src/pages/mail/MailLayout.vue b/frontend/src/pages/mail/MailLayout.vue index d5e1b3e..1154ac4 100644 --- a/frontend/src/pages/mail/MailLayout.vue +++ b/frontend/src/pages/mail/MailLayout.vue @@ -57,6 +57,7 @@ useMailStatePolling({ void mailStore.loadScheduledCount() // 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다) void mailStore.refreshOpenedLargeFiles() + // 읽기 화면의 "같은 대화" 칸은 위 syncCounts 로 받은 메일함들의 통 수가 바뀌었을 때만 다시 받는다 (14단계 — mail.store.ts 의 mailboxTotalsKey) }, onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread), }) diff --git a/frontend/src/shared/constants/mail-conversation.ts b/frontend/src/shared/constants/mail-conversation.ts new file mode 100644 index 0000000..d48d192 --- /dev/null +++ b/frontend/src/shared/constants/mail-conversation.ts @@ -0,0 +1,54 @@ +// 같은 대화 — 읽기 화면의 "같은 대화" 칸 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "읽기 화면 — 같은 대화 (Thread14)") +// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다. + +/** 한 번에 받는 수 — 계약의 처음 값이자 최대(100). 대화는 대개 한 쪽이다 */ +export const MAIL_CONVERSATION_PAGE_SIZE = 100 + +/** + * 쪽이 모자라면 이어 묻는 횟수 — 칸을 열 때 · [더 보기] 한 번에 요청을 이만큼까지 보낸다(검색의 이어 찾기 MAIL_SEARCH_FILL_MAX_REQUESTS 와 같은 결). + * 엔진 방식(그 직원의 색인을 채우는 동안)은 한 요청이 스레드를 1,000통까지만 훑어 hasNext 인데 항목이 적거나 0개인 쪽이 온다 — + * 새로 한 쪽(100통)이 차거나 더 없으면 멈추고, 그래도 남으면 [더 보기]로 사용자가 잇는다 + */ +export const MAIL_CONVERSATION_FILL_MAX_REQUESTS = 5 + +/** + * 요청 하나의 시간 한도 — 서버는 엔진 방식의 한 요청을 15초 안에 끝내고(넘으면 503 MAIL_001) 그 전에 색인을 1.5초 맞춘다. + * 기본 10초(useApi)로는 서버가 답하기 전에 끊기므로 넉넉히 둔다 + */ +export const MAIL_CONVERSATION_TIMEOUT_MS = 30_000 + +/** + * 바뀜 소식으로 다시 받는 사이의 최소 간격 — 마지막으로 받기가 **끝난** 뒤 이만큼 안에 온 소식은 모았다가 간격이 끝날 때 한 번 받는다. + * 소식은 메일함들의 통 수가 바뀌었을 때만 온다(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 것은 아니다, mail.store.ts). + * 대화 경로도 요청 수 한도(경로마다 · IP 마다 1분 100회 — 사무실은 한 IP)를 함께 쓴다 + */ +export const MAIL_CONVERSATION_REFRESH_GAP_MS = 10_000 + +/** + * 접힌 줄의 받아 둔 본문 수 — 최근에 본 것부터 이만큼만 둔다(다시 펴면 다시 받는다). 펼친 줄의 본문은 세지 않고 늘 둔다 + */ +export const MAIL_CONVERSATION_DETAIL_CACHE_MAX = 10 + +/** 읽기 화면의 "같은 대화" 칸 (디자인 원본 Thread14) */ +export const MAIL_CONVERSATION_TEXT = { + /** 칸 제목 — 통 수를 모르면(total null — 쪽을 넘긴 뒤 · 엔진 방식의 큰 대화) 수 없이 */ + title: '같은 대화', + titleCount: (count: string) => `같은 대화 ${count}통`, + /** 내가 보낸 메일의 이름 자리 (3차 "대화로 묶어 보기"의 참여자 줄도 같은 말) */ + me: '나', + current: '지금 보고 있는 메일', + reply: '답장', + openOnly: '이 메일만 열기', + /** 펼친 줄의 머리 — 읽기 화면 머리와 같은 이름 ("받는 사람 김아라 <…>" · "제목 …") */ + recipients: '받는 사람', + subject: '제목', + /** 펼친 줄의 본문을 받는 동안 (화면 낭독) */ + bodyLoading: '메일 불러오는 중', + /** 펼친 줄의 본문 틀 이름 (화면 낭독) — 같은 대화는 제목이 같아 보낸 사람 · 시각으로 가른다 */ + bodyTitle: (who: string, time: string) => `메일 본문: ${who}, ${time}`, + more: '더 보기', + moreBusy: '불러오는 중…', + loadFailed: '같은 대화를 불러오지 못했습니다.', + retry: '다시 시도', + traceCode: (code: string) => `문의 코드 ${code}`, +} as const diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts index 8d602d0..e53735b 100644 --- a/frontend/src/shared/constants/mail.ts +++ b/frontend/src/shared/constants/mail.ts @@ -240,6 +240,8 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다. /** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */ export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)' export const MAIL_NO_SUBJECT = '(제목 없음)' +/** 보낸 사람이 없는 메일 — 목록 · 읽기 화면 · 같은 대화 칸의 이름 자리 */ +export const MAIL_NO_SENDER = '(보낸 사람 없음)' // ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ───────────── /** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 — 실시간 연결이 살아 있으면 묻지 않는다 (5단계, mail-notify.ts) */ diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts index 6c8b130..00cffca 100644 --- a/frontend/src/shared/types/mail.ts +++ b/frontend/src/shared/types/mail.ts @@ -3,7 +3,7 @@ import type { ContactEmailLabel } from '@/shared/types/contacts' import type { DraftLargeFile, MessageLargeFile } from '@/shared/types/large-file' -import type { CursorPaginationQuery } from '@/shared/types/pagination' +import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination' export interface Address { name: string | null @@ -202,6 +202,20 @@ export interface SearchIndexState { total: number | null } +// ── 대화 (계약 2절 "대화 — 14단계") ───────────────────────── + +/** 대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 채우는 동안) */ +export type ConversationBasis = 'index' | 'engine' + +/** + * GET /messages/{id}/conversation 응답 — 그 메일의 같은 대화, 오래된 차례(한 통뿐이면 그 메일 하나). + * hasNext 인데 항목이 limit 보다 적거나 0개일 수 있다(engine — 한 요청이 스레드를 1,000통까지만 훑는다). + * total 은 index 면 첫 쪽에서만, engine 이면 쪽마다(1,000통을 넘으면 null). 모양이 틀린 응답은 빈 대화 · basis null 로 맞춘다 (useMail().getConversation) + */ +export interface ConversationPage extends PaginatedResult { + basis: ConversationBasis | null +} + /** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */ export interface UpdateMessagePayload { isRead?: boolean diff --git a/frontend/src/shared/utils/format.spec.ts b/frontend/src/shared/utils/format.spec.ts index 68ac616..491820a 100644 --- a/frontend/src/shared/utils/format.spec.ts +++ b/frontend/src/shared/utils/format.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { formatBlockedDay, + formatConversationTime, formatDraftSavedTime, formatPrintDateTime, formatScheduleShortTime, @@ -215,3 +216,24 @@ describe('formatRelativeDayTime (11단계 — 설정 › 대용량 첨부의 날 expect(formatRelativeDayTime('나중에', now, SEOUL)).toBe('') }) }) + +describe('formatConversationTime (14단계 — 같은 대화 칸의 시각, 디자인 원본 Thread14)', () => { + /** 2026-11-05(목) 오후 3시 (서울) */ + const now = new Date('2026-11-05T06:00:00Z') + + it('오늘 · 어제는 그 말과 시각 — "오늘 오전 10:42"', () => { + expect(formatConversationTime('2026-11-05T01:42:00Z', now, SEOUL)).toBe('오늘 오전 10:42') + expect(formatConversationTime('2026-11-04T04:05:00Z', now, SEOUL)).toBe('어제 오후 1:05') + }) + + it('그 밖의 올해는 요일까지 "10월 29일 (목) 오전 9:30", 다른 해는 연도까지 (읽기 화면 머리와 같다)', () => { + expect(formatConversationTime('2026-10-29T00:30:00Z', now, SEOUL)).toBe('10월 29일 (목) 오전 9:30') + expect(formatConversationTime('2025-10-29T00:30:00Z', now, SEOUL)).toBe('2025. 10. 29. (수) 오전 9:30') + }) + + it('날은 서울 날짜로 가른다 — UTC 로는 어제여도 서울로 오늘이면 "오늘"', () => { + // 2026-11-04T23:30Z = 서울 11월 5일 오전 8:30 + expect(formatConversationTime('2026-11-04T23:30:00Z', now, SEOUL)).toBe('오늘 오전 8:30') + expect(formatConversationTime('나중에', now, SEOUL)).toBe('') + }) +}) diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts index 6855e8b..c3c4dd1 100644 --- a/frontend/src/shared/utils/format.ts +++ b/frontend/src/shared/utils/format.ts @@ -274,6 +274,23 @@ export function formatScheduleTime(value: string | number, timeZone?: string): s }).format(date) } +/** + * 같은 대화 칸의 시각 (14단계 디자인 원본 Thread14: "오늘 오전 10:42" · "10월 29일 (목) 오전 9:30") — 줄마다 날과 시각을 함께 보인다 + * - 오늘 · 어제: "오늘 오전 10:42" · "어제 오후 1:05" + * - 올해: "10월 29일 (목) 오전 9:30" + * - 그 이전: "2025. 10. 29. (수) 오전 9:30" (읽기 화면 머리와 같다) + */ +export function formatConversationTime(value: string, now: Date = new Date(), timeZone?: string): string { + const date = parseDate(value) + if (!date) return '' + const target = calendarDayOf(date, timeZone) + const today = calendarDayOf(now, timeZone) + const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date) + if (isSameDay(target, today)) return `오늘 ${time}` + if (isSameDay(target, previousDay(today))) return `어제 ${time}` + return target.year === today.year ? formatScheduleTime(value, timeZone) : formatMailDateTime(value, timeZone) +} + /** 보낸편지함 날짜 칸의 예약 시각 — 요일 없이 (디자인 원본 MailSendLater: "예약 · 10월 30일 오전 9:00") */ export function formatScheduleShortTime(value: string | number, timeZone?: string): string { const date = dateOf(value) diff --git a/frontend/src/shared/utils/mail-conversation.spec.ts b/frontend/src/shared/utils/mail-conversation.spec.ts new file mode 100644 index 0000000..de07492 --- /dev/null +++ b/frontend/src/shared/utils/mail-conversation.spec.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest' + +import type { Mailbox, MessageSummary } from '@/shared/types/mail' +import { + appendConversationPage, + conversationCountOf, + conversationMessageLocation, + conversationPageOf, + conversationReplyLocation, + conversationRowsOf, + conversationSenderOf, + hasConversation, + nextConversationCursor, + participantName, + type ConversationLinkContext, +} from './mail-conversation' + +// 같은 대화 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 원본 Thread14) — 응답 맞추기 · 통 수 · 이름 자리 · 누르면 갈 곳 + +const SEOUL = 'Asia/Seoul' +const ME = { email: 'ara.kim@arasystem.test', name: '김아라' } + +function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 } +} + +const INBOX = mailbox('inbox', 'inbox', '받은편지함') +const SENT = mailbox('sent', 'sent', '보낸편지함') +const DRAFTS = mailbox('drafts', 'drafts', '임시보관함') +const PROJECT = mailbox('project', null, '프로젝트') +const MAILBOXES = [INBOX, SENT, DRAFTS, PROJECT] + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: 't1', + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [{ name: '김아라', email: ME.email }], + subject: '회신: 유지보수 계약 갱신 건', + preview: '네, 확인했습니다.', + receivedAt: '2026-10-29T00:30:00Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + ...partial, + } +} + +const EMPTY_LINKS: ConversationLinkContext = { + source: { kind: 'mailbox', mailboxId: 'inbox' }, + listedIds: new Set(), + mailboxes: MAILBOXES, +} + +describe('conversationPageOf — 응답 모양 맞추기', () => { + it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => { + const page = conversationPageOf({ + items: [summary('m1'), summary('m2')], + pageInfo: { hasNext: true, nextCursor: 'c2', total: 4 }, + basis: 'index', + }) + + expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2']) + expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'c2', total: 4 }) + expect(page.basis).toBe('index') + }) + + it('모양이 틀리면(항목 · 쪽 정보가 없음 · 응답을 바꾼 게이트웨이) 빈 대화 — 칸을 보이지 않는다', () => { + for (const value of [null, 'html', { items: 'x', pageInfo: {} }, { items: [] }, { usedBytes: 0 }]) { + const page = conversationPageOf(value) + expect(page.items).toEqual([]) + expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 0 }) + expect(page.basis).toBeNull() + expect(hasConversation(page.items, page.pageInfo)).toBe(false) + } + }) + + it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 · 소수 total 은 모르는 것, 모르는 basis 는 null, 줄이 읽는 칸이 없는 항목은 뺀다', () => { + const page = conversationPageOf({ + items: [summary('m1'), { id: 'broken' }, { ...summary('m3'), preview: undefined }, summary('m4')], + pageInfo: { hasNext: true, nextCursor: '', total: 2.5 }, + basis: 'thread', + }) + + expect(page.items.map((item) => item.id)).toEqual(['m1', 'm4']) + expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null }) + expect(page.basis).toBeNull() + expect(conversationPageOf({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: -1 } }).pageInfo.total).toBeNull() + }) +}) + +describe('appendConversationPage · 통 수 · 칸 보이기', () => { + it('다음 쪽을 붙인다 — 이미 있는 메일은 건너뛰고, total 이 없는 다음 쪽(색인 방식)은 첫 쪽의 수를 남긴다', () => { + const first = { hasNext: true, nextCursor: 'c2', total: 3 } + const merged = appendConversationPage([summary('m1'), summary('m2')], first, { + items: [summary('m2'), summary('m3')], + pageInfo: { hasNext: false, nextCursor: null, total: null }, + basis: 'index', + }) + + expect(merged.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm3']) + expect(merged.added).toBe(1) + expect(merged.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 3 }) + }) + + it('N 은 total — 모르면 다 받았을 때의 받은 수, 그것도 모르면(남은 쪽이 있다) null', () => { + const items = [summary('m1'), summary('m2')] + expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: 8 })).toBe(8) + expect(conversationCountOf(items, { hasNext: false, nextCursor: null, total: null })).toBe(2) + expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: null })).toBeNull() + expect(conversationCountOf(items, null)).toBeNull() + }) + + it('한 통뿐이면 칸이 없다 — 수를 모르면 두 통 넘게 받았거나 받을 쪽이 남았을 때 보인다', () => { + expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: 1 })).toBe(false) + expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: null })).toBe(false) + expect(hasConversation([summary('m1'), summary('m2')], { hasNext: false, nextCursor: null, total: 2 })).toBe(true) + // 엔진 방식의 큰 대화 — 첫 쪽에는 지금 메일뿐이지만 다른 메일이 있다는 수가 왔다 + expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: 5 })).toBe(true) + expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: null })).toBe(true) + expect(nextConversationCursor({ hasNext: true, nextCursor: null, total: null })).toBeNull() + }) +}) + +describe('이름 자리 — 내가 보냈으면 "나" + 회색 메일함 이름 (디자인 원본 Thread14)', () => { + const context = { me: ME, mailboxes: MAILBOXES } + + it('보낸편지함의 메일은 "나" · 보낸편지함 — 머리글자는 내 이름의 첫 글자, 회색', () => { + const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } }) + + expect(conversationSenderOf(sent, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '보낸편지함' }) + }) + + it('임시 저장본은 "나" · 임시보관함, 보낸 사람이 나면(대소문자 무시) 받은편지함에 들어도 "나" — 메일함 이름 없이', () => { + const draft = summary('m1', { mailboxIds: ['drafts'], from: null }) + expect(conversationSenderOf(draft, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '임시보관함' }) + + const toSelf = summary('m2', { from: { name: null, email: 'Ara.Kim@AraSystem.test' } }) + expect(conversationSenderOf(toSelf, context)).toMatchObject({ name: '나', mine: true, mailboxName: null }) + }) + + it('받은 메일은 보낸 사람 이름(없으면 주소, 보낸 사람도 없으면 "(보낸 사람 없음)") — 내 메일함이 아닌 곳의 이름은 붙이지 않는다', () => { + expect(conversationSenderOf(summary('m1', { mailboxIds: ['project'] }), context)).toEqual({ + name: '이도윤', + initial: '이', + mine: false, + mailboxName: null, + }) + expect(conversationSenderOf(summary('m2', { from: { name: null, email: 'seojun.park@arasystem.test' } }), context).name).toBe( + 'seojun.park@arasystem.test', + ) + expect(conversationSenderOf(summary('m3', { from: null }), context)).toMatchObject({ name: '(보낸 사람 없음)', mine: false }) + }) + + it('메일함을 아직 모르면 보낸 사람 주소로만 가른다', () => { + const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } }) + expect(conversationSenderOf(sent, { me: ME, mailboxes: [] })).toMatchObject({ name: '나', mailboxName: null }) + expect(conversationSenderOf(sent, { me: null, mailboxes: [] })).toMatchObject({ name: '김아라', mine: false }) + }) + + it('participantName — 나면 "나", 아니면 이름 (3차 묶은 목록의 참여자 줄도 쓴다)', () => { + expect(participantName({ name: '김아라', email: ME.email }, ME.email)).toBe('나') + expect(participantName({ name: '이도윤', email: 'doyun.lee@arasystem.test' }, ME.email)).toBe('이도윤') + expect(participantName({ name: null, email: 'x@example.com' }, null)).toBe('x@example.com') + }) +}) + +describe('누르면 갈 곳 — [이 메일만 열기] · [답장]', () => { + it('지금 메일함에 든 메일은 그 메일함 안, 다른 메일함의 메일은 그 메일이 든 메일함 (보낸편지함의 내 메일)', () => { + expect(conversationMessageLocation(summary('m2'), EMPTY_LINKS)).toEqual({ + name: 'mail-message', + params: { mailboxId: 'inbox', messageId: 'm2' }, + }) + expect(conversationMessageLocation(summary('m1', { mailboxIds: ['sent'] }), EMPTY_LINKS)).toEqual({ + name: 'mail-message', + params: { mailboxId: 'sent', messageId: 'm1' }, + }) + }) + + it('임시 저장본은 쓰기 화면에서 이어 쓴다 (목록에서 누를 때와 같다)', () => { + expect(conversationMessageLocation(summary('d1', { mailboxIds: ['drafts'] }), EMPTY_LINKS)).toEqual({ + name: 'mail-compose', + query: { draft: 'd1' }, + }) + }) + + it('검색 결과 · 예약됨은 받아 둔 줄에 있는 메일만 그 목록 안에서 연다 — 아니면 그 메일의 메일함', () => { + const search: ConversationLinkContext = { + source: { kind: 'search', q: '계약', scope: 'all' }, + listedIds: new Set(['m2']), + mailboxes: MAILBOXES, + } + expect(conversationMessageLocation(summary('m2'), search)).toEqual({ + name: 'mail-search-message', + params: { messageId: 'm2' }, + query: { q: '계약', in: 'all' }, + }) + expect(conversationMessageLocation(summary('m3', { mailboxIds: ['project'] }), search)).toEqual({ + name: 'mail-message', + params: { mailboxId: 'project', messageId: 'm3' }, + }) + + const scheduled: ConversationLinkContext = { source: { kind: 'scheduled' }, listedIds: new Set(['s1']), mailboxes: MAILBOXES } + expect(conversationMessageLocation(summary('s1', { mailboxIds: ['sent'] }), scheduled)).toEqual({ + name: 'mail-scheduled-message', + params: { messageId: 's1' }, + }) + }) + + it('메일함을 모르면 그 메일의 첫 메일함 id 로', () => { + expect( + conversationMessageLocation(summary('m1', { mailboxIds: ['x1'] }), { ...EMPTY_LINKS, mailboxes: [] }), + ).toEqual({ name: 'mail-message', params: { mailboxId: 'x1', messageId: 'm1' } }) + }) + + it('[답장]은 읽기 화면과 같은 쓰기 화면 — 임시 저장본 · 예약한 메일은 없고, 보내기 취소를 기다리는 메일은 있다', () => { + expect(conversationReplyLocation(summary('m1'), MAILBOXES)).toEqual({ name: 'mail-compose', query: { replyTo: 'm1' } }) + expect(conversationReplyLocation(summary('d1', { mailboxIds: ['drafts'] }), MAILBOXES)).toBeNull() + const sendAt = '2026-10-30T00:00:00Z' + expect( + conversationReplyLocation(summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } }), MAILBOXES), + ).toBeNull() + expect( + conversationReplyLocation(summary('s2', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } }), MAILBOXES), + ).not.toBeNull() + }) +}) + +describe('conversationRowsOf — 칸의 줄', () => { + /** 2026-10-29(목) 오후 3시 (서울) */ + const now = new Date('2026-10-29T06:00:00Z') + const context = { ...EMPTY_LINKS, currentId: 'm3', me: ME, now, timeZone: SEOUL } + + it('오래된 차례 그대로 — 지금 메일 표시 · 시각("오늘 …" · "10월 27일 (화) …") · 미리 보기가 비면 제목', () => { + const rows = conversationRowsOf( + [ + summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email }, receivedAt: '2026-10-27T05:10:00Z' }), + summary('m2', { preview: ' ' }), + summary('m3', { receivedAt: '2026-10-29T01:42:00Z' }), + ], + context, + ) + + expect(rows.map((row) => [row.message.id, row.current, row.sender.name, row.time])).toEqual([ + ['m1', false, '나', '10월 27일 (화) 오후 2:10'], + ['m2', false, '이도윤', '오늘 오전 9:30'], + ['m3', true, '이도윤', '오늘 오전 10:42'], + ]) + expect(rows[1]?.preview).toBe('회신: 유지보수 계약 갱신 건') + expect(rows[0]?.preview).toBe('네, 확인했습니다.') + expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } }) + }) + + it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" —