feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면

- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다
- 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계)
- 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제
- 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게
- 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다
- 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 23:56:09 +09:00
parent 090ee361a3
commit 192807f943
83 changed files with 7421 additions and 1157 deletions
+19 -1
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
import AppShell from '@/components/shell/AppShell.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useClock } from '@/composables/useClock'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailStore } from '@/stores/mail.store'
@@ -14,10 +19,23 @@ void mailStore.ensureMailboxes()
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
useMailStatePolling({
clock: useClock(),
onChange: () => {
mailStore.syncCounts()
void mailStore.syncVisibleList()
},
})
useMailTabTitle()
</script>
<template>
<AppShell module-id="mail">
<template #search>
<MailSearchBox />
</template>
<MailboxPanel />
<RouterView />
</AppShell>
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPage from './MailboxPage.vue'
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const Q = encodeURIComponent('회의')
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 0 }
}
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('sent', 'sent', '보낸편지함')]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: `주간 회의 ${id}`,
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
function detail(id: string): MessageDetail {
return {
...summary(id),
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
}
describe('MailboxPage — 검색 결과', () => {
let pinia: Pinia
let requests: MockRequest[]
let results: MessageSummary[]
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
useMailStore().mailboxes = MAILBOXES
results = [summary('r1'), summary('r2')]
requests = installMockApi(({ url }) => {
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
return ok(detail(url.split('/').pop() ?? 'r1'))
})
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router }
}
const listRequests = () => requests.filter((request) => request.url === '/messages')
it('주소의 검색어 · 범위로 찾는다 — 머리 "검색 결과" · "‘회의’ · 2통", 범위 줄 [받은편지함 | 전체 메일함]', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
expect(listRequests()[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
expect(wrapper.get('.search-header__close').attributes('aria-label')).toBe('검색 닫기')
const scope = wrapper.findAll('.search-scope .segmented__item')
expect(scope.map((button) => [button.text(), button.attributes('aria-pressed')])).toEqual([
['받은편지함', 'true'],
['전체 메일함', 'false'],
])
expect(wrapper.find('.search-scope__hint').exists()).toBe(false)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
})
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
results = []
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
const push = vi.spyOn(router, 'push')
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
)
await wrapper.get('.search-empty__all').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
expect(push).not.toHaveBeenCalled()
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
})
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
const push = vi.spyOn(router, 'push')
const scopeButton = (label: string) =>
wrapper.findAll('.search-scope .segmented__item').find((button) => button.text() === label)
await scopeButton('전체 메일함')?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
expect(scopeButton('전체 메일함')?.attributes('aria-pressed')).toBe('true')
// 메일함 패널은 전체 메일함에서 찾는 동안 현재 메일함을 표시하지 않는다
expect(useMailStore().currentMailboxId).toBeNull()
await scopeButton('받은편지함')?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(listRequests()).toHaveLength(3)
expect(push).not.toHaveBeenCalled()
})
it('결과에서 연 메일은 /mail/search/<id>?… — 다시 찾지 않고, 읽기 화면에 "메일함 받은편지함" 을 더한다', async () => {
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=all`)
const row = wrapper.findAll('.message-row__main')[1]
expect(row?.attributes('href')).toBe(`/mail/search/r2?q=${Q}&in=all`)
await router.push(row?.attributes('href') ?? '')
await flushPromises()
expect(listRequests()).toHaveLength(1)
expect(wrapper.get('#reader-subject').text()).toBe('주간 회의 r2')
expect(wrapper.get('.reader__mailbox').text()).toBe('메일함 받은편지함')
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-selected')
})
it('검색 닫기(←)는 검색을 시작한 화면으로 돌아간다', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
await wrapper.get('.search-header__close').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
expect(useMailStore().search).toBeNull()
})
it('검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다', async () => {
const { router } = await mountAt('/mail/search')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(listRequests().some((request) => request.params.q !== undefined)).toBe(false)
})
it('목록 보기 — 검색 결과도 표로, 전체 메일함이면 메일함 칸이 생긴다', async () => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
results = [summary('r1'), summary('r2', { mailboxIds: ['sent'] })]
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.find('table.mail-table').exists()).toBe(true)
expect(wrapper.get('#message-table-title').text()).toBe('검색 결과')
expect(wrapper.findAll('tbody .mail-table__mailbox').map((cell) => cell.text())).toEqual([
'받은편지함',
'보낸편지함',
])
})
it('검색하면 고른 것을 푼다', async () => {
const store = useMailStore()
const { router } = await mountAt('/mail/inbox')
store.toggleCheck('r1')
await router.push(`/mail/search?q=${Q}&in=inbox`)
await flushPromises()
expect(store.checkedCount).toBe(0)
})
})
+34 -20
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,6 +14,7 @@ import MessageList from '@/components/mail/MessageList.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import MessageReader from '@/components/mail/MessageReader.vue'
import MessageTable from '@/components/mail/MessageTable.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
@@ -20,14 +22,17 @@ const route = useRoute()
const router = useRouter()
const mailStore = useMailStore()
const viewStore = useMailViewStore()
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
function paramOf(value: string | string[] | undefined): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
const mailboxId = computed(() => paramOf(route.params.mailboxId))
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
const messageId = computed(() => paramOf(route.params.messageId))
const isListView = computed(() => viewStore.mode === 'list')
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
watch(
mailboxId,
@@ -37,6 +42,21 @@ watch(
{ immediate: true },
)
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
watch(
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
([searching, q, scope]) => {
if (!searching) return
if (q === null || scope === null) {
void closeSearch()
return
}
void mailStore.openSearch(q, scope)
},
{ immediate: true },
)
watch(
messageId,
(id) => {
@@ -46,20 +66,20 @@ watch(
{ immediate: true },
)
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일·검색 결과)
watch(
() => route.fullPath,
(fullPath) => {
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
if (hasList.value) mailStore.rememberBrowseLocation(fullPath)
},
{ immediate: true },
)
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
watch(
[mailboxId, () => mailStore.defaultMailbox],
([id, fallback]) => {
if (!id && fallback) {
[hasList, () => mailStore.defaultMailbox],
([listed, fallback]) => {
if (!listed && fallback) {
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
}
},
@@ -69,26 +89,20 @@ watch(
<template>
<main :class="['mail-view', { 'mail-view--list': isListView }]">
<template v-if="mailboxId && isListView">
<template v-if="hasList && isListView">
<MessageReader
v-if="messageId"
layout="list"
>
<template #toolbar-start>
<ListReadNav
:mailbox-id="mailboxId"
:message-id="messageId"
/>
<ListReadNav :message-id="messageId" />
</template>
</MessageReader>
<MessageTable
v-else
:mailbox-id="mailboxId"
/>
<MessageTable v-else />
</template>
<template v-else-if="mailboxId">
<MessageList :mailbox-id="mailboxId" />
<template v-else-if="hasList">
<MessageList />
<MessageReader />
</template>