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
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
import { onMounted, ref, watch } from 'vue'
interface Props {
@@ -168,6 +168,15 @@ onMounted(() => {
background: var(--color-brand-strong);
}
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
:slotted(.modal-dialog__action--danger) {
background: var(--color-danger);
}
:slotted(.modal-dialog__action--danger:hover) {
background: var(--color-danger-strong);
}
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
:slotted(.modal-dialog__action--secondary) {
background: transparent;
@@ -55,7 +55,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
await router.push(`/mail/inbox/${messageId}`)
await router.isReady()
const wrapper = mount(ListReadNav, {
props: { mailboxId: 'inbox', messageId },
props: { messageId },
global: { plugins: [pinia, router] },
})
return { wrapper, router }
@@ -114,7 +114,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
await router.push('/mail/inbox/m2')
await router.isReady()
const wrapper = mount(ListReadNav, {
props: { mailboxId: 'inbox', messageId: 'm2' },
props: { messageId: 'm2' },
global: { plugins: [pinia, router] },
})
@@ -134,4 +134,27 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
})
it('검색 결과에서 연 메일 — 이전/다음은 결과 안의 주소로, "목록으로"는 검색 결과로 돌아간다', async () => {
const store = useMailStore()
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
const query = `q=${encodeURIComponent('회의')}&in=all`
const router = createTestRouter()
await router.push(`/mail/search?${query}`)
await router.push(`/mail/search/m2?${query}`)
await router.isReady()
const wrapper = mount(ListReadNav, { props: { messageId: 'm2' }, global: { plugins: [pinia, router] } })
expect(wrapper.get('a.tool-button').attributes('href')).toBe(`/mail/search?${query}`)
expect(wrapper.get('.nav-position').text()).toBe('2 / 3')
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
})
})
+18 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -9,11 +10,11 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { formatCount } from '@/shared/utils/format'
import { listLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
interface Props {
mailboxId: string
messageId: string
}
@@ -22,17 +23,23 @@ const props = defineProps<Props>()
const router = useRouter()
const mailStore = useMailStore()
const viewStore = useMailViewStore()
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
const { backToSourceList, showSiblingIn } = useMailNavigation()
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
const loadingNext = ref<boolean>(false)
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
const listHref = computed(() => {
const source = mailStore.listSource
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
})
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
const total = computed(
() =>
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
mailStore.pageInfo?.total ??
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
mailStore.messages.length,
)
const position = computed(() =>
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
@@ -46,7 +53,8 @@ const hasNext = computed(
async function goPrev() {
const previous = mailStore.messages[index.value - 1]
if (previous) await showSibling(props.mailboxId, previous.id)
const source = mailStore.listSource
if (previous && source) await showSiblingIn(source, previous.id)
}
async function goNext() {
@@ -61,7 +69,8 @@ async function goNext() {
}
}
const next = mailStore.messages[current + 1]
if (next) await showSibling(props.mailboxId, next.id)
const source = mailStore.listSource
if (next && source) await showSiblingIn(source, next.id)
}
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
@@ -69,8 +78,10 @@ function onBackToList(event: MouseEvent) {
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
const source = mailStore.listSource
if (!source) return
viewStore.setReturnFocus(props.messageId)
void backToList(props.mailboxId)
void backToSourceList(source)
}
</script>
@@ -0,0 +1,128 @@
<script setup lang="ts">
// 여러 통 처리 도구 줄의 단추 — 높이 44 · 아이콘 + 이름 (디자인 원본 BulkSelect · ListStates)
// 이름은 labelMode 로 정한다: always(늘 보인다) · wide(폭 1100 이상에서만 — 목록 보기) · never(아이콘만 40×44 — 분할 보기의 목록 칸)
// 이름을 숨겨도 aria-label · 툴팁(title)으로 읽힌다. data-tool · tabindex(로빙) · aria-haspopup 같은 속성은 단추에 그대로 붙는다.
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
label: string
/** 툴팁 — 주지 않으면 이름 */
title?: string
labelMode?: 'always' | 'wide' | 'never'
/** danger: 삭제 · 영구 삭제 (빨강) */
tone?: 'default' | 'danger'
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
menu?: boolean
disabled?: boolean
iconStrokeWidth?: number
}
interface Emits {
(e: 'click', event: MouseEvent): void
}
withDefaults(defineProps<Props>(), {
title: undefined,
labelMode: 'wide',
tone: 'default',
menu: false,
disabled: false,
iconStrokeWidth: 1.75,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
defineExpose({
/** 단추 요소 — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
element: buttonRef,
})
</script>
<template>
<button
ref="buttonRef"
type="button"
:class="['bulk-button', `bulk-button--${tone}`, `bulk-button--label-${labelMode}`, { 'bulk-button--menu': menu }]"
:aria-label="label"
:title="title ?? label"
:disabled="disabled"
@click="emit('click', $event)"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span class="bulk-button__label">{{ label }}</span>
<AppIcon
v-if="menu"
name="chevron-down"
:size="12"
:stroke-width="2"
class="bulk-button__chevron"
/>
</button>
</template>
<style scoped>
.bulk-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-width: var(--size-icon-button);
min-height: var(--size-touch);
padding: 0 var(--toolbar-button-inline);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
white-space: nowrap;
}
.bulk-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.bulk-button:disabled {
opacity: 0.5;
}
.bulk-button--menu {
padding: 0 var(--space-8) 0 var(--toolbar-button-inline);
}
.bulk-button--danger {
color: var(--color-danger);
}
.bulk-button--danger:hover:not(:disabled) {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
.bulk-button--label-never {
width: var(--size-icon-button);
padding: 0;
}
.bulk-button--label-never .bulk-button__label,
.bulk-button--label-never .bulk-button__chevron {
display: none;
}
/* 1100px 미만 — 아이콘만 (메뉴 단추는 ▾ 를 둔다 · tokens.css 화면 폭 기준) */
@media (max-width: 68.6875rem) {
.bulk-button--label-wide .bulk-button__label {
display: none;
}
}
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
// 이동 ▾ — 옮길 메일함 메뉴 (디자인 원본 BulkSelect "이동 메뉴" · README "3단계 › 여러 통 처리")
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
interface Props {
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
mailboxes: readonly Mailbox[]
labelMode: 'always' | 'wide' | 'never'
disabled?: boolean
}
interface Emits {
(e: 'move', mailbox: Mailbox): void
}
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<InstanceType<typeof MailBulkButton> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
const items = computed<EditorMenuItem[]>(() => props.mailboxes.map((mailbox) => ({ id: mailbox.id, label: mailbox.name })))
function openMenu(keyboard: boolean) {
if (props.disabled || items.value.length === 0) return
byKeyboard.value = keyboard
open.value = true
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
openMenu(true)
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) trigger.value?.focus()
}
function onSelect(id: string) {
open.value = false
trigger.value?.focus()
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
if (target) emit('move', target)
}
</script>
<template>
<div class="bulk-move">
<MailBulkButton
v-bind="$attrs"
ref="buttonRef"
icon="folder-move"
:label="MAIL_BULK_TEXT.move"
:label-mode="labelMode"
menu
aria-haspopup="menu"
:aria-expanded="open"
:disabled="disabled"
@click="onClick"
@keydown="onKeydown"
/>
<EditorMenu
v-if="open"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
.bulk-move {
position: relative;
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
// 고르기 상자 — 여러 통 처리 (디자인 원본 BulkSelect · ListStates)
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
interface Props {
checked: boolean
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
mixed?: boolean
label: string
}
interface Emits {
(e: 'toggle', event: MouseEvent): void
}
withDefaults(defineProps<Props>(), {
mixed: false,
})
const emit = defineEmits<Emits>()
</script>
<template>
<button
type="button"
role="checkbox"
class="mail-checkbox"
:aria-checked="mixed ? 'mixed' : checked"
:aria-label="label"
@click="emit('toggle', $event)"
>
<svg
class="mail-checkbox__box"
viewBox="0 0 16 16"
aria-hidden="true"
>
<rect
v-if="!checked && !mixed"
class="mail-checkbox__empty"
x="1"
y="1"
width="14"
height="14"
rx="3.5"
/>
<template v-else>
<rect
class="mail-checkbox__fill"
x="0.25"
y="0.25"
width="15.5"
height="15.5"
rx="4"
/>
<line
v-if="mixed"
class="mail-checkbox__mark"
x1="4.5"
y1="8"
x2="11.5"
y2="8"
/>
<polyline
v-else
class="mail-checkbox__mark"
points="4.2 8.3 6.9 10.8 11.8 5.4"
/>
</template>
</svg>
</button>
</template>
<style scoped>
.mail-checkbox {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: transparent;
}
.mail-checkbox__box {
flex: 0 0 auto;
width: var(--size-checkbox);
height: var(--size-checkbox);
}
.mail-checkbox__empty {
fill: var(--color-surface);
stroke: var(--color-border-input);
stroke-width: 1.5;
}
.mail-checkbox__fill {
fill: var(--color-brand);
}
.mail-checkbox__mark {
fill: none;
stroke: var(--color-on-brand);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
</style>
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
const INBOX: Mailbox = {
id: 'inbox',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 3,
unreadCount: 0,
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
describe('MailListFrame (목록 칸의 틀)', () => {
let pinia: Pinia
let fresh: MessageSummary[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
fresh = []
installMockApi(() => ok({ items: fresh, pageInfo: { hasNext: false, nextCursor: null, total: fresh.length } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountList(messages: MessageSummary[]) {
const store = useMailStore()
store.mailboxes = [INBOX]
store.currentMailboxId = 'inbox'
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] }, attachTo: document.body })
return { wrapper, store, section: wrapper.get('section.message-list').element as HTMLElement }
}
it('목록을 내려 본 채 새 메일이 위에 생기면 머리 아래에 "새 메일 N통 · 맨 위로" — 누르면 맨 위로 가고 사라진다', async () => {
const old = [summary('m1'), summary('m2'), summary('m3')]
const { wrapper, store, section } = await mountList(old)
section.scrollTop = 240
fresh = [summary('new1', { isRead: false }), summary('new2', { isRead: false }), ...old]
await store.syncVisibleList()
await flushPromises()
const notice = wrapper.get('.new-mail-notice')
expect(notice.text()).toBe('새 메일 2통 · 맨 위로')
expect(wrapper.findAll('.message-row')).toHaveLength(5)
expect(section.scrollTop).toBe(240)
await notice.trigger('click')
expect(section.scrollTop).toBe(0)
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.findAll('.message-row__main')[0]?.element)
})
it('맨 위를 보고 있으면 알림 줄 없이 새 메일이 그냥 위에 생긴다', async () => {
const old = [summary('m1')]
const { wrapper, store } = await mountList(old)
fresh = [summary('new1', { isRead: false }), ...old]
await store.syncVisibleList()
await flushPromises()
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
expect(wrapper.findAll('.message-row')[0]?.classes()).toContain('is-unread')
})
it('알림 줄은 스스로 맨 위로 올라가도 사라지고, 다른 메일함으로 바꿔도 사라진다', async () => {
const old = [summary('m1')]
const { wrapper, store, section } = await mountList(old)
section.scrollTop = 300
fresh = [summary('new1'), ...old]
await store.syncVisibleList()
await flushPromises()
expect(wrapper.find('.new-mail-notice').exists()).toBe(true)
section.scrollTop = 0
section.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
section.scrollTop = 300
fresh = [summary('new2'), summary('new1'), ...old]
await store.syncVisibleList()
await flushPromises()
expect(wrapper.get('.new-mail-notice').text()).toBe('새 메일 1통 · 맨 위로')
await store.openMailbox('sent')
await flushPromises()
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
})
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
store.toggleCheck('m1')
await flushPromises()
const clear = wrapper.get('[data-tool="markRead"]')
;(clear.element as HTMLElement).focus()
await clear.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(store.checkedCount).toBe(0)
expect(document.activeElement).toBe(section)
})
})
@@ -0,0 +1,247 @@
<script setup lang="ts">
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, ref, watch } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
import { useListPositionMemory } from '@/composables/useListPositionMemory'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
variant: 'split' | 'list'
/** 목록 칸 제목의 id — 머리 · 검색 결과 머리 · 도구 줄 가운데 보이는 것이 이 id 를 단다 */
titleId: string
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const { replaceWithMailbox } = useMailNavigation()
const { setScope } = useMailSearchNavigation()
const sectionRef = ref<HTMLElement | null>(null)
const topRef = ref<HTMLElement | null>(null)
const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
// 분할 보기의 목록 칸은 메일을 열어도 사라지지 않는다 — 목록 보기의 표만 위치를 기억한다
useListPositionMemory(sectionRef, () => props.variant === 'list')
const search = computed(() => mailStore.search)
const selecting = computed(() => mailStore.checkedCount > 0)
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
const mailbox = computed(() => mailStore.currentMailbox)
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
function retry() {
const current = search.value
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
}
function leave() {
if (search.value) void setScope(SEARCH_SCOPE_ALL)
else void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
}
/** 초점이 사라진 도구 줄 대신 목록 칸에 둔다 — 키보드 사용자가 자리를 잃지 않게 */
function focusSection() {
sectionRef.value?.focus({ preventScroll: true })
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
const target = event.target instanceof Element ? event.target : null
if (target?.closest('dialog, [data-editor-popup]')) return
event.preventDefault()
mailStore.clearChecked()
}
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
watch(
() => mailStore.checkedCount,
(count, previous) => {
if (count > 0 || previous === 0) return
const active = document.activeElement
if (active === null || active === document.body) focusSection()
},
{ flush: 'post' },
)
</script>
<template>
<section
ref="sectionRef"
:class="['mail-list-frame', `mail-list-frame--${variant}`]"
:aria-labelledby="titleId"
:aria-busy="busy"
tabindex="-1"
@keydown="onKeydown"
>
<div
ref="topRef"
class="mail-list-frame__top"
>
<MailSelectionToolbar
v-if="selecting"
:title-id="titleId"
:variant="variant"
/>
<MailSearchHeader
v-else-if="search"
:title-id="titleId"
:variant="variant"
/>
<MailListHeader
v-else
:title-id="titleId"
:variant="variant"
/>
<MailSearchScope v-if="search" />
<div
class="mail-list-frame__notice"
role="status"
>
<MailNewNotice
v-if="noticeCount > 0"
:count="noticeCount"
@go-top="goTop"
/>
</div>
</div>
<MessageListSkeleton
v-if="mailStore.listStatus === 'loading'"
:label="search ? MAIL_SEARCH_TEXT.loading : undefined"
/>
<MailLoadError
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
:error="mailStore.listError"
:leave-label="leaveLabel"
@retry="retry"
@leave="leave"
/>
<template v-else-if="mailStore.messages.length > 0">
<slot />
<div
v-if="mailStore.hasMore"
:class="['mail-list-frame__more', `mail-list-frame__more--${variant}`]"
>
<button
type="button"
class="mail-list-frame__more-button"
:disabled="mailStore.isLoadingMore"
@click="mailStore.loadMore()"
>
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
</button>
</div>
</template>
<MailSearchEmpty
v-else-if="mailStore.listStatus === 'ready' && search"
:query="search.q"
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
@search-all="setScope(SEARCH_SCOPE_ALL)"
/>
<StateMessage
v-else-if="mailStore.listStatus === 'ready'"
:class="{ 'mail-list-frame__empty': variant === 'list' }"
:icon="emptyIcon"
:title="empty.title"
:hint="empty.hint"
/>
</section>
</template>
<style scoped>
.mail-list-frame {
min-width: 0;
display: flex;
flex-direction: column;
}
/* 프로그램이 준 초점(도구 줄이 사라진 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
.mail-list-frame:focus {
outline: none;
}
/* 머리 묶음 — 스크롤해도 위에 남는다 (넓은 화면은 칸, 좁은 화면은 창 기준) */
.mail-list-frame__top {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-surface);
}
.mail-list-frame__more--split {
padding: var(--space-8) var(--space-12) var(--space-16);
}
.mail-list-frame__more--list {
display: flex;
justify-content: center;
padding: var(--space-10) var(--space-20) var(--space-20);
}
.mail-list-frame__more-button {
min-height: var(--size-row-min);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.mail-list-frame__more--split .mail-list-frame__more-button {
width: 100%;
}
.mail-list-frame__more--list .mail-list-frame__more-button {
padding: 0 var(--space-24);
}
.mail-list-frame__more-button:hover:not(:disabled) {
background: var(--color-brand-subtle);
}
.mail-list-frame__more-button:disabled {
color: var(--color-text-muted);
}
.mail-list-frame__empty {
padding-block: calc(var(--space-80) + var(--space-16));
}
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.mail-list-frame {
overflow-y: auto;
}
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -54,9 +55,6 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
<style scoped>
.list-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: var(--space-8);
@@ -0,0 +1,58 @@
<script setup lang="ts">
// "새 메일 N통 · 맨 위로" — 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래에 (디자인 원본 ListStates · README "3단계 › 새 메일")
// 높이 40(밀도 예외) · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표. 누르면 맨 위로 가고 줄은 사라진다 (목록 틀이 처리한다)
import AppIcon from '@/components/AppIcon.vue'
import { NEW_MAIL_NOTICE_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
interface Props {
count: number
}
interface Emits {
(e: 'go-top'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<button
type="button"
class="new-mail-notice"
@click="emit('go-top')"
>
<AppIcon
name="arrow-up"
:size="14"
:stroke-width="2"
/>
<span>{{ NEW_MAIL_NOTICE_TEXT(formatCount(count)) }}</span>
</button>
</template>
<style scoped>
.new-mail-notice {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
width: 100%;
min-height: var(--size-notice-bar);
padding: 0 var(--space-12);
border: 0;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-brand-tint);
color: var(--color-brand-strong);
font-weight: 600;
}
.new-mail-notice:hover {
background: var(--color-row-checked);
}
.new-mail-notice:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
count: number
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
:message="MAIL_BULK_TEXT.purgeMessage"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('cancel')"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
data-purge-confirm
@click="emit('confirm')"
>
{{ MAIL_BULK_TEXT.purgeConfirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-purge-cancel
autofocus
@click="emit('cancel')"
>
{{ MAIL_BULK_TEXT.purgeCancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailSearchBox from './MailSearchBox.vue'
// 상단 검색 칸 (디자인 README "3단계 › 검색" · 계약 2절 "검색 › 클라이언트")
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
}
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('trash', 'trash', '휴지통')]
const Q = encodeURIComponent('회의')
describe('MailSearchBox (메일 검색)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
useMailStore().mailboxes = MAILBOXES
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailSearchBox, { global: { plugins: [pinia, router] } })
return { wrapper, router, input: wrapper.get('input') }
}
it('이름 · 안내는 "메일 검색" — 검색 중이 아니면 ✕ 가 없다', async () => {
const { wrapper, input } = await mountAt('/mail/inbox')
expect(input.attributes('aria-label')).toBe('메일 검색')
expect(input.attributes('placeholder')).toBe('메일 검색')
expect(wrapper.get('form').attributes('role')).toBe('search')
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.get('form').classes()).not.toContain('is-active')
})
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
const { router, input } = await mountAt('/mail/inbox/m7')
const push = vi.spyOn(router, 'push')
await input.setValue(' 회의 ')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(push).toHaveBeenCalled()
expect(useMailStore().searchOrigin).toEqual({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
})
it('휴지통에서 시작하면 그 안에서 찾는다', async () => {
const { router, input } = await mountAt('/mail/trash')
await input.setValue('회의')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=trash`)
})
it('한글 조합 중의 Enter 는 글자 확정이다 — 찾지 않는다 (isComposing · Safari 의 keyCode 229)', async () => {
const { router, input } = await mountAt('/mail/inbox')
await input.setValue('회의')
await input.trigger('keydown', { key: 'Enter', isComposing: true })
const safari = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
input.element.dispatchEvent(safari)
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(safari.defaultPrevented).toBe(false)
})
it('낱말이 8개를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로', async () => {
const { router, input } = await mountAt('/mail/inbox')
const words = '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉'
await input.setValue(words)
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect((input.element as HTMLInputElement).value).toBe(words)
})
it('빈 검색어의 Enter 는 아무것도 하지 않는다', async () => {
const { router, input } = await mountAt('/mail/inbox')
await input.setValue(' ')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('검색 화면 — 칸에 검색어를 보이고 테두리가 강조색, 끝에 ✕("검색어 지우기") — ✕ 는 검색 전 메일함으로', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
expect((input.element as HTMLInputElement).value).toBe('회의')
expect(wrapper.get('form').classes()).toContain('is-active')
const clear = wrapper.get('button')
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
await clear.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
expect((input.element as HTMLInputElement).value).toBe('')
})
it('검색 화면의 Esc 는 ✕ 와 같다 — 시작한 곳을 모르면(주소로 바로 들어옴) 그 범위의 메일함으로', async () => {
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=trash`)
await input.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/trash')
})
it('검색 화면이 아니면 Esc 는 친 글자만 지운다', async () => {
const { router, input } = await mountAt('/mail/inbox')
await input.setValue('회의')
await input.trigger('keydown', { key: 'Escape' })
expect((input.element as HTMLInputElement).value).toBe('')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('검색 화면에서 새로 찾으면 지금 범위 그대로 바꿔 끼운다 (기록을 쌓지 않는다)', async () => {
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all`)
const push = vi.spyOn(router, 'push')
await input.setValue('일정')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${encodeURIComponent('일정')}&in=all`)
expect(push).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,157 @@
<script setup lang="ts">
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
import { ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const text = ref<string>('')
function onKeydown(event: KeyboardEvent) {
if (isImeComposing(event)) return
if (event.key === 'Enter') {
event.preventDefault()
void startSearch(text.value)
return
}
if (event.key !== 'Escape') return
if (isSearching.value) {
event.preventDefault()
void closeSearch()
} else if (text.value !== '') {
event.preventDefault()
text.value = ''
}
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
watch(
() => routeSearch.value?.q ?? null,
(query, previous) => {
if (query !== null) text.value = query
else if (previous) text.value = ''
},
{ immediate: true },
)
</script>
<template>
<form
role="search"
:class="['mail-search', { 'is-active': isSearching }]"
@submit.prevent
>
<AppIcon
name="search"
class="mail-search__icon"
/>
<input
v-model="text"
type="search"
class="mail-search__input"
:aria-label="MAIL_SEARCH_TEXT.box"
:placeholder="MAIL_SEARCH_TEXT.box"
:maxlength="MAIL_SEARCH_MAX_LENGTH"
autocomplete="off"
enterkeyhint="search"
@keydown="onKeydown"
>
<button
v-if="isSearching"
type="button"
class="mail-search__clear"
:aria-label="MAIL_SEARCH_TEXT.clear"
:title="MAIL_SEARCH_TEXT.clearTitle"
@click="closeSearch"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</form>
</template>
<style scoped>
.mail-search {
flex: 1 1 17.5rem;
max-width: 32.5rem;
display: flex;
align-items: center;
gap: var(--space-8);
height: var(--size-search-box);
margin: 0;
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-surface);
}
/* 검색 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 (입력칸 자체의 고리 대신 칸 테두리로 초점을 보인다) */
.mail-search.is-active,
.mail-search:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
.mail-search.is-active {
padding-right: 0;
}
.mail-search__icon {
color: var(--color-text-muted);
}
.mail-search__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
}
.mail-search__input:focus,
.mail-search__input:focus-visible {
outline: none;
}
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
.mail-search__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css) */
.mail-search__clear {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-search-clear-width);
height: var(--size-search-clear-height);
padding: 0;
border: 0;
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
background: transparent;
color: var(--color-text-muted);
}
.mail-search__clear:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.mail-search__clear:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,63 @@
<script setup lang="ts">
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
import StateMessage from '@/components/feedback/StateMessage.vue'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
interface Props {
query: string
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
offerAll: boolean
}
interface Emits {
(e: 'search-all'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<StateMessage
class="search-empty"
icon="search"
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
:hint="MAIL_SEARCH_TEXT.emptyHint"
>
<button
v-if="offerAll"
type="button"
class="search-empty__all"
@click="emit('search-all')"
>
{{ MAIL_SEARCH_TEXT.searchAll }}
</button>
</StateMessage>
</template>
<style scoped>
.search-empty {
padding: var(--space-72) var(--space-28);
}
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
.search-empty :deep(.state-message__hint) {
max-width: 18.75rem;
}
.search-empty__all {
margin-top: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.search-empty__all:hover {
background: var(--color-brand-subtle);
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { directionParticle } from '@/shared/utils/korean'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 제목(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
titleId: string
variant?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
variant: 'split',
})
const mailStore = useMailStore()
const { originMailbox, closeSearch } = useMailSearchNavigation()
const countText = computed(() => {
const query = mailStore.search?.q
if (!query) return ''
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
})
const closeTitle = computed(() => {
const name = originMailbox.value?.name
return name ? MAIL_SEARCH_TEXT.closeTitle(`${name}${directionParticle(name)}`) : MAIL_SEARCH_TEXT.close
})
</script>
<template>
<div :class="['search-header', `search-header--${variant}`]">
<button
type="button"
class="search-header__close"
:aria-label="MAIL_SEARCH_TEXT.close"
:title="closeTitle"
@click="closeSearch"
>
<AppIcon
name="arrow-left"
:stroke-width="1.9"
/>
</button>
<h1
:id="titleId"
class="search-header__title"
>
{{ MAIL_SEARCH_TEXT.title }}
</h1>
<span class="search-header__count">{{ countText }}</span>
<ViewModeSwitch class="search-header__switch" />
</div>
</template>
<style scoped>
.search-header {
display: flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-header-min);
padding: var(--space-4);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 목록 보기 — 오른쪽 여백을 표 머리와 맞춘다 (디자인 원본 ListStates) */
.search-header--list {
padding: var(--space-4) var(--space-8);
}
.search-header__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-icon-button);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
}
.search-header__close:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.search-header__title {
flex: 0 0 auto;
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.search-header__count {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.search-header__switch {
flex: 0 0 auto;
margin-left: auto;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
// 검색 범위 줄(48 · 바탕 #FBFBFC) — "찾을 곳" + [그 메일함 | 전체 메일함] + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"
// 디자인 원본 Search · README "3단계 › 검색". 전환 단추는 설정 화면과 같은 .segmented(36 — 밀도 예외, base.css)
// "그 메일함"은 검색을 시작한 메일함이다 (휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다) — 바꾸면 기록을 쌓지 않고 다시 찾는다
import { computed } from 'vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
const { originMailbox, setScope } = useMailSearchNavigation()
const isAll = computed(() => mailStore.search?.scope === SEARCH_SCOPE_ALL)
/** 그 메일함 — 한 메일함에서 찾는 중이면 그 메일함, 전체에서 찾는 중이면 검색을 시작한 메일함 */
const boxMailbox = computed<Mailbox | null>(() => (isAll.value ? originMailbox.value : mailStore.currentMailbox))
</script>
<template>
<div class="search-scope">
<span
id="search-scope-label"
class="search-scope__label"
>{{ MAIL_SEARCH_TEXT.scopeLabel }}</span>
<div
class="segmented"
role="group"
aria-labelledby="search-scope-label"
>
<button
v-if="boxMailbox"
type="button"
class="segmented__item"
:aria-pressed="!isAll"
@click="setScope(boxMailbox.id)"
>
{{ boxMailbox.name }}
</button>
<button
type="button"
class="segmented__item"
:aria-pressed="isAll"
@click="setScope(SEARCH_SCOPE_ALL)"
>
{{ MAIL_SEARCH_TEXT.scopeAll }}
</button>
</div>
<span
v-if="isAll"
class="search-scope__hint"
>{{ MAIL_SEARCH_TEXT.scopeAllHint }}</span>
</div>
</template>
<style scoped>
.search-scope {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-10);
min-height: var(--space-48);
padding: var(--space-4) var(--space-12) var(--space-4) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface-sunken);
}
.search-scope__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.search-scope__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,379 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
import MessageTable from './MessageTable.vue'
// 여러 통 처리 — 도구 줄 · 이동 메뉴 · 알림 · 되돌리기 · 영구 삭제 확인 (디자인 원본 BulkSelect · ListStates)
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0 }
}
const MAILBOXES = [
mailbox('inbox', 'inbox', '받은편지함'),
mailbox('sent', 'sent', '보낸편지함'),
mailbox('drafts', 'drafts', '임시보관함'),
mailbox('junk', 'junk', '스팸메일함'),
mailbox('trash', 'trash', '휴지통'),
mailbox('archive', 'archive', '보관함'),
]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
type Handler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
describe('여러 통 처리 도구 줄', () => {
let pinia: Pinia
let requests: MockRequest[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountView(
options: { view?: 'split' | 'list'; boxId?: string; messages?: MessageSummary[]; path?: string; handler?: Handler } = {},
) {
const boxId = options.boxId ?? 'inbox'
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
requests = installMockApi((request) => {
const custom = options.handler?.(request)
if (custom) return custom
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 6 } })
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
}
if (request.method === 'PATCH') {
const patch = request.data as MessagesPatch
return ok({ updatedIds: patch.ids, failedIds: [] })
}
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.currentMailboxId = boxId
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(options.path ?? `/mail/${boxId}`)
await router.isReady()
const wrapper = mount(options.view === 'split' ? MessageList : MessageTable, {
global: { plugins: [pinia, router] },
attachTo: document.body,
})
return { wrapper, router, store }
}
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
const bulkPatches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
store.toggleCheck('m2')
await flushPromises()
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
})
it('읽음 · 안 읽음 · 중요 표시 — PATCH /messages 한 번, 목록이 바로 바뀌고 알리지 않는다 · 모두 중요면 "중요 해제"', async () => {
const { wrapper, store } = await mountView({
messages: [summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
})
store.toggleCheck('m1')
store.toggleCheck('m2')
await flushPromises()
await wrapper.get('[data-tool="markRead"]').trigger('click')
await flushPromises()
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], isRead: true }])
expect(wrapper.findAll('tbody tr.is-unread')).toHaveLength(0)
await wrapper.get('[data-tool="flag"]').trigger('click')
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], isFlagged: true })
expect(wrapper.get('[data-tool="flag"]').attributes('aria-label')).toBe('중요 해제')
expect(toastTexts()).toEqual([])
})
it('이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함, 아래 화살표로 열면 첫 항목에 초점 · Esc 는 메뉴만 닫는다', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
await flushPromises()
const move = wrapper.get('[data-tool="move"]')
await move.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe('옮길 메일함')
expect(items.map((item) => item.text())).toEqual(['보낸편지함', '스팸메일함', '보관함'])
expect(document.activeElement).toBe(items[0]?.element)
expect(move.attributes('aria-expanded')).toBe('true')
await items[0]?.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-tool="move"]').element)
expect(store.checkedCount).toBe(1)
})
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
store.toggleCheck('m3')
await flushPromises()
await wrapper.get('[data-tool="move"]').trigger('click')
await flushPromises()
const archive = wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')
await archive?.trigger('click')
await flushPromises()
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'archive' }])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(store.checkedCount).toBe(0)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
})
it('일부만 옮겼으면 알림 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."', async () => {
const { wrapper, store } = await mountView({
handler: (request) =>
request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm2'], failedIds: ['m3'] }) : undefined,
})
store.toggleCheckAll()
await flushPromises()
await wrapper.get('[data-tool="move"]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보낸편지함')?.trigger('click')
await flushPromises()
expect(toastTexts()).toEqual([
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
])
})
it('휴지통 밖의 삭제는 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 }', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m2')
await flushPromises()
await wrapper.get('[data-tool="delete"]').trigger('click')
await flushPromises()
expect(bulkPatches()).toEqual([{ ids: ['m2'], mailboxId: 'trash' }])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
expect(toastTexts()).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
})
it('요청이 실패하면 인터셉터 알림만 — 고른 것을 그대로 둔다', async () => {
const { wrapper, store } = await mountView({
handler: (request) => (request.method === 'PATCH' ? fail(503, 'MAIL_001') : undefined),
})
store.toggleCheck('m1')
await flushPromises()
await wrapper.get('[data-tool="delete"]').trigger('click')
await flushPromises()
expect(store.messages).toHaveLength(3)
expect(store.checkedCount).toBe(1)
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
})
describe('휴지통 — 영구 삭제 확인', () => {
const trashed = () => [summary('t1', { mailboxIds: ['trash'] }), summary('t2', { mailboxIds: ['trash'] })]
it('"영구 삭제"를 누르면 먼저 묻는다 — 제목 · 글, 처음 초점은 「취소」 · 취소하면 지우지 않는다', async () => {
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
store.toggleCheckAll()
await flushPromises()
const purge = wrapper.get('[data-tool="delete"]')
expect(purge.attributes('aria-label')).toBe('영구 삭제')
await purge.trigger('click')
await flushPromises()
const dialog = wrapper.get('dialog')
expect(dialog.attributes('open')).toBeDefined()
expect(dialog.get('#bulk-purge-title').text()).toBe('메일 2통을 영구 삭제할까요?')
expect(dialog.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
// 대화상자 안의 Esc 는 고른 것을 풀지 않는다 (목록 칸의 Esc 와 겹치지 않게)
await dialog.get('[data-purge-cancel]').trigger('keydown', { key: 'Escape' })
await dialog.get('[data-purge-cancel]').trigger('click')
await flushPromises()
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
expect(store.checkedCount).toBe(2)
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
})
it('「영구 삭제」를 누르면 POST /message-deletions — 지운 메일을 빼고 "메일 N통을 영구 삭제했습니다."', async () => {
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
store.toggleCheckAll()
await flushPromises()
await wrapper.get('[data-tool="delete"]').trigger('click')
await flushPromises()
await wrapper.get('[data-purge-confirm]').trigger('click')
await flushPromises()
const deletion = requests.find((request) => request.url === '/message-deletions')
expect(deletion).toMatchObject({ method: 'POST', data: { ids: ['t1', 't2'] } })
expect(store.messages).toEqual([])
expect(toastTexts()).toEqual(['메일 2통을 영구 삭제했습니다.'])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
})
})
it('분할 보기 — 열어 둔 메일을 옮기면 목록 주소로 바꿔 끼운다', async () => {
const { wrapper, router, store } = await mountView({ view: 'split', path: '/mail/inbox/m2' })
store.selectedId = 'm2'
store.toggleCheck('m2')
await flushPromises()
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
await wrapper.get('[data-tool="delete"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('도구 줄 안에서는 화살표로 단추를 옮긴다 (로빙 tabindex) · 선택 해제는 고른 것을 푼다', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
await flushPromises()
const first = wrapper.get('[data-tool="markRead"]')
expect(first.attributes('tabindex')).toBe('0')
expect(wrapper.get('[data-tool="markUnread"]').attributes('tabindex')).toBe('-1')
await first.trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement).toBe(wrapper.get('[data-tool="markUnread"]').element)
await wrapper.get('[data-tool="markUnread"]').trigger('keydown', { key: 'End' })
expect(document.activeElement).toBe(wrapper.get('[data-tool="clear"]').element)
await wrapper.get('[data-tool="clear"]').trigger('click')
await flushPromises()
expect(store.checkedCount).toBe(0)
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
})
it('옮기는 동안 눌리지 않는 단추(이동 · 삭제)는 Tab 자리 · 화살표에서 건너뛰고, 초점이 그 단추에 있었으면 다음 단추로 옮긴다', async () => {
let release: (result: MockResult) => void = () => undefined
const { wrapper, store } = await mountView({
handler: (request) =>
request.method === 'PATCH'
? new Promise<MockResult>((resolve) => {
release = resolve
})
: undefined,
})
store.toggleCheck('m1')
await flushPromises()
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
;(tool('delete').element as HTMLElement).focus()
await flushPromises()
expect(tool('delete').attributes('tabindex')).toBe('0')
await tool('delete').trigger('click')
await flushPromises()
expect(tool('delete').attributes('disabled')).toBeDefined()
expect(tool('move').attributes('disabled')).toBeDefined()
expect(tool('delete').attributes('tabindex')).toBe('-1')
expect(tool('clear').attributes('tabindex')).toBe('0')
expect(document.activeElement).toBe(tool('clear').element)
await tool('flag').trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement).toBe(tool('clear').element)
await tool('clear').trigger('keydown', { key: 'ArrowLeft' })
expect(document.activeElement).toBe(tool('flag').element)
release(ok({ updatedIds: ['m1'], failedIds: [] }))
await flushPromises()
expect(store.checkedCount).toBe(0)
})
it('초점이 도구 줄 밖에 있어도 Tab 자리는 누를 수 있는 단추로 옮긴다 — 초점은 빼앗지 않는다', async () => {
let release: (result: MockResult) => void = () => undefined
const { wrapper, store } = await mountView({
handler: (request) =>
request.method === 'PATCH'
? new Promise<MockResult>((resolve) => {
release = resolve
})
: undefined,
})
store.toggleCheck('m1')
await flushPromises()
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
;(tool('delete').element as HTMLElement).focus()
const outside = document.createElement('button')
document.body.append(outside)
outside.focus()
await flushPromises()
await tool('delete').trigger('click')
await flushPromises()
expect(tool('delete').attributes('tabindex')).toBe('-1')
expect(tool('clear').attributes('tabindex')).toBe('0')
expect(document.activeElement).toBe(outside)
release(ok({ updatedIds: ['m1'], failedIds: [] }))
await flushPromises()
})
})
@@ -0,0 +1,274 @@
<script setup lang="ts">
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
import { computed, nextTick, ref, watch } from 'vue'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** "N통 선택됨"(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
titleId: string
variant: 'split' | 'list'
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const bulk = useBulkMailActions()
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
interface LeadingTool {
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
icon: IconName
label: string
run: () => void
}
const toolsRef = ref<HTMLElement | null>(null)
/** 로빙 tabindex — 마지막으로 초점이 간 단추 (Tab 으로 들어올 단추는 rovingTool) */
const activeTool = ref<ToolId>('markRead')
const purgeOpen = ref<boolean>(false)
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
let lastFocusedTool: ToolId | null = null
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
const rovingTool = computed<ToolId>(() => {
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
const start = TOOL_ORDER.indexOf(activeTool.value)
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
if (candidate && !disabledTools.value.has(candidate)) return candidate
}
return 'clear'
})
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
const leadingTools = computed<LeadingTool[]>(() => [
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
{
id: 'flag',
icon: 'star',
label: bulk.allFlagged.value ? MAIL_BULK_TEXT.unflag : MAIL_BULK_TEXT.flag,
run: () => void bulk.toggleFlagged(),
},
])
function tabindexOf(id: ToolId): number {
return rovingTool.value === id ? 0 : -1
}
function focusTool(id: ToolId) {
activeTool.value = id
toolsRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`)?.focus()
}
function onDelete() {
if (bulk.inTrash.value) {
purgeOpen.value = true
return
}
void bulk.moveToTrash()
}
function onPurgeConfirm() {
purgeOpen.value = false
void bulk.purge()
}
function onKeydown(event: KeyboardEvent) {
const target = event.target instanceof HTMLElement ? event.target : null
// 메뉴 · 대화상자 안의 키는 그쪽이 처리한다
if (!target || target.closest('[data-editor-popup], dialog')) return
const order = enabledTools.value
const current = order.findIndex((id) => id === target.dataset.tool)
if (current === -1) return
const last = order.length - 1
const moves: Record<string, number> = {
ArrowRight: current === last ? 0 : current + 1,
ArrowLeft: current === 0 ? last : current - 1,
Home: 0,
End: last,
}
const next = order[moves[event.key] ?? -1]
if (!next) return
event.preventDefault()
focusTool(next)
}
function onFocusIn(event: FocusEvent) {
const element = event.target instanceof HTMLElement ? event.target : null
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
if (!tool) return
activeTool.value = tool
lastFocusedTool = tool
}
/** 초점이 도구 줄 밖의 다른 곳으로 갔다 — 갈 곳 없이 사라진 것(단추가 눌리지 않게 됨)은 기억해 둔다 */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && toolsRef.value?.contains(next)) return
if (next !== null) lastFocusedTool = null
}
// 초점이 있던 단추를 누를 수 없게 되면(옮기기 · 지우기 시작) 다음 누를 수 있는 단추로 초점을 옮긴다 — 키보드 사용자가 자리를 잃지 않게
watch(disabledTools, (disabled) => {
const focused = lastFocusedTool
if (!focused || !disabled.has(focused)) return
void nextTick(() => {
const active = document.activeElement
const lost =
active === null ||
active === document.body ||
(active instanceof HTMLElement && active.dataset.tool === focused)
if (lost) focusTool(rovingTool.value)
})
})
</script>
<template>
<div :class="['bulk-bar', `bulk-bar--${variant}`]">
<h1
:id="titleId"
class="bulk-bar__count"
>
{{ countText }}
</h1>
<div
ref="toolsRef"
class="bulk-bar__tools"
role="toolbar"
:aria-label="MAIL_BULK_TEXT.toolbar"
@keydown="onKeydown"
@focusin="onFocusIn"
@focusout="onFocusOut"
>
<MailBulkButton
v-for="tool in leadingTools"
:key="tool.id"
:icon="tool.icon"
:label="tool.label"
:label-mode="labelMode"
:data-tool="tool.id"
:tabindex="tabindexOf(tool.id)"
@click="tool.run"
/>
<span
v-if="variant === 'list'"
class="bulk-bar__divider"
aria-hidden="true"
/>
<MailBulkMoveButton
:mailboxes="bulk.moveTargets.value"
:label-mode="labelMode"
:disabled="bulk.busy.value"
data-tool="move"
:tabindex="tabindexOf('move')"
@move="bulk.moveTo"
/>
<MailBulkButton
icon="trash"
:label="deleteLabel"
:label-mode="labelMode"
tone="danger"
:disabled="bulk.busy.value"
data-tool="delete"
:tabindex="tabindexOf('delete')"
@click="onDelete"
/>
<MailBulkButton
class="bulk-bar__clear"
icon="x"
:label="MAIL_BULK_TEXT.clear"
:title="MAIL_BULK_TEXT.clearTitle"
:label-mode="variant === 'split' ? 'never' : 'always'"
:icon-stroke-width="1.9"
data-tool="clear"
:tabindex="tabindexOf('clear')"
@click="mailStore.clearChecked()"
/>
</div>
<MailPurgeDialog
:open="purgeOpen"
:count="mailStore.checkedCount"
id-prefix="bulk-purge"
@confirm="onPurgeConfirm"
@cancel="purgeOpen = false"
/>
</div>
</template>
<style scoped>
.bulk-bar {
display: flex;
align-items: center;
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-brand-tint);
}
/* 분할 보기의 목록 칸(384) — 촘촘하게 (디자인 원본 ListStates) */
.bulk-bar--split {
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-12);
}
.bulk-bar__count {
flex: 0 0 auto;
margin: 0 var(--space-10) 0 0;
font-size: var(--font-size-body);
font-weight: 700;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.bulk-bar--split .bulk-bar__count {
margin-right: var(--space-4);
}
.bulk-bar__tools {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.bulk-bar--split .bulk-bar__tools {
gap: 0;
}
.bulk-bar__divider {
width: var(--space-1);
height: var(--space-20);
margin: 0 var(--space-6);
background: var(--color-border-strong);
}
.bulk-bar__clear {
margin-left: auto;
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · useMessageRemoval)
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageSummary
/** split: 지우면 다음 메일을 연다 · list: 지우면 목록으로 */
layout: 'split' | 'list'
}
const props = defineProps<Props>()
const router = useRouter()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const { backToSourceList } = useMailNavigation()
const working = ref<boolean>(false)
const confirmOpen = ref<boolean>(false)
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
const permanent = computed(() => removal.deletesPermanently(props.message))
async function run(kind: 'trash' | 'purge') {
const target = props.message
const source = mailStore.listSource
working.value = true
try {
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
if (!outcome.removed || !source) return
if (props.layout === 'list') {
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToSourceList(source)
return
}
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
} finally {
working.value = false
}
}
function onClick() {
if (working.value) return
if (permanent.value) confirmOpen.value = true
else void run('trash')
}
function onConfirm() {
confirmOpen.value = false
void run('purge')
}
</script>
<template>
<MailToolButton
icon="trash"
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
tone="danger"
:disabled="working"
data-reader-delete
@click="onClick"
/>
<MailPurgeDialog
:open="confirmOpen"
:count="1"
id-prefix="reader-purge"
@confirm="onConfirm"
@cancel="confirmOpen = false"
/>
</template>
@@ -2,22 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { flushPromises, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
const DRAFTS: Mailbox = {
id: 'drafts',
name: '임시보관함',
role: 'drafts',
const INBOX: Mailbox = {
id: 'inbox',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 3,
totalCount: 2,
sortOrder: 0,
totalCount: 3,
unreadCount: 0,
}
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', sortOrder: 3, totalCount: 2 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
@@ -50,21 +53,22 @@ describe('MessageList (분할 보기 목록)', () => {
restoreApiAdapter()
})
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
async function mountList(mailbox: Mailbox, messages: MessageSummary[], path = `/mail/${mailbox.id}`) {
const store = useMailStore()
store.mailboxes = [mailbox]
store.mailboxes = [INBOX, SENT, DRAFTS]
store.currentMailboxId = mailbox.id
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.push(path)
await router.isReady()
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] } })
return { wrapper, router, store }
}
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const wrapper = await mountList(DRAFTS, [
const { wrapper } = await mountList(DRAFTS, [
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
summary('d2', { to: [], subject: '' }),
])
@@ -80,11 +84,76 @@ describe('MessageList (분할 보기 목록)', () => {
})
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
const { wrapper } = await mountList(SENT, [summary('s1', { mailboxIds: ['sent'] })])
const row = wrapper.get('.message-row')
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
})
describe('여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기")', () => {
const inboxRows = () => [
summary('m1', { mailboxIds: ['inbox'] }),
summary('m2', { mailboxIds: ['inbox'] }),
summary('m3', { mailboxIds: ['inbox'] }),
]
it('행마다 고르기 상자("고르기: 제목")가 있고, 하나라도 고르면 모든 행이 고르기 칸을 보인다', async () => {
const { wrapper, store } = await mountList(INBOX, inboxRows())
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(0)
const box = wrapper.findAll('.message-row [role="checkbox"]')[1]
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
await box?.trigger('click')
expect(store.checkedIds.has('m2')).toBe(true)
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(3)
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-checked')
// 머리 자리가 도구 줄로 — 분할 보기의 목록 칸에서는 아이콘만(이름은 aria-label · 툴팁)
expect(wrapper.get('#message-list-title').text()).toBe('1통 선택됨')
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
expect(wrapper.get('[data-tool="markRead"]').attributes('aria-label')).toBe('읽음 표시')
})
it('행을 누르면 지금처럼 메일을 열고, Ctrl(⌘)+클릭 · Shift+클릭은 열지 않고 고른다', async () => {
const { wrapper, router, store } = await mountList(INBOX, inboxRows())
const main = (index: number) => wrapper.findAll('.message-row__main')[index]
await main(0)?.trigger('click', { metaKey: true })
await main(2)?.trigger('click', { shiftKey: true })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
await main(1)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
expect(store.checkedCount).toBe(3)
})
})
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
const q = encodeURIComponent('회의')
const { wrapper, store } = await mountList(
INBOX,
[
summary('r1', { mailboxIds: ['inbox'], from: { name: '이도윤', email: 'doyun.lee@arasystem.test' } }),
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
],
`/mail/search?q=${q}&in=all`,
)
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
await flushPromises()
const rows = wrapper.findAll('.message-row')
expect(rows.map((row) => row.get('.message-row__who').text())).toEqual(['이도윤', '받는 사람: 정하늘'])
expect(rows.map((row) => row.get('.message-row__mailbox').text())).toEqual(['받은편지함 ·', '보낸편지함 ·'])
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe(`/mail/search/r1?q=${q}&in=all`)
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
})
})
+30 -125
View File
@@ -1,111 +1,50 @@
<script setup lang="ts">
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
import { computed } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageListItem from '@/components/mail/MessageListItem.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailListRows } from '@/composables/useMailListRows'
import { useMailStore } from '@/stores/mail.store'
interface Props {
mailboxId: string
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const { replaceWithMailbox } = useMailNavigation()
const rows = useMailListRows()
const mailbox = computed(() => mailStore.currentMailbox)
const showRecipients = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
function retry() {
void mailStore.openMailbox(props.mailboxId)
}
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
function goToInbox() {
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
if (range) mailStore.checkRangeTo(id)
else mailStore.toggleCheck(id)
}
</script>
<template>
<section
<MailListFrame
class="message-list"
aria-labelledby="message-list-title"
:aria-busy="busy"
variant="split"
title-id="message-list-title"
>
<MailListHeader title-id="message-list-title" />
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
<MailLoadError
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
:error="mailStore.listError"
leave-label="받은편지함으로"
@retry="retry"
@leave="goToInbox"
/>
<template v-else-if="mailStore.messages.length > 0">
<ul class="message-list__items">
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:mailbox-id="mailboxId"
:selected="message.id === mailStore.selectedId"
:show-recipients="showRecipients"
:draft="isDrafts"
@toggle-flag="mailStore.toggleFlag"
/>
</ul>
<div
v-if="mailStore.hasMore"
class="message-list__more"
>
<button
type="button"
class="message-list__text-button message-list__more-button"
:disabled="mailStore.isLoadingMore"
@click="mailStore.loadMore()"
>
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
</button>
</div>
</template>
<StateMessage
v-else-if="mailStore.listStatus === 'ready'"
:icon="emptyIcon"
:title="empty.title"
:hint="empty.hint"
/>
</section>
<ul class="message-list__items">
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:selected="message.id === mailStore.selectedId"
:checked="mailStore.checkedIds.has(message.id)"
:checking="mailStore.checkedCount > 0"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</ul>
</MailListFrame>
</template>
<style scoped>
.message-list {
flex: 1 1 24rem;
min-width: 0;
display: flex;
flex-direction: column;
border-right: var(--border-width) solid var(--color-border);
}
@@ -114,38 +53,4 @@ function goToInbox() {
padding: 0;
list-style: none;
}
.message-list__more {
padding: var(--space-8) var(--space-12) var(--space-16);
}
.message-list__text-button {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.message-list__text-button:hover:not(:disabled) {
background: var(--color-brand-subtle);
}
.message-list__more-button {
width: 100%;
min-height: var(--size-row-min);
}
.message-list__more-button:disabled {
color: var(--color-text-muted);
}
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.message-list {
overflow-y: auto;
}
}
</style>
+119 -33
View File
@@ -1,73 +1,105 @@
<script setup lang="ts">
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
interface Props {
message: MessageSummary
mailboxId: string
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
to: RouteLocationRaw
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
role: MailboxRole | null
/** 전체 메일함 검색 결과 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
mixed?: boolean
/** 시각 앞에 보일 메일함 이름 (전체 메일함 검색 결과) */
mailboxName?: string | null
/** 지금 열려 있는 메일 */
selected?: boolean
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
showRecipients?: boolean
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
draft?: boolean
/** 고른 메일 (여러 통 처리) */
checked?: boolean
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
checking?: boolean
}
interface Emits {
(e: 'toggle-flag', id: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', id: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
mixed: false,
mailboxName: null,
selected: false,
showRecipients: false,
draft: false,
checked: false,
checking: false,
})
const emit = defineEmits<Emits>()
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
const who = computed(() => {
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
if (props.showRecipients) {
const recipients = summarizeRecipients(props.message.to)
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
}
return displayName(props.message.from) || '(보낸 사람 없음)'
})
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const time = computed(() => formatMailListTime(props.message.receivedAt))
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
const unread = computed(() => !props.draft && !props.message.isRead)
const location = computed<RouteLocationRaw>(() =>
props.draft
? { name: 'mail-compose', query: { draft: props.message.id } }
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
)
const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
function onMainClick(event: MouseEvent) {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
event.preventDefault()
emit('check', props.message.id, event.shiftKey)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
</script>
<template>
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
<li
:class="[
'message-row',
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="message.id"
@mousedown="onMouseDown"
>
<MailCheckbox
class="message-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
/>
<RouterLink
class="message-row__main"
:to="location"
:to="to"
:data-message-id="message.id"
@click="onMainClick"
>
<span class="message-row__top">
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
<span :class="['message-row__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>안 읽음, </span>{{ who }}
>안 읽음, </span>{{ who.text }}
</span>
<span
v-if="mixed && mailboxName"
class="message-row__mailbox"
>{{ mailboxName }} ·</span>
<time
class="message-row__time"
:datetime="message.receivedAt"
@@ -104,6 +136,7 @@ const location = computed<RouteLocationRaw>(() =>
<style scoped>
.message-row {
position: relative;
display: flex;
align-items: stretch;
border-bottom: var(--border-width) solid var(--color-border-subtle);
@@ -118,6 +151,45 @@ const location = computed<RouteLocationRaw>(() =>
background: var(--color-brand-subtle);
}
/* 고른 행 (디자인 원본 ListStates · BulkSelect) */
.message-row.is-checked {
background: var(--color-row-checked);
}
.message-row.is-checked:hover {
background: var(--color-row-checked-hover);
}
/*
* 고르기 칸 — 고르지 않았고 마우스 · 초점도 없으면 칸 없이 지금 행 그대로다 (디자인 원본 ListStates).
* 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 — 초점이 오면 행에 초점이 있으므로 나타난다
*/
.message-row__check {
position: absolute;
width: var(--space-1);
height: var(--space-1);
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외)에 16px 상자, 상자는 칸 오른쪽에 붙는다 */
.message-row:hover .message-row__check,
.message-row:focus-within .message-row__check,
.message-row.is-checking .message-row__check {
position: static;
flex: 0 0 auto;
justify-content: flex-end;
width: var(--size-row-check);
height: auto;
overflow: visible;
clip-path: none;
}
.message-row__check:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
.message-row__main {
flex: 1 1 auto;
@@ -133,6 +205,13 @@ const location = computed<RouteLocationRaw>(() =>
text-decoration: none;
}
/* 고르기 칸이 보이면 칸 뒤 10px 에서 글이 시작한다 */
.message-row:hover .message-row__main,
.message-row:focus-within .message-row__main,
.message-row.is-checking .message-row__main {
padding-left: var(--space-10);
}
.message-row__main:hover {
color: inherit;
}
@@ -161,6 +240,13 @@ const location = computed<RouteLocationRaw>(() =>
color: var(--color-text-muted);
}
/* 전체 메일함 검색 결과 — 시각 앞의 메일함 이름 (디자인 원본 Search) */
.message-row__mailbox {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.message-row__time {
flex: 0 0 auto;
font-size: var(--font-size-small);
@@ -1,18 +1,21 @@
<script setup lang="ts">
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중" · Search "찾는 중")
interface Props {
rows?: number
/** 스크린리더에 읽힐 이름 — 검색 중이면 "찾는 중" */
label?: string
}
withDefaults(defineProps<Props>(), {
rows: 7,
label: '메일 목록 불러오는 중',
})
</script>
<template>
<ul
class="list-skeleton"
aria-label="메일 목록 불러오는 중"
:aria-label="label"
aria-busy="true"
>
<li
@@ -5,10 +5,19 @@ import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import type { Mailbox, MessageDetail, MessagesPatch } 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 { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import {
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
@@ -270,3 +279,181 @@ describe('MessageReader 불러오기 실패', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
})
})
describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를 쓰지 않는다', () => {
let pinia: Pinia
let requests: MockRequest[]
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(
options: {
mailboxIds?: string[]
layout?: 'split' | 'list'
handler?: (request: MockRequest) => MockResult | undefined
source?: MailListSource
} = {},
) {
requests = installMockApi((request) => {
const custom = options.handler?.(request)
if (custom) return custom
if (request.url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url === '/message-deletions') {
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = [INBOX, TRASH]
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push('/mail/inbox')
await router.push('/mail/inbox/m1')
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
return { wrapper, router, store }
}
const deleteButton = (wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) => wrapper.get('[data-reader-delete]')
const usedDelete = () => requests.some((request) => request.method === 'DELETE')
it('휴지통 밖 — 휴지통으로 옮기고(PATCH) 다음 메일을 연다, "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]', async () => {
const { wrapper, router, store } = await mountWith()
expect(deleteButton(wrapper).text()).toBe('삭제')
await deleteButton(wrapper).trigger('click')
await flushPromises()
expect(requests.find((request) => request.method === 'PATCH')).toMatchObject({
url: '/messages',
data: { ids: ['m1'], mailboxId: 'trash' },
})
expect(usedDelete()).toBe(false)
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 휴지통으로 옮겼습니다.')
// 되돌리기 — 원래 메일함으로 (여러 통 처리와 같은 규칙)
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
})
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
const { wrapper, router } = await mountWith({ layout: 'list' })
await deleteButton(wrapper).trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(usedDelete()).toBe(false)
})
it('휴지통인지는 목록이 아니라 그 메일의 메일함으로 정한다 — 「영구 삭제」는 확인(처음 초점 「취소」)을 거쳐 POST /message-deletions', async () => {
const { wrapper, router, store } = await mountWith({ mailboxIds: ['trash'] })
expect(deleteButton(wrapper).text()).toBe('영구 삭제')
await deleteButton(wrapper).trigger('click')
await flushPromises()
const dialog = wrapper.get('dialog')
expect(dialog.attributes('open')).toBeDefined()
expect(dialog.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
await dialog.get('[data-purge-cancel]').trigger('click')
await flushPromises()
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
await deleteButton(wrapper).trigger('click')
await flushPromises()
await wrapper.get('[data-purge-confirm]').trigger('click')
await flushPromises()
expect(requests.find((request) => request.url === '/message-deletions')).toMatchObject({
method: 'POST',
data: { ids: ['m1'] },
})
expect(usedDelete()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 영구 삭제했습니다.'])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
})
it('그사이 휴지통을 떠난 메일이면(skippedIds) 지우지 않았다고 알리고 목록을 다시 맞춘다 — 넘어가지 않는다', async () => {
const { wrapper, router } = await mountWith({
mailboxIds: ['trash'],
handler: (request) =>
request.url === '/message-deletions' ? ok({ deletedIds: [], skippedIds: ['m1'] }) : undefined,
})
await deleteButton(wrapper).trigger('click')
await flushPromises()
await wrapper.get('[data-purge-confirm]').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
])
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages')).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(usedDelete()).toBe(false)
})
it('휴지통 목록 밖에서 휴지통과 다른 메일함에 함께 든 메일은 「삭제」 — 휴지통으로 옮긴다(영구 삭제를 묻지 않는다)', async () => {
const { wrapper, store } = await mountWith({
mailboxIds: ['inbox', 'trash'],
source: { kind: 'search', q: '회의', scope: 'all' },
})
expect(deleteButton(wrapper).text()).toBe('삭제')
await deleteButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'trash' })
// 전체 메일함 검색에는 휴지통이 빠진다 — 결과에서 빠지고 알림은 "옮겼습니다"
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
expect(usedDelete()).toBe(false)
})
it('휴지통 목록 밖에서 휴지통에만 든 메일은 「영구 삭제」, 휴지통 목록에서는 다른 메일함에 함께 들어도 「영구 삭제」 (지금처럼)', async () => {
const onlyTrash = await mountWith({ mailboxIds: ['trash'], source: { kind: 'search', q: '회의', scope: 'all' } })
expect(deleteButton(onlyTrash.wrapper).text()).toBe('영구 삭제')
onlyTrash.wrapper.unmount()
const inTrashList = await mountWith({ mailboxIds: ['trash', 'inbox'], source: { kind: 'mailbox', mailboxId: 'trash' } })
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
})
})
+39 -39
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead · Search 오른쪽)
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
@@ -12,11 +14,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 MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { 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'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -27,18 +32,19 @@ interface Props {
layout?: 'split' | 'list'
}
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
layout: 'split',
})
const router = useRouter()
const mailStore = useMailStore()
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
const deleting = ref<boolean>(false)
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
const message = computed(() => mailStore.selected)
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
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 fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
@@ -49,35 +55,14 @@ const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.valu
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
async function onDelete() {
const current = message.value
const mailboxId = mailStore.currentMailboxId
if (!current || !mailboxId || deleting.value) return
deleting.value = true
try {
const nextId = await mailStore.removeMessage(current.id)
if (props.layout === 'list') {
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToList(mailboxId)
return
}
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
} catch {
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
} finally {
deleting.value = false
}
}
function retry() {
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
}
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
function leaveToList() {
const mailboxId = mailStore.currentMailboxId
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
const source = mailStore.listSource
void (source ? backToSourceList(source) : replaceWithMailbox(null))
}
</script>
@@ -114,12 +99,9 @@ function leaveToList() {
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MailToolButton
icon="trash"
:label="isTrash ? '영구 삭제' : '삭제'"
tone="danger"
:disabled="deleting"
@click="onDelete"
<MessageDeleteButton
:message="message"
:layout="layout"
/>
</template>
</div>
@@ -206,6 +188,13 @@ function leaveToList() {
<span class="reader__recipients-label">참조</span>
{{ formatAddressList(message.cc) }}
</p>
<p
v-if="searchMailboxName"
class="reader__mailbox"
>
<span class="reader__recipients-label">{{ MAIL_SEARCH_TEXT.mailboxLine }}</span>
{{ searchMailboxName }}
</p>
</div>
</div>
@@ -255,7 +244,11 @@ function leaveToList() {
v-else
icon="mail"
title="메일을 선택하세요"
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
:hint="
mailStore.search
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
"
fill
/>
</div>
@@ -400,6 +393,13 @@ function leaveToList() {
margin-right: var(--space-6);
}
/* 검색 결과에서 연 메일 — "메일함 받은편지함" (12.5px #667085, 디자인 원본 Search) */
.reader__mailbox {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.reader__notice {
display: flex;
align-items: flex-start;
+115 -13
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -20,6 +21,8 @@ const INBOX: Mailbox = {
totalCount: 15,
unreadCount: 2,
}
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
@@ -55,37 +58,38 @@ describe('MessageTable (목록 보기)', () => {
})
async function mountTable(
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean; path?: string } = {},
) {
const store = useMailStore()
store.mailboxes = [options.mailbox ?? INBOX]
store.mailboxes = [INBOX, SENT, DRAFTS]
store.currentMailboxId = (options.mailbox ?? INBOX).id
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${store.currentMailboxId}`)
await router.push(options.path ?? `/mail/${store.currentMailboxId}`)
await router.isReady()
const wrapper = mount(MessageTable, {
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
return { wrapper, router, store }
}
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
it('진짜 <table> 이다 — 캡션 · 여섯 칸 colgroup(고르기 칸 포함) · 머리 칸', async () => {
const { wrapper } = await mountTable()
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
expect(wrapper.findAll('colgroup col')).toHaveLength(6)
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
'',
'중요 표시',
'보낸 사람',
'제목',
'날짜',
'크기',
])
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 메일 모두 고르기')
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
})
@@ -144,26 +148,25 @@ describe('MessageTable (목록 보기)', () => {
})
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
const { wrapper } = await mountTable({ mailbox: SENT, messages: [summary('s1', { mailboxIds: ['sent'] })] })
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
expect(wrapper.findAll('thead th')[2]?.text()).toBe('받는 사람')
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
})
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const { wrapper, router } = await mountTable({
mailbox: drafts,
mailbox: DRAFTS,
messages: [
summary('d1', {
mailboxIds: ['drafts'],
isRead: false,
to: [
{ name: '박준호', email: 'junho.park@arasystem.test' },
{ name: null, email: 'infra@arasystem.test' },
],
}),
summary('d2', { to: [], subject: '' }),
summary('d2', { mailboxIds: ['drafts'], to: [], subject: '' }),
],
})
const rows = wrapper.findAll('tbody tr')
@@ -188,7 +191,7 @@ describe('MessageTable (목록 보기)', () => {
)
const { wrapper } = await mountTable({ hasNext: true })
await wrapper.get('.message-table__more-button').trigger('click')
await wrapper.get('.mail-list-frame__more-button').trigger('click')
await flushPromises()
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
@@ -204,4 +207,103 @@ describe('MessageTable (목록 보기)', () => {
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
scrollTo.mockRestore()
})
describe('여러 통 고르기 (디자인 원본 BulkSelect)', () => {
const three = () => [summary('m1'), summary('m2'), summary('m3'), summary('m4')]
it('행의 고르기 상자 — 이름 "고르기: 제목", 고르면 행이 고른 바탕이 되고 머리가 도구 줄 "N통 선택됨"으로 바뀐다', async () => {
const { wrapper, store } = await mountTable({ messages: three() })
const box = wrapper.findAll('tbody [role="checkbox"]')[1]
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
expect(box?.attributes('aria-checked')).toBe('false')
await box?.trigger('click')
expect(store.checkedIds.has('m2')).toBe(true)
expect(wrapper.findAll('tbody tr')[1]?.classes()).toContain('is-checked')
expect(wrapper.findAll('tbody [role="checkbox"]')[1]?.attributes('aria-checked')).toBe('true')
expect(wrapper.find('.list-header').exists()).toBe(false)
expect(wrapper.get('#message-table-title').text()).toBe('1통 선택됨')
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('고른 메일')
})
it('머리의 상자 — 하나도 · 일부만(mixed) · 모두, 누르면 불러온 메일을 모두 고르고 모두 골랐으면 푼다', async () => {
const { wrapper, store } = await mountTable({ messages: three() })
const head = () => wrapper.get('thead [role="checkbox"]')
expect(head().attributes('aria-checked')).toBe('false')
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
expect(head().attributes('aria-checked')).toBe('mixed')
await head().trigger('click')
expect([...store.checkedIds]).toEqual(['m1', 'm2', 'm3', 'm4'])
expect(head().attributes('aria-checked')).toBe('true')
await head().trigger('click')
expect(store.checkedCount).toBe(0)
})
it('Ctrl(⌘)+클릭은 메일을 열지 않고 하나씩, Shift+클릭은 범위로 고른다', async () => {
const { wrapper, router, store } = await mountTable({ messages: three() })
const rows = () => wrapper.findAll('tbody tr')
await rows()[0]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
await rows()[2]?.get('.mail-table__link').trigger('click', { shiftKey: true })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
await rows()[1]?.get('.mail-table__date').trigger('click', { metaKey: true })
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
})
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
const { wrapper, store } = await mountTable({ messages: three() })
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
await wrapper.get('section.message-table').trigger('keydown', { key: 'Escape' })
expect(store.checkedCount).toBe(0)
expect(wrapper.find('.list-header').exists()).toBe(true)
})
})
it('전체 메일함 검색 결과 — 보낸 사람 뒤에 메일함 칸(96), 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름"', async () => {
const store = useMailStore()
const { wrapper } = await mountTable({
path: `/mail/search?q=${encodeURIComponent('회의')}&in=all`,
messages: [
summary('r1', { mailboxIds: ['inbox'] }),
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
summary('r3', { mailboxIds: ['drafts'], to: [{ name: '운영팀', email: 'ops@arasystem.test' }] }),
],
})
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
await flushPromises()
expect(wrapper.findAll('colgroup col')).toHaveLength(7)
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
'',
'중요 표시',
'보낸 사람',
'메일함',
'제목',
'날짜',
'크기',
])
const rows = wrapper.findAll('tbody tr')
expect(rows.map((row) => row.get('.mail-table__who').text())).toEqual([
'이도윤',
'받는 사람: 정하늘',
'받는 사람: 운영팀',
])
expect(rows.map((row) => row.get('.mail-table__mailbox').text())).toEqual(['받은편지함', '보낸편지함', '임시보관함'])
// 검색 결과에서 연 메일의 주소 · 임시 저장본은 지금처럼 쓰기 화면
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe(
`/mail/search/r1?q=${encodeURIComponent('회의')}&in=all`,
)
expect(rows[2]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=r3')
expect(wrapper.get('table caption').text()).toBe('검색 결과')
})
})
+94 -407
View File
@@ -1,279 +1,88 @@
<script setup lang="ts">
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
// 목록 보기 — 게시판형 표 (디자인 원본 MainList · BulkSelect · ListStates). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
import { useMailListRows } from '@/composables/useMailListRows'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
interface Props {
mailboxId: string
}
const props = defineProps<Props>()
const router = useRouter()
const mailStore = useMailStore()
const viewStore = useMailViewStore()
const { messageLocation, replaceWithMailbox } = useMailNavigation()
const rows = useMailListRows()
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
const sectionRef = ref<HTMLElement | null>(null)
const mailbox = computed(() => mailStore.currentMailbox)
const isOutgoing = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
const caption = computed(() =>
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
)
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
function whoOf(message: MessageSummary): string {
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
return displayName(message.from) || '(보낸 사람 없음)'
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
if (range) mailStore.checkRangeTo(id)
else mailStore.toggleCheck(id)
}
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
function hasNoDraftRecipients(message: MessageSummary): boolean {
return isDrafts.value && draftRecipientNames(message.to) === ''
function open(location: RouteLocationRaw) {
void router.push(location)
}
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
function isUnread(message: MessageSummary): boolean {
return !isDrafts.value && !message.isRead
}
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
function rowLocation(message: MessageSummary): RouteLocationRaw {
return isDrafts.value
? { name: 'mail-compose', query: { draft: message.id } }
: messageLocation(props.mailboxId, message.id)
}
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent, message: MessageSummary) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('a, button')) return
if (window.getSelection()?.toString()) return
void router.push(rowLocation(message))
}
function retry() {
void mailStore.openMailbox(props.mailboxId)
}
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
function goToInbox() {
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
}
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
onBeforeUnmount(() => {
viewStore.rememberListPosition({
mailboxId: props.mailboxId,
scrollTop: sectionRef.value?.scrollTop ?? 0,
windowY: window.scrollY,
})
})
onMounted(async () => {
const saved = viewStore.takeListPosition(props.mailboxId)
const focusId = viewStore.takeReturnFocus()
if (!saved && !focusId) return
await nextTick()
if (saved) {
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
}
if (focusId) {
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
link?.focus({ preventScroll: Boolean(saved) })
}
})
</script>
<template>
<section
ref="sectionRef"
<MailListFrame
class="message-table"
aria-labelledby="message-table-title"
:aria-busy="busy"
variant="list"
title-id="message-table-title"
>
<MailListHeader
title-id="message-table-title"
variant="list"
/>
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
<MailLoadError
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
:error="mailStore.listError"
leave-label="받은편지함으로"
@retry="retry"
@leave="goToInbox"
/>
<template v-else-if="mailStore.messages.length > 0">
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
<div class="message-table__scroll">
<table class="mail-table">
<caption class="visually-hidden">
{{ caption }}
</caption>
<colgroup>
<col class="mail-table__col-star">
<col class="mail-table__col-who">
<col class="mail-table__col-subject">
<col class="mail-table__col-date">
<col class="mail-table__col-size">
</colgroup>
<thead>
<tr>
<th
scope="col"
class="mail-table__star"
>
<AppIcon
name="star"
:size="14"
/>
<span class="visually-hidden">중요 표시</span>
</th>
<th scope="col">
{{ whoHeader }}
</th>
<th scope="col">
제목
</th>
<th
scope="col"
class="mail-table__date"
>
날짜
</th>
<th
scope="col"
class="mail-table__size"
>
크기
</th>
</tr>
</thead>
<tbody>
<tr
v-for="message in mailStore.messages"
:key="message.id"
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
@click="onRowClick($event, message)"
>
<td class="mail-table__star">
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
<button
type="button"
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
aria-label="중요 표시"
:aria-pressed="message.isFlagged"
@click="mailStore.toggleFlag(message.id)"
>
<AppIcon
name="star"
:size="14"
:filled="message.isFlagged"
/>
</button>
</td>
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
<span
v-if="isUnread(message)"
class="visually-hidden"
>안 읽음, </span>{{ whoOf(message) }}
</td>
<td class="mail-table__subject">
<div class="mail-table__subject-line">
<RouterLink
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
:to="rowLocation(message)"
:data-message-id="message.id"
>
{{ message.subject || MAIL_NO_SUBJECT }}
</RouterLink>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
label="첨부 있음"
class="mail-table__clip"
/>
</div>
</td>
<td class="mail-table__date">
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
</td>
<td class="mail-table__size">
{{ formatFileSize(message.size) }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="mailStore.hasMore"
class="message-table__more"
>
<button
type="button"
class="message-table__text-button message-table__more-button"
:disabled="mailStore.isLoadingMore"
@click="mailStore.loadMore()"
>
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
</button>
</div>
</template>
<StateMessage
v-else-if="mailStore.listStatus === 'ready'"
class="message-table__empty"
:icon="emptyIcon"
:title="empty.title"
:hint="empty.hint"
/>
</section>
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
<div class="message-table__scroll">
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
<caption class="visually-hidden">
{{ caption }}
</caption>
<colgroup>
<col class="mail-table__col-check">
<col class="mail-table__col-star">
<col class="mail-table__col-who">
<col
v-if="rows.mixed.value"
class="mail-table__col-mailbox"
>
<col class="mail-table__col-subject">
<col class="mail-table__col-date">
<col class="mail-table__col-size">
</colgroup>
<MessageTableHead
:who-header="rows.whoHeader.value"
:mixed="rows.mixed.value"
/>
<tbody>
<MessageTableRow
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:checked="mailStore.checkedIds.has(message.id)"
@open="open"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</tbody>
</table>
</div>
</MailListFrame>
</template>
<style scoped>
.message-table {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
/*
@@ -293,18 +102,31 @@ onMounted(async () => {
border-collapse: collapse;
}
/* 전체 메일함 검색 결과 — 메일함 칸만큼 더 넓어야 칸이 찌그러지지 않는다 */
.mail-table--mixed {
min-width: calc(var(--table-min-width) + var(--table-col-mailbox) + var(--table-gap));
}
/*
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
*/
.mail-table__col-check {
width: calc(var(--space-20) + var(--table-col-check) + var(--table-gap));
}
.mail-table__col-star {
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
width: calc(var(--table-col-star) + var(--table-gap));
}
.mail-table__col-who {
width: calc(var(--table-col-who) + var(--table-gap));
}
.mail-table__col-mailbox {
width: calc(var(--table-col-mailbox) + var(--table-gap));
}
.mail-table__col-date {
width: calc(var(--table-col-date) + var(--table-gap));
}
@@ -313,8 +135,9 @@ onMounted(async () => {
width: calc(var(--table-col-size) + var(--space-8));
}
.mail-table th,
.mail-table td {
/* 칸 공통 — 머리 줄(MessageTableHead)과 행(MessageTableRow)의 칸에 함께 쓴다 */
.mail-table :deep(th),
.mail-table :deep(td) {
padding: 0 var(--table-gap) 0 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -323,164 +146,35 @@ onMounted(async () => {
vertical-align: middle;
}
.mail-table th:first-child,
.mail-table td:first-child {
.mail-table :deep(th:first-child),
.mail-table :deep(td:first-child) {
padding-left: var(--space-20);
}
.mail-table th:last-child,
.mail-table td:last-child {
.mail-table :deep(th:last-child),
.mail-table :deep(td:last-child) {
padding-right: var(--space-8);
}
.mail-table thead th {
height: var(--size-table-head);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface-sunken);
color: var(--color-text-muted);
font-size: var(--font-size-small);
font-weight: 600;
}
.mail-table thead .mail-table__star {
color: var(--color-text-muted);
}
.mail-table__row {
height: var(--size-table-row);
cursor: pointer;
}
.mail-table__row td {
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.mail-table__row:hover {
background: var(--color-row-hover);
}
.mail-table__row:focus-within {
background: var(--color-brand-subtle);
}
.mail-table__star-button {
width: var(--size-compact-control);
height: calc(var(--size-table-row) - var(--border-width));
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--color-icon-muted);
}
.mail-table__star-button.is-flagged {
color: var(--color-star);
}
.mail-table__who {
font-weight: 450;
color: var(--color-text-strong);
}
.mail-table__subject-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.mail-table__link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.mail-table__link:hover {
color: var(--color-text);
text-decoration: underline;
}
.mail-table__clip {
color: var(--color-text-muted);
}
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
.mail-table th.mail-table__date,
.mail-table th.mail-table__size,
.mail-table td.mail-table__date,
.mail-table td.mail-table__size {
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 칸 공통 규칙보다 우선하도록 th·td 를 함께 적는다 */
.mail-table :deep(th.mail-table__date),
.mail-table :deep(th.mail-table__size),
.mail-table :deep(td.mail-table__date),
.mail-table :deep(td.mail-table__size) {
text-align: right;
}
.mail-table__date,
.mail-table__size {
.mail-table :deep(.mail-table__date),
.mail-table :deep(.mail-table__size) {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.mail-table__who.is-placeholder,
.mail-table__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.mail-table__row.is-unread .mail-table__who,
.mail-table__row.is-unread .mail-table__link {
font-weight: 650;
color: var(--color-text);
}
.mail-table__row.is-unread .mail-table__date {
font-weight: 600;
color: var(--color-text);
}
.message-table__more {
display: flex;
justify-content: center;
padding: var(--space-10) var(--space-20) var(--space-20);
}
.message-table__text-button {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.message-table__text-button:hover:not(:disabled) {
background: var(--color-brand-subtle);
}
.message-table__more-button {
min-height: var(--size-row-min);
padding: 0 var(--space-24);
}
.message-table__more-button:disabled {
color: var(--color-text-muted);
}
.message-table__empty {
padding-block: calc(var(--space-80) + var(--space-16));
}
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
@media (max-width: 68.6875rem) {
.mail-table__col-size,
.mail-table__size {
.mail-table :deep(.mail-table__size) {
display: none;
}
@@ -488,13 +182,13 @@ onMounted(async () => {
width: calc(var(--table-col-date) + var(--space-8));
}
.mail-table th.mail-table__date,
.mail-table td.mail-table__date {
.mail-table :deep(th.mail-table__date),
.mail-table :deep(td.mail-table__date) {
padding-right: var(--space-8);
}
}
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭보다 좁으면 가로 스크롤 */
@media (max-width: 37.4375rem) {
.mail-table__col-who {
width: calc(var(--table-col-who-narrow) + var(--table-gap));
@@ -504,11 +198,4 @@ onMounted(async () => {
width: calc(var(--table-col-date-narrow) + var(--space-8));
}
}
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.message-table {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,93 @@
<script setup lang="ts">
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함은 "받는 사람" */
whoHeader: string
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
mixed?: boolean
}
withDefaults(defineProps<Props>(), {
mixed: false,
})
const mailStore = useMailStore()
</script>
<template>
<thead class="mail-table-head">
<tr>
<th
scope="col"
class="mail-table__check"
>
<!-- 폭 32 · 머리 줄 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
<MailCheckbox
class="mail-table-head__check"
:checked="mailStore.checkState === 'all'"
:mixed="mailStore.checkState === 'some'"
:label="MAIL_BULK_TEXT.selectAll"
@toggle="mailStore.toggleCheckAll()"
/>
</th>
<th
scope="col"
class="mail-table__star"
>
<AppIcon
name="star"
:size="14"
/>
<span class="visually-hidden">중요 표시</span>
</th>
<th scope="col">
{{ whoHeader }}
</th>
<th
v-if="mixed"
scope="col"
>
{{ MAIL_SEARCH_TEXT.mailboxColumn }}
</th>
<th scope="col">
제목
</th>
<th
scope="col"
class="mail-table__date"
>
날짜
</th>
<th
scope="col"
class="mail-table__size"
>
크기
</th>
</tr>
</thead>
</template>
<style scoped>
.mail-table-head th {
height: var(--size-table-head);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface-sunken);
color: var(--color-text-muted);
font-size: var(--font-size-small);
font-weight: 600;
}
.mail-table-head__check {
width: var(--table-col-check);
height: calc(var(--size-table-head) - var(--border-width));
justify-content: flex-start;
}
</style>
@@ -0,0 +1,242 @@
<script setup lang="ts">
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
interface Props {
message: MessageSummary
to: RouteLocationRaw
role: MailboxRole | null
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
mixed?: boolean
mailboxName?: string | null
checked?: boolean
}
interface Emits {
(e: 'open', location: RouteLocationRaw): void
(e: 'toggle-flag', id: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', id: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
mixed: false,
mailboxName: null,
checked: false,
})
const emit = defineEmits<Emits>()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
</script>
<template>
<tr
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="message.id"
@click="onRowClick"
@mousedown="onMouseDown"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
<MailCheckbox
class="mail-table__check-button"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
/>
</td>
<td class="mail-table__star">
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
<button
type="button"
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
aria-label="중요 표시"
:aria-pressed="message.isFlagged"
@click="emit('toggle-flag', message.id)"
>
<AppIcon
name="star"
:size="14"
:filled="message.isFlagged"
/>
</button>
</td>
<td :class="['mail-table__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>안 읽음, </span>{{ who.text }}
</td>
<td
v-if="mixed"
class="mail-table__mailbox"
>
{{ mailboxName ?? '' }}
</td>
<td class="mail-table__subject">
<div class="mail-table__subject-line">
<RouterLink
:class="['mail-table__link', { 'is-placeholder': draft && !message.subject }]"
:to="to"
:data-message-id="message.id"
>
{{ subject }}
</RouterLink>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
label="첨부 있음"
class="mail-table__clip"
/>
</div>
</td>
<td class="mail-table__date">
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
</td>
<td class="mail-table__size">
{{ formatFileSize(message.size) }}
</td>
</tr>
</template>
<style scoped>
.mail-table__row {
height: var(--size-table-row);
cursor: pointer;
}
.mail-table__row td {
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.mail-table__row:hover {
background: var(--color-row-hover);
}
.mail-table__row:focus-within {
background: var(--color-brand-subtle);
}
/* 고른 행 (디자인 원본 BulkSelect) */
.mail-table__row.is-checked {
background: var(--color-row-checked);
}
.mail-table__row.is-checked:hover {
background: var(--color-row-checked-hover);
}
.mail-table__check-button {
width: var(--table-col-check);
height: calc(var(--size-table-row) - var(--border-width));
justify-content: flex-start;
}
.mail-table__star-button {
width: var(--size-compact-control);
height: calc(var(--size-table-row) - var(--border-width));
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--color-icon-muted);
}
.mail-table__star-button.is-flagged {
color: var(--color-star);
}
.mail-table__who {
font-weight: 450;
color: var(--color-text-strong);
}
/* 전체 메일함 검색 결과의 메일함 칸 (디자인 원본 ListStates) */
.mail-table__mailbox {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.mail-table__subject-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.mail-table__link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.mail-table__link:hover {
color: var(--color-text);
text-decoration: underline;
}
.mail-table__clip {
color: var(--color-text-muted);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.mail-table__who.is-placeholder,
.mail-table__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.mail-table__row.is-unread .mail-table__who,
.mail-table__row.is-unread .mail-table__link {
font-weight: 650;
color: var(--color-text);
}
.mail-table__row.is-unread .mail-table__date {
font-weight: 600;
color: var(--color-text);
}
</style>
+9 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
import { computed } from 'vue'
import AppRail from './AppRail.vue'
@@ -20,7 +21,14 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
<div class="app-shell">
<AppRail :active-module="moduleId" />
<div class="app-shell__main">
<AppTopBar :title="moduleLabel" />
<AppTopBar :title="moduleLabel">
<template
v-if="$slots.search"
#search
>
<slot name="search" />
</template>
</AppTopBar>
<div class="app-shell__module">
<slot />
</div>
+21 -18
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -39,23 +40,25 @@ async function onLogout() {
<header class="top-bar">
<span class="top-bar__title">{{ title }}</span>
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
<div
class="top-bar__search"
:title="MODULE_NOT_READY_LABEL"
>
<AppIcon
name="search"
class="top-bar__search-icon"
/>
<input
type="search"
class="top-bar__search-input"
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
placeholder="통합 검색"
disabled
<slot name="search">
<!-- 모듈을 가로지르는 통합 검색은 아직 없다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
<div
class="top-bar__search"
:title="MODULE_NOT_READY_LABEL"
>
</div>
<AppIcon
name="search"
class="top-bar__search-icon"
/>
<input
type="search"
class="top-bar__search-input"
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
placeholder="통합 검색"
disabled
>
</div>
</slot>
<div
v-if="authStore.user"
@@ -109,7 +112,7 @@ async function onLogout() {
display: flex;
align-items: center;
gap: var(--space-8);
height: 2.5rem;
height: var(--size-search-box);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
@@ -0,0 +1,95 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import type { Mailbox } from '@/shared/types/mail'
import { listLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
export function useBulkMailActions() {
const mailStore = useMailStore()
const router = useRouter()
const feedback = useMailMoveFeedback()
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
const busy = ref<boolean>(false)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
const allFlagged = computed<boolean>(
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
)
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
.map((node) => node.mailbox)
.filter(
(mailbox) =>
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
),
)
async function markRead(isRead: boolean): Promise<void> {
await mailStore.patchChecked({ isRead })
}
/** 중요 표시 — 모두 중요면 해제, 아니면 모두 중요로 */
async function toggleFlagged(): Promise<void> {
await mailStore.patchChecked({ isFlagged: !allFlagged.value })
}
/** 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 (기록을 쌓지 않는다) */
async function leaveRemovedMessage(result: MailBulkResult): Promise<void> {
const openId = mailStore.selectedId
const source = mailStore.listSource
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
if (mailStore.messages.some((message) => message.id === openId)) return
await router.replace(listLocationOf(source))
}
async function moveTo(mailbox: Mailbox): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id)
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox.name)
} finally {
busy.value = false
}
}
/** 휴지통 밖의 삭제 — 휴지통으로 옮긴다 (DELETE 가 아니다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않게) */
async function moveToTrash(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
const trash = await mailStore.resolveMailboxByRole('trash')
if ('error' in trash) {
notifyApiError(trash.error)
return
}
await moveTo(trash.mailbox)
}
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
async function purge(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
busy.value = true
try {
const result = await mailStore.purgeChecked()
await leaveRemovedMessage(result)
feedback.announcePurge(result)
} finally {
busy.value = false
}
}
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
}
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, nextTick, type EffectScope } from 'vue'
import { useDraftTrash } from '@/composables/useDraftTrash'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore, type ToastAction } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장") — 되돌리기는 알림을 띄운 세션에서만 움직인다
const USER = { 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: 1, unreadCount: 0 }
}
const MAILBOXES = [mailbox('drafts', 'drafts', '임시보관함'), mailbox('trash', 'trash', '휴지통')]
function summary(id: string): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['drafts'],
from: null,
to: [],
subject: '',
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 1,
}
}
describe('useDraftTrash (쓰기 화면의 휴지통)', () => {
let scope: EffectScope
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
scope = effectScope()
requests = installMockApi(({ method, url, data }) => {
if (url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
if (method === 'PATCH') return ok({ ...summary('d1'), mailboxIds: [(data as { mailboxId: string }).mailboxId] })
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
})
afterEach(() => {
scope.stop()
useToastStore().clear()
restoreApiAdapter()
})
async function discard(): Promise<ToastAction> {
useMailStore().mailboxes = MAILBOXES
const trash = scope.run(() => useDraftTrash())
if (!trash) throw new Error('scope')
await trash.discard('d1')
const action = useToastStore().toasts[0]?.action
if (!action || !('run' in action)) throw new Error('되돌리기 단추가 없다')
return action
}
const patchesTo = (mailboxId: string) =>
requests.filter(
(request) => request.method === 'PATCH' && (request.data as { mailboxId?: string }).mailboxId === mailboxId,
)
it('휴지통으로 옮기고(PATCH) [되돌리기] — 누르면 임시보관함으로 되돌린다', async () => {
const action = await discard()
expect(patchesTo('trash')).toHaveLength(1)
expect(useToastStore().toasts[0]?.message).toBe('임시 저장한 메일을 휴지통으로 옮겼습니다.')
if ('run' in action) action.run()
await flushPromises()
expect(patchesTo('drafts')).toHaveLength(1)
})
it('그사이 로그아웃했으면(다시 로그인했어도) 되돌리기는 아무것도 하지 않고, 알림도 닫힌다', async () => {
const action = await discard()
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
await nextTick()
expect(useToastStore().toasts).toEqual([])
if ('run' in action) action.run()
await flushPromises()
expect(patchesTo('drafts')).toHaveLength(0)
})
})
+7 -11
View File
@@ -1,4 +1,4 @@
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
import { notifyApiError, toApiError } from './useApi'
import { useMail } from './useMail'
import { useToast } from './useToast'
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
@@ -21,18 +21,14 @@ export function useDraftTrash() {
const toast = useToast()
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
let trash = mailStore.mailboxByRole('trash')
if (!trash) {
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
const loadError = await mailStore.loadMailboxes({ background: true })
trash = mailStore.mailboxByRole('trash')
if (!trash) {
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
return 'failed'
}
// 메일함을 아직 못 받았으면(받는 중 · 실패) 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
const trash = await mailStore.resolveMailboxByRole('trash')
if ('error' in trash) {
notifyApiError(trash.error)
return 'failed'
}
try {
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
await updateMessage(draftId, { mailboxId: trash.mailbox.id }, { silent: ['RES_001'] })
return 'moved'
} catch (error) {
// 그 밖의 실패는 인터셉터가 알렸다
@@ -0,0 +1,114 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { listKeyOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
// 새 메일 확인으로 목록을 맞출 때의 스크롤 · 알림 줄 (디자인 README "3단계 › 새 메일")
// - 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다
// - 목록을 내려 본 채면 보던 행을 같은 자리에 두고(위에 행이 생겨도 화면이 밀리지 않게), 머리 아래에 "새 메일 N통 · 맨 위로"를 띄운다
// — 누르거나 스스로 맨 위로 올라가면 사라진다. 다른 목록으로 바꾸면 사라진다
// 넓은 화면은 목록 칸이, 좁은 화면은 창 전체가 스크롤된다 (AppShell) — 둘 다 같은 규칙이다.
// 행은 data-row-id 를 단다 (분할 보기 행 · 표 행).
/** 이만큼(px)보다 덜 내려갔으면 맨 위로 본다 */
const SCROLL_TOP_TOLERANCE = 1
interface RowAnchor {
id: string
/** 화면에서의 위쪽 위치 */
top: number
}
export function useListArrivalNotice(sectionRef: Ref<HTMLElement | null>, topRef: Ref<HTMLElement | null>) {
const mailStore = useMailStore()
/** 알림 줄의 "N통" — 0 이면 줄이 없다 */
const noticeCount = ref<number>(0)
function sectionScrolled(): boolean {
return (sectionRef.value?.scrollTop ?? 0) > SCROLL_TOP_TOLERANCE
}
/** 목록을 내려 본 채인지 — 목록 칸이 스크롤되었거나(넓은 화면) 창이 목록 위쪽을 지나 스크롤되었다(좁은 화면) */
function isScrolledDown(): boolean {
const section = sectionRef.value
if (!section) return false
return sectionScrolled() || section.getBoundingClientRect().top < -SCROLL_TOP_TOLERANCE
}
function rows(): HTMLElement[] {
return Array.from(sectionRef.value?.querySelectorAll<HTMLElement>('[data-row-id]') ?? [])
}
/** 머리 묶음 바로 아래에 보이는 첫 행 — 새 행이 위에 생겨도 이 행이 같은 자리에 남게 한다 */
function firstVisibleRow(): RowAnchor | null {
const line = topRef.value?.getBoundingClientRect().bottom ?? 0
for (const row of rows()) {
const rect = row.getBoundingClientRect()
if (rect.bottom > line && row.dataset.rowId) return { id: row.dataset.rowId, top: rect.top }
}
return null
}
function keepAnchor(anchor: RowAnchor, viaSection: boolean) {
const row = rows().find((element) => element.dataset.rowId === anchor.id)
if (!row) return
const delta = row.getBoundingClientRect().top - anchor.top
if (Math.abs(delta) < SCROLL_TOP_TOLERANCE) return
const section = sectionRef.value
if (viaSection && section) section.scrollTop += delta
else window.scrollBy(0, delta)
}
/** 맨 위로 — 알림 줄을 치우고 맨 위의 메일에 초점을 준다 (키보드 사용자가 새 메일부터 보게) */
function goTop() {
const section = sectionRef.value
noticeCount.value = 0
if (!section) return
section.scrollTop = 0
if (section.getBoundingClientRect().top < 0 && typeof section.scrollIntoView === 'function') {
section.scrollIntoView({ block: 'start' })
}
section.querySelector<HTMLElement>('[data-row-id] [data-message-id]')?.focus({ preventScroll: true })
}
function onScroll() {
if (noticeCount.value > 0 && !isScrolledDown()) noticeCount.value = 0
}
// 목록을 바꾸기 직전(sync — 화면은 아직 옛 목록)에 보던 행을 잡아 두고, 바뀐 뒤 그 행을 같은 자리에 되돌린다
watch(
() => mailStore.listSync,
(sync) => {
if (!sync) return
const scrolled = isScrolledDown()
const viaSection = sectionScrolled()
const anchor = scrolled ? firstVisibleRow() : null
void nextTick(() => {
if (anchor) keepAnchor(anchor, viaSection)
else if (sectionRef.value && !scrolled) sectionRef.value.scrollTop = 0
if (scrolled && sync.addedAbove > 0) noticeCount.value += sync.addedAbove
})
},
{ flush: 'sync' },
)
// 다른 목록(메일함 · 검색)으로 바꾸면 알림 줄을 치운다
watch(
() => (mailStore.listSource ? listKeyOf(mailStore.listSource) : null),
() => {
noticeCount.value = 0
},
)
onMounted(() => {
sectionRef.value?.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
})
onBeforeUnmount(() => {
sectionRef.value?.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
})
return { noticeCount, goTop }
}
@@ -0,0 +1,43 @@
import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
import { listKeyOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
/**
* 목록 보기의 표 위치 기억 — 메일을 열면 표가 사라지므로, 사라질 때 스크롤을 기억했다가 "목록으로" 돌아오면 되살리고
* 보던 메일의 제목 링크로 초점을 돌려준다(키보드 사용자가 자리를 잃지 않게). 메일함 · 검색 결과마다 따로 기억한다.
* 불러온 페이지는 스토어에 그대로 있다. 넓은 화면은 칸(section)이, 좁은 화면은 창이 스크롤된다.
*/
export function useListPositionMemory(sectionRef: Ref<HTMLElement | null>, enabled: () => boolean) {
const mailStore = useMailStore()
const viewStore = useMailViewStore()
onBeforeUnmount(() => {
const source = mailStore.listSource
if (!enabled() || !source) return
viewStore.rememberListPosition({
listKey: listKeyOf(source),
scrollTop: sectionRef.value?.scrollTop ?? 0,
windowY: window.scrollY,
})
})
onMounted(async () => {
const source = mailStore.listSource
if (!enabled() || !source) return
const saved = viewStore.takeListPosition(listKeyOf(source))
const focusId = viewStore.takeReturnFocus()
if (!saved && !focusId) return
await nextTick()
if (saved) {
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
}
if (focusId) {
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
link?.focus({ preventScroll: Boolean(saved) })
}
})
}
+41 -6
View File
@@ -13,10 +13,15 @@ import type {
DraftSaved,
MailQuota,
MailSignature,
MailState,
Mailbox,
MessageDeletionRequest,
MessageDeletionResult,
MessageDetail,
MessageListQuery,
MessageSummary,
MessagesPatch,
MessagesPatched,
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
@@ -64,7 +69,7 @@ function startBrowserDownload(url: string): void {
anchor.remove()
}
// 메일 API Composable — docs/mail/api-contract.md 2절의 모든 엔드포인트
// 메일 API Composable — docs/mail/api-contract.md 2절의 엔드포인트 (DELETE /messages/{id} 만 웹은 쓰지 않는다 — 아래 updateMessage 뒤 설명)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
export function useMail() {
const api = useApi()
@@ -78,7 +83,18 @@ export function useMail() {
return api.get<MailQuota, MailQuota>('/mail-quota', options)
}
/** 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다 */
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
*/
function getMailState(options?: MailRequestOptions): Promise<MailState> {
return api.get<MailState, MailState>('/mail-state', options)
}
/**
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
*/
function listMessages(
query: MessageListQuery,
options?: MailRequestOptions,
@@ -102,9 +118,26 @@ export function useMail() {
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
}
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
await api.delete(`/messages/${segment(id)}`, options)
// DELETE /messages/{id} 는 부르지 않는다 — 휴지통이면 확인 없이 영구 삭제하므로, 그사이 다른 창이 휴지통으로 옮긴 메일을
// 지울 수 있다. 삭제는 휴지통으로 옮기기(updateMessages { mailboxId }), 영구 삭제는 휴지통에서 확인한 뒤 deleteMessages 다.
/**
* 여러 통의 읽음 · 중요 표시 · 이동 (PATCH /messages) — ids 는 100개까지 (부르는 쪽이 나눠 보낸다).
* 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등)
*/
function updateMessages(patch: MessagesPatch, options?: MailRequestOptions): Promise<MessagesPatched> {
return api.patch<MessagesPatched, MessagesPatched>('/messages', patch, options)
}
/**
* 휴지통에서 영구 삭제 (POST /message-deletions) — 휴지통에 있는 메일만 다룬다. ids 는 100개까지.
* 확인한 상태 그대로일 때만 지우고, 계속 어긋나면 아무것도 지우지 않고 503 MAIL_001
*/
function deleteMessages(
request: MessageDeletionRequest,
options?: MailRequestOptions,
): Promise<MessageDeletionResult> {
return api.post<MessageDeletionResult, MessageDeletionResult>('/message-deletions', request, options)
}
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
@@ -218,10 +251,12 @@ export function useMail() {
return {
listMailboxes,
getQuota,
getMailState,
listMessages,
getMessage,
updateMessage,
deleteMessage,
updateMessages,
deleteMessages,
sendMessage,
uploadAttachment,
attachmentDownloadUrl,
@@ -0,0 +1,43 @@
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
import { isDraftRole, mailboxOfMessage, whoColumnLabel } from '@/shared/utils/mail-list-row'
import { isAllMailboxSearch, messageLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
/**
* 보이는 목록의 행마다 필요한 값 — 분할 보기 행(MessageListItem)과 목록 보기 표(MessageTable)가 함께 쓴다.
* - 메일함 목록 · 한 메일함 안의 검색: 모든 행이 그 메일함의 규칙(임시보관함 · 보낸편지함)을 따른다
* - 전체 메일함 검색: 행마다 그 메일의 메일함 — 메일함 이름을 함께 보인다 (디자인 README "3단계 › 검색")
* - 누르면 갈 곳: 임시 저장본은 쓰기 화면에서 이어 쓰고, 그 밖은 지금 목록 안의 메일 주소 (검색 결과면 /mail/search/<id>?…)
*/
export function useMailListRows() {
const mailStore = useMailStore()
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 */
const mixed = computed<boolean>(() => isAllMailboxSearch(mailStore.listSource))
/** 보낸 사람 칸 머리 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
const whoHeader = computed<string>(() => whoColumnLabel(mailStore.currentMailbox?.role ?? null, mixed.value))
function mailboxOf(message: MessageSummary): Mailbox | null {
return mixed.value ? mailboxOfMessage(message, mailStore.mailboxes) : mailStore.currentMailbox
}
function roleOf(message: MessageSummary): MailboxRole | null {
return mailboxOf(message)?.role ?? null
}
/** 행에 보일 메일함 이름 — 전체 메일함 검색 결과에서만 */
function mailboxNameOf(message: MessageSummary): string | null {
return mixed.value ? (mailboxOf(message)?.name ?? null) : null
}
function locationOf(message: MessageSummary): RouteLocationRaw {
if (isDraftRole(roleOf(message))) return { name: 'mail-compose', query: { draft: message.id } }
const source = mailStore.listSource
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
}
return { mixed, whoHeader, roleOf, mailboxNameOf, locationOf }
}
@@ -0,0 +1,43 @@
import { useToast } from './useToast'
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
/**
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
* - "메일 N통을 영구 삭제했습니다."
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
*/
export function useMailMoveFeedback() {
const mailStore = useMailStore()
const toast = useToast()
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
const result = await mailStore.restoreMoved(origins)
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
}
function announceMove(result: BulkMoveResult, mailboxName: string) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done === 0 && skipped === 0) return
const origins = result.origins
toast.success(
movedMessageText(done, mailboxName, skipped),
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
)
}
function announcePurge(result: MailBulkResult) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
}
return { announceMove, announcePurge }
}
+38 -8
View File
@@ -1,34 +1,55 @@
import type { RouteLocationRaw } from 'vue-router'
import { useRouter } from 'vue-router'
import {
listLocationOf,
messageLocationOf,
type MailListSource,
} from '@/shared/utils/mail-list-source'
/**
* 메일 화면 사이 이동 규칙 — 브라우저 기록(history)을 깔끔하게 유지한다.
* - 이전·다음 메일은 기록을 바꿔 끼운다: 몇 통을 넘겨 봐도 "뒤로"는 목록으로 간다
* - "목록으로"는 바로 앞 기록이 그 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다
* - 없는 주소(404 RES_001)에서 다른 메일함으로 빠져나갈 때도 바꿔 끼운다: 뒤로 가기로 그 주소에 다시 오지 않게
* 목록은 메일함 또는 검색 결과다 (mail-list-source.ts) — 검색 결과에서 연 메일은 /mail/search/<메일 id>?q=…&in=…
*/
export function useMailNavigation() {
const router = useRouter()
function mailboxLocation(mailboxId: string): RouteLocationRaw {
return { name: 'mail-mailbox', params: { mailboxId } }
return listLocationOf({ kind: 'mailbox', mailboxId })
}
function messageLocation(mailboxId: string, messageId: string): RouteLocationRaw {
return { name: 'mail-message', params: { mailboxId, messageId } }
return messageLocationOf({ kind: 'mailbox', mailboxId }, messageId)
}
async function backToList(mailboxId: string): Promise<void> {
const listPath = router.resolve(mailboxLocation(mailboxId)).fullPath
if (router.options.history.state.back === listPath) {
/** 바로 앞 기록이 그곳이면 뒤로 가기, 아니면 바꿔 끼운다 (기록을 쌓지 않는다) */
async function backOrReplace(location: RouteLocationRaw): Promise<void> {
const path = router.resolve(location).fullPath
if (router.options.history.state.back === path) {
router.back()
return
}
await router.replace(mailboxLocation(mailboxId))
await router.replace(location)
}
/** 목록으로 — 메일함 목록 또는 검색 결과 */
async function backToSourceList(source: MailListSource): Promise<void> {
await backOrReplace(listLocationOf(source))
}
async function backToList(mailboxId: string): Promise<void> {
await backToSourceList({ kind: 'mailbox', mailboxId })
}
async function showSiblingIn(source: MailListSource, messageId: string): Promise<void> {
await router.replace(messageLocationOf(source, messageId))
}
async function showSibling(mailboxId: string, messageId: string): Promise<void> {
await router.replace(messageLocation(mailboxId, messageId))
await showSiblingIn({ kind: 'mailbox', mailboxId }, messageId)
}
/**
@@ -39,5 +60,14 @@ export function useMailNavigation() {
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
}
return { mailboxLocation, messageLocation, backToList, showSibling, replaceWithMailbox }
return {
mailboxLocation,
messageLocation,
backOrReplace,
backToList,
backToSourceList,
showSibling,
showSiblingIn,
replaceWithMailbox,
}
}
@@ -0,0 +1,110 @@
import { computed } from 'vue'
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
import { getErrorMessage, notifyApiError } from './useApi'
import { useMailNavigation } from './useMailNavigation'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
exceedsSearchWords,
isSearchRouteName,
listLocationOf,
normalizeSearchText,
scopeMailboxIdOf,
searchSourceFromQuery,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
/** 메일함 화면(목록 · 연 메일)의 라우트 */
const MAILBOX_ROUTES: readonly string[] = ['mail-mailbox', 'mail-message']
function paramOf(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
/**
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
*/
export function useMailSearchNavigation() {
const route = useRoute()
const router = useRouter()
const mailStore = useMailStore()
const { backOrReplace } = useMailNavigation()
const isSearching = computed<boolean>(() => isSearchRouteName(route.name))
/** 지금 주소의 검색 — 검색 화면이 아니거나 검색어가 없으면 null */
const routeSearch = computed<SearchListSource | null>(() =>
isSearching.value ? searchSourceFromQuery(route.query) : null,
)
/** 검색을 시작한 메일함 — 범위 줄의 "그 메일함". 모르면(주소로 바로 들어옴) 지금 범위, 그것도 없으면 기본 메일함 */
const originMailbox = computed<Mailbox | null>(() => {
const id = mailStore.searchOrigin?.mailboxId ?? scopeMailboxIdOf(routeSearch.value)
const known = id ? mailStore.mailboxes.find((mailbox) => mailbox.id === id) : undefined
return known ?? mailStore.defaultMailbox
})
/** 지금 화면을 검색을 시작한 곳으로 — 쓰기 화면처럼 메일함 화면이 아니면 마지막으로 보던 메일함 화면 */
function originOfCurrentScreen(): SearchOrigin {
if (MAILBOX_ROUTES.includes(String(route.name))) {
return { mailboxId: paramOf(route.params.mailboxId), location: route.fullPath }
}
const last = mailStore.lastBrowseLocation ? router.resolve(mailStore.lastBrowseLocation) : null
if (last && MAILBOX_ROUTES.includes(String(last.name))) {
return { mailboxId: paramOf(last.params.mailboxId), location: last.fullPath }
}
// 마지막으로 본 것이 검색 결과였다 — 그 검색을 시작한 곳
return mailStore.searchOrigin ?? { mailboxId: mailStore.defaultMailbox?.id ?? null, location: null }
}
/** 검색을 닫으면 갈 곳 */
function returnLocation(): RouteLocationRaw {
const saved = mailStore.searchOrigin?.location
if (saved) return saved
const id = originMailbox.value?.id
return id ? { name: 'mail-mailbox', params: { mailboxId: id } } : { name: 'mail' }
}
/**
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
*/
async function startSearch(text: string): Promise<void> {
const q = normalizeSearchText(text)
if (!q) return
if (exceedsSearchWords(q)) {
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
return
}
const current = routeSearch.value
if (current) {
if (current.q === q) {
await mailStore.openSearch(q, current.scope, { force: true })
return
}
await router.replace(listLocationOf({ ...current, q }))
return
}
const origin = originOfCurrentScreen()
mailStore.rememberSearchOrigin(origin)
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
}
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
async function setScope(scope: string): Promise<void> {
const current = routeSearch.value
if (!current) return
if (current.scope === scope && route.name !== 'mail-search-message') return
await router.replace(listLocationOf({ ...current, scope }))
}
async function closeSearch(): Promise<void> {
await backOrReplace(returnLocation())
}
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
}
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { useAuthStore } from '@/stores/auth.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'
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
describe('useMailStatePolling (새 메일 확인)', () => {
let scope: EffectScope
let clock: ManualClock
let visibility: DocumentVisibilityState
let states: MockResult[]
let requests: MockRequest[]
const onChange = vi.fn()
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
scope = effectScope()
clock = createManualClock()
visibility = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
onChange.mockReset()
states = []
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
})
afterEach(() => {
scope.stop()
Reflect.deleteProperty(document, 'visibilityState')
useToastStore().clear()
restoreApiAdapter()
})
function start() {
scope.run(() => useMailStatePolling({ onChange, clock }))
}
function setVisibility(next: DocumentVisibilityState) {
visibility = next
document.dispatchEvent(new Event('visibilitychange'))
}
const asked = () => requests.filter((request) => request.url === '/mail-state').length
async function tick(ms: number) {
clock.advance(ms)
await flushPromises()
}
it('바로 한 번 물어 기준을 잡고(알리지 않는다), 1분마다 다시 묻다가 값이 바뀌면 알린다', async () => {
states = [ok({ state: 's1' }), ok({ state: 's1' }), ok({ state: 's2' })]
start()
await flushPromises()
expect(asked()).toBe(1)
expect(requests[0]?.config.silent).toBe(true)
expect(onChange).not.toHaveBeenCalled()
await tick(MAIL_STATE_POLL_INTERVAL_MS - 1)
expect(asked()).toBe(1)
await tick(1)
expect(asked()).toBe(2)
expect(onChange).not.toHaveBeenCalled()
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(asked()).toBe(3)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('화면이 안 보이는 동안은 묻지 않는다 — 돌아왔을 때 마지막으로 물은 지 10초가 지났으면 바로 한 번', async () => {
start()
await flushPromises()
setVisibility('hidden')
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
expect(asked()).toBe(1)
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(2)
})
it('돌아왔을 때 마지막으로 물은 지 10초가 안 됐으면 원래 차례(1분)에 묻는다', async () => {
start()
await flushPromises()
await tick(MAIL_STATE_RECHECK_GAP_MS - 4_000)
setVisibility('hidden')
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(1)
await tick(MAIL_STATE_POLL_INTERVAL_MS - (MAIL_STATE_RECHECK_GAP_MS - 4_000) - 1)
expect(asked()).toBe(1)
await tick(1)
expect(asked()).toBe(2)
})
it('한 번에 하나씩 — 묻는 중에 탭으로 돌아와도 또 묻지 않는다', async () => {
let release: (result: MockResult) => void = () => undefined
requests = installMockApi(
() =>
new Promise<MockResult>((resolve) => {
release = resolve
}),
)
start()
await flushPromises()
await tick(MAIL_STATE_RECHECK_GAP_MS * 2)
setVisibility('hidden')
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(1)
release(ok({ state: 's1' }))
await flushPromises()
expect(asked()).toBe(1)
})
it('실패(MAIL_001 · 네트워크)는 알리지 않고 다음 차례에 다시 묻는다 — 지난 값은 그대로 기준이다', async () => {
states = [ok({ state: 's1' }), fail(503, 'MAIL_001'), 'network', ok({ state: 's1' }), ok({ state: 's2' })]
start()
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS)
await tick(MAIL_STATE_POLL_INTERVAL_MS)
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(asked()).toBe(4)
expect(onChange).not.toHaveBeenCalled()
expect(useToastStore().toasts).toEqual([])
await tick(MAIL_STATE_POLL_INTERVAL_MS)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('로그인하지 않았으면 묻지 않는다 — 세션이 끝나면 그만 묻는다', async () => {
start()
await flushPromises()
useAuthStore().clearAuth()
await flushPromises()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
expect(asked()).toBe(1)
})
it('화면이 사라지면(scope 종료) 그만 묻는다', async () => {
start()
await flushPromises()
scope.stop()
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
setVisibility('hidden')
setVisibility('visible')
await flushPromises()
expect(asked()).toBe(1)
expect(clock.pendingCount()).toBe(0)
})
})
@@ -0,0 +1,115 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail } from './useMail'
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
import { useAuthStore } from '@/stores/auth.store'
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
export interface MailStatePollingOptions {
/** 지난 값과 달라졌을 때 */
onChange: () => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
}
export interface MailStatePollingHandle {
/** 그만 묻는다 — 화면이 사라지면 저절로 부른다 */
stop: () => void
}
function isPageVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState === 'visible'
}
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
const { getMailState } = useMail()
const authStore = useAuthStore()
const clock = options.clock ?? systemClock
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
let baseline: string | null = null
let lastAskedAt: number | null = null
let timer: ClockTimer | null = null
let inFlight = false
let stopped = false
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
function schedule(delay: number) {
cancelTimer()
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
timer = clock.setTimeout(
() => {
timer = null
void ask()
},
Math.max(0, delay),
)
}
async function ask(): Promise<void> {
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
inFlight = true
lastAskedAt = clock.now()
const session = authStore.generation
try {
const { state } = await getMailState({ silent: true })
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
if (stopped || authStore.generation !== session) return
const changed = baseline !== null && state !== baseline
baseline = state
if (changed) options.onChange()
} catch {
// 알리지 않는다 — 다음 차례에 다시 묻는다
} finally {
inFlight = false
schedule(MAIL_STATE_POLL_INTERVAL_MS)
}
}
function onVisibilityChange() {
if (stopped) return
if (!isPageVisible()) {
cancelTimer()
return
}
if (inFlight) return
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
}
function stop() {
stopped = true
cancelTimer()
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
}
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
watch(
() => authStore.generation,
() => {
baseline = null
if (authStore.isLoggedIn) void ask()
else cancelTimer()
},
)
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
void ask()
onScopeDispose(stop)
return { stop }
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import type { Mailbox } from '@/shared/types/mail'
import { documentTitle } from '@/shared/utils/document-title'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { flushPromises } from '@/test-utils/mock-api'
// 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 "(N) " (디자인 README "3단계 › 새 메일" · 디자인 원본 ListStates)
const Empty: FunctionalComponent = () => h('div')
/** 메일 모듈 레이아웃(MailLayout) 대신 — 이 composable 만 부른다 */
const TitleHost = defineComponent({
setup() {
useMailTabTitle()
return () => h('div')
},
})
function inbox(unreadCount: number): Mailbox {
return { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 9, unreadCount }
}
describe('useMailTabTitle (탭 제목)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
document.title = ''
})
async function mountHost() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { title: '메일 쓰기' } },
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { title: '메일' } },
{ path: '/settings', name: 'settings', component: Empty, meta: { title: '설정' } },
],
})
// 앱 라우터와 같다 — 이동할 때마다 기본 제목을 쓴다
router.afterEach((to) => {
document.title = documentTitle(to.meta.title)
})
await router.push('/mail/inbox')
await router.isReady()
mount(TitleHost, { global: { plugins: [pinia, router] } })
await flushPromises()
return router
}
it('"(3) 메일 · ARA SYSTEM" — 모두 읽으면 "메일 · ARA SYSTEM"', async () => {
useMailStore().mailboxes = [inbox(3)]
await mountHost()
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
useMailStore().mailboxes = [inbox(0)]
await flushPromises()
expect(document.title).toBe('메일 · ARA SYSTEM')
})
it('쓰기 화면에서도 — "(3) 메일 쓰기 · ARA SYSTEM", 이동이 기본 제목을 써도 다시 붙인다', async () => {
useMailStore().mailboxes = [inbox(3)]
const router = await mountHost()
await router.push('/mail/compose')
await flushPromises()
expect(document.title).toBe('(3) 메일 쓰기 · ARA SYSTEM')
await router.push('/mail/inbox')
await flushPromises()
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
})
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
useMailStore().mailboxes = [inbox(3)]
const router = await mountHost()
await router.push('/settings')
await flushPromises()
expect(document.title).toBe('설정 · ARA SYSTEM')
})
})
@@ -0,0 +1,29 @@
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { documentTitle } from '@/shared/utils/document-title'
import { useMailStore } from '@/stores/mail.store'
/** 메일 모듈의 라우트 — 이름이 모두 mail 로 시작한다 (router/index.ts) */
function isMailRouteName(name: unknown): boolean {
return typeof name === 'string' && (name === 'mail' || name.startsWith('mail-'))
}
/**
* 메일 화면의 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " ("(3) 메일 · ARA SYSTEM" · "(3) 메일 쓰기 · ARA SYSTEM").
* 디자인 README "3단계 › 새 메일". 이동할 때마다 router.afterEach 가 기본 제목을 쓰므로, 이동이 끝난 뒤(post)에 다시 맞춘다.
* 메일 모듈 레이아웃(MailLayout)에서 부른다 — 다른 모듈로 가면 그 화면의 제목이 그대로 남는다
*/
export function useMailTabTitle() {
const route = useRoute()
const mailStore = useMailStore()
watch(
[() => route.fullPath, () => route.meta.title, () => mailStore.inboxUnreadCount],
() => {
if (!isMailRouteName(route.name)) return
document.title = documentTitle(route.meta.title, mailStore.inboxUnreadCount)
},
{ immediate: true, flush: 'post' },
)
}
@@ -0,0 +1,67 @@
import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import type { MessageSummary } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
export interface MessageRemovalOutcome {
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
removed: boolean
/** 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이었으면 null) */
nextId: string | null
}
const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
export function useMessageRemoval() {
const mailStore = useMailStore()
const feedback = useMailMoveFeedback()
/**
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
*/
function deletesPermanently(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
const trashId = mailStore.mailboxByRole('trash')?.id
if (!trashId || !message.mailboxIds.includes(trashId)) return false
if (mailStore.currentMailbox?.role === 'trash') return true
return message.mailboxIds.every((id) => id === trashId)
}
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
return { removed, nextId: removed ? nextId : null }
}
/** 휴지통으로 옮긴다 */
async function moveToTrash(message: MessageSummary): Promise<MessageRemovalOutcome> {
const trash = await mailStore.resolveMailboxByRole('trash')
if ('error' in trash) {
notifyApiError(trash.error)
return NOTHING
}
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.moveMessages([message], trash.mailbox.id)
feedback.announceMove(result, trash.mailbox.name)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
async function purge(message: MessageSummary): Promise<MessageRemovalOutcome> {
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.purgeMessages([message.id])
feedback.announcePurge(result)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
return { deletesPermanently, moveToTrash, purge }
}
+17 -1
View File
@@ -1,14 +1,30 @@
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore, type ToastAction } from '@/stores/toast.store'
/**
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
*/
export function useToast() {
const store = useToastStore()
const authStore = useAuthStore()
/** 띄운 세션에서만 움직이는 단추 */
function sessionBound(action: ToastAction): ToastAction {
if (!('run' in action)) return action
const session = authStore.generation
return {
label: action.label,
run: () => {
if (authStore.generation === session) action.run()
},
}
}
function success(message: string, action?: ToastAction): number {
return store.show({ kind: 'success', message, action })
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
}
return { success, dismiss: store.dismiss }
+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>
+13 -2
View File
@@ -1,7 +1,8 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { APP_NAME } from '@/shared/constants/brand'
import { documentTitle } from '@/shared/utils/document-title'
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
import { toSafeRedirect } from '@/shared/utils/redirect'
import { useAuthStore } from '@/stores/auth.store'
@@ -44,6 +45,15 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/mail/ComposePage.vue'),
meta: { title: '메일 쓰기' },
},
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
{
path: 'search/:messageId',
name: MAIL_SEARCH_MESSAGE_ROUTE,
component: MailboxPage,
meta: { title: '메일 검색' },
},
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
@@ -92,8 +102,9 @@ router.beforeEach(async (to) => {
return true
})
// 메일 화면에서는 MailLayout(useMailTabTitle)이 받은편지함의 안 읽은 수 "(N) " 를 앞에 다시 붙인다 — 이동이 끝난 뒤에 돈다
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} · ${APP_NAME}` : APP_NAME
document.title = documentTitle(to.meta.title)
})
export default router
+13
View File
@@ -143,6 +143,19 @@ export const ICONS = {
line(4, 18, 4.01, 18),
],
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
/** 새 메일 알림 줄 "맨 위로" (디자인 원본 ListStates) */
'arrow-up': [line(12, 19, 12, 5), polyline('5 12 12 5 19 12')],
/** 여러 통 처리 — 읽음 표시 (열린 봉투, 디자인 원본 BulkSelect) */
'mail-open': [
path('M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z'),
path('m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10'),
],
/** 여러 통 처리 — 이동 (디자인 원본 BulkSelect) */
'folder-move': [
path('M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5'),
path('M2 13h10'),
path('m9 16 3-3-3-3'),
],
'chevron-left': [polyline('15 18 9 12 15 6')],
'chevron-right': [polyline('9 18 15 12 9 6')],
'chevron-down': [polyline('6 9 12 15 18 9')],
+79
View File
@@ -190,6 +190,85 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 */
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
/** 탭이 다시 보일 때 마지막으로 물은 지 이만큼 지났으면 바로 한 번 묻는다 */
export const MAIL_STATE_RECHECK_GAP_MS = 10_000
/** 바뀌었을 때 보이는 목록을 다시 받는 최대 개수 — 지금까지 불러온 만큼, 그보다 많이 불러왔으면 이만큼만 맞추고 그 뒤는 둔다 */
export const MAIL_LIST_SYNC_MAX = 100
/** 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래의 알림 줄 */
export const NEW_MAIL_NOTICE_TEXT = (count: string) => `새 메일 ${count}통 · 맨 위로`
// ── 검색 (계약 2절 "검색" · 디자인 README "3단계 › 검색") ─────────────
/** 검색 범위 "전체 메일함" — 주소의 in 값 (휴지통 · 스팸메일함은 서버가 뺀다) */
export const SEARCH_SCOPE_ALL = 'all'
/** 검색어 길이 — 앞뒤 공백을 뺀 1~200자, JS 문자열 길이(UTF-16) 기준 (넘으면 400 VAL_001) */
export const MAIL_SEARCH_MAX_LENGTH = 200
/** 검색어 낱말 수 — 띄어쓰기로 나눈 조각 8개까지 (넘으면 400 VAL_001) */
export const MAIL_SEARCH_MAX_WORDS = 8
export const MAIL_SEARCH_TEXT = {
/** 메일 화면에서 상단 검색 칸의 이름 · 안내 */
box: '메일 검색',
clear: '검색어 지우기',
clearTitle: '검색어 지우기 (Esc)',
close: '검색 닫기',
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
closeTitle: (place: string) => `검색 닫기 — ${place}`,
title: '검색 결과',
quoted: (query: string) => `‘${query}’`,
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
searching: (query: string) => `‘${query}’ · 찾는 중`,
scopeLabel: '찾을 곳',
scopeAll: '전체 메일함',
scopeAllHint: '휴지통 · 스팸메일함은 빼고 찾습니다',
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
emptyHint:
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
searchAll: '전체 메일함에서 찾기',
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
loading: '찾는 중',
/** 검색 결과에서 연 메일의 읽기 화면 — "메일함 받은편지함" */
mailboxLine: '메일함',
/** 전체 메일함 결과에서 보낸편지함 · 임시보관함의 메일 — 보낸 사람 자리에 받는 사람 */
recipientPrefix: '받는 사람: ',
/** 결과 표의 메일함 칸 머리 */
mailboxColumn: '메일함',
} as const
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리") ─────────────
/** 요청 하나의 ids 한도 — 넘으면 이만큼씩 차례로 보낸다 */
export const MAIL_BULK_MAX_IDS = 100
export const MAIL_BULK_TEXT = {
toolbar: '고른 메일',
selected: (count: string) => `${count}통 선택됨`,
markRead: '읽음 표시',
markUnread: '안 읽음 표시',
flag: '중요 표시',
unflag: '중요 해제',
move: '이동',
moveMenu: '옮길 메일함',
delete: '삭제',
purge: '영구 삭제',
clear: '선택 해제',
clearTitle: '선택 해제 (Esc)',
/** 표 머리의 고르기 상자 */
selectAll: '불러온 메일 모두 고르기',
/** 행의 고르기 상자 */
selectOne: (subject: string) => `고르기: ${subject}`,
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("보낸편지함으로") */
moved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다.`,
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
purgeConfirm: '영구 삭제',
purgeCancel: '취소',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
+37 -2
View File
@@ -95,9 +95,13 @@ export interface MailQuota {
attachmentTotalMaxBytes: number
}
/** GET /messages 쿼리 */
/**
* GET /messages 쿼리 — mailboxId 와 q 중 하나는 있어야 한다 (계약 2절 "검색").
* q 만 있으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
*/
export interface MessageListQuery extends CursorPaginationQuery {
mailboxId: string
mailboxId?: string
/** 검색어 — 앞뒤 공백을 뺀 1~200자, 낱말 8개까지 */
q?: string
}
@@ -108,6 +112,37 @@ export interface UpdateMessagePayload {
mailboxId?: string
}
// ── 새 메일 확인 (계약 2절 "새 메일 확인" — 3단계) ──────────────────
/** GET /mail-state — 불투명 값. 해석하지 말고 지난 값과 같은지만 본다 */
export interface MailState {
state: string
}
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" — 3단계) ─────────────
/** PATCH /messages 본문 — ids 1~100(같은 id 를 두 번 넣지 않는다), 바꿀 것이 하나 이상 */
export interface MessagesPatch extends UpdateMessagePayload {
ids: string[]
}
/** PATCH /messages 응답 — failedIds: 없음 · 그사이 지워짐 · 엔진이 거절함 (나머지는 그대로 처리했다) */
export interface MessagesPatched {
updatedIds: string[]
failedIds: string[]
}
/** POST /message-deletions 본문 — 휴지통에서 영구 삭제, ids 1~100 */
export interface MessageDeletionRequest {
ids: string[]
}
/** POST /message-deletions 응답 — skippedIds: 휴지통에 없음 · 없음 · 엔진이 거절 (지우지 않았다) */
export interface MessageDeletionResult {
deletedIds: string[]
skippedIds: string[]
}
/** 발송 본문에 싣는 첨부 — POST /mail-attachments 응답의 id */
export interface AttachmentRef {
id: string
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { documentTitle } from './document-title'
describe('documentTitle (탭 제목)', () => {
it('받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " (디자인 원본 ListStates)', () => {
expect(documentTitle('메일', 3)).toBe('(3) 메일 · ARA SYSTEM')
expect(documentTitle('메일 쓰기', 3)).toBe('(3) 메일 쓰기 · ARA SYSTEM')
expect(documentTitle('메일', 1_204)).toBe('(1,204) 메일 · ARA SYSTEM')
})
it('모두 읽었으면 그대로 — 제목이 없으면 앱 이름만', () => {
expect(documentTitle('메일', 0)).toBe('메일 · ARA SYSTEM')
expect(documentTitle('설정')).toBe('설정 · ARA SYSTEM')
expect(documentTitle(undefined)).toBe('ARA SYSTEM')
})
})
@@ -0,0 +1,10 @@
// 브라우저 탭 제목 — "메일 · ARA SYSTEM", 받은편지함에 안 읽은 메일이 있으면 "(3) 메일 · ARA SYSTEM" (순수 함수만 둔다)
// 디자인 README "3단계 › 새 메일" — 다른 탭에서 일할 때 알아보게. 소리 · 데스크톱 알림은 하지 않는다.
import { APP_NAME } from '@/shared/constants/brand'
import { formatCount } from '@/shared/utils/format'
export function documentTitle(pageTitle: string | undefined, unreadCount = 0): string {
const base = pageTitle ? `${pageTitle} · ${APP_NAME}` : APP_NAME
return unreadCount > 0 ? `(${formatCount(unreadCount)}) ${base}` : base
}
+23 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { subjectParticle } from './korean'
import { directionParticle, subjectParticle } from './korean'
describe('subjectParticle (주격 조사 이/가)', () => {
it.each([
@@ -13,3 +13,25 @@ describe('subjectParticle (주격 조사 이/가)', () => {
expect(subjectParticle(word)).toBe(particle)
})
})
describe('directionParticle (방향 조사 으로/로)', () => {
it.each([
// 받침 있음 → 으로
['보낸편지함', '으로'],
['휴지통', '으로'],
['받은편지함', '으로'],
['보관함', '으로'],
// 받침 없음 → 로
['프로젝트', '로'],
['스팸메일함 ', '으로'],
// ㄹ 받침 → 로
['서울', '로'],
['메일', '로'],
// 한글로 끝나지 않으면 발음을 알 수 없다
['Work', '(으)로'],
['2026', '(으)로'],
['', '(으)로'],
])('%s → %s', (word, particle) => {
expect(directionParticle(word)).toBe(particle)
})
})
+26 -6
View File
@@ -4,16 +4,36 @@ const HANGUL_SYLLABLE_START = 0xac00
const HANGUL_SYLLABLE_END = 0xd7a3
/** 한글 음절 하나당 받침 경우의 수 (받침 없음 포함) */
const JONGSEONG_COUNT = 28
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
const NO_JONGSEONG = 0
const JONGSEONG_RIEUL = 8
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
function lastJongseong(word: string): number | null {
const last = Array.from(word.trim()).pop()
if (!last) return null
const code = last.charCodeAt(0)
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
}
/**
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
*/
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
const last = Array.from(word.trim()).pop()
if (!last) return '이(가)'
const code = last.charCodeAt(0)
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return '이(가)'
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT === 0 ? '가' : '이'
const jongseong = lastJongseong(word)
if (jongseong === null) return '이(가)'
return jongseong === NO_JONGSEONG ? '가' : '이'
}
/**
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
*/
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '(으)로'
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
chunk,
groupByOrigin,
movedMessageText,
purgedMessageText,
restoredMessageText,
sendInChunks,
} from './mail-bulk'
describe('mail-bulk (여러 통 처리 보조)', () => {
it('100통씩 나눈다 — 마지막 묶음은 남은 만큼', () => {
const ids = Array.from({ length: 250 }, (_, index) => `m${index}`)
const chunks = chunk(ids, 100)
expect(chunks.map((part) => part.length)).toEqual([100, 100, 50])
expect(chunks.flat()).toEqual(ids)
expect(chunk([], 100)).toEqual([])
})
it('나눠 차례로 보낸다 — 처리한 메일 · 그대로 둔 메일을 모으고, 요청이 실패하면 그 묶음부터 보내지 않은 것으로 두고 멈춘다', async () => {
const ids = Array.from({ length: 7 }, (_, index) => `m${index}`)
const sent: string[][] = []
const result = await sendInChunks(ids, 3, async (chunkIds) => {
sent.push(chunkIds)
if (sent.length === 2) throw new Error('503')
return { done: chunkIds.slice(1), skipped: chunkIds.slice(0, 1) }
})
expect(sent).toEqual([
['m0', 'm1', 'm2'],
['m3', 'm4', 'm5'],
])
expect(result).toEqual({ doneIds: ['m1', 'm2'], skippedIds: ['m0'], unsentIds: ['m3', 'm4', 'm5', 'm6'] })
})
it('되돌리기 — 원래 메일함별로 묶는다, 원래 메일함을 모르는 메일은 뺀다', () => {
const origins = new Map([
['m1', 'inbox'],
['m2', 'project'],
['m3', 'inbox'],
])
expect(groupByOrigin(['m1', 'm2', 'm3', 'm4'], origins)).toEqual([
{ mailboxId: 'inbox', ids: ['m1', 'm3'] },
{ mailboxId: 'project', ids: ['m2'] },
])
})
it('옮긴 알림 — 메일함 이름의 받침에 맞춰 으로/로 (디자인 README "3단계 › 여러 통 처리")', () => {
expect(movedMessageText(3, '보낸편지함')).toBe('메일 3통을 보낸편지함으로 옮겼습니다.')
expect(movedMessageText(2, '휴지통')).toBe('메일 2통을 휴지통으로 옮겼습니다.')
expect(movedMessageText(1, '프로젝트')).toBe('메일 1통을 프로젝트로 옮겼습니다.')
expect(movedMessageText(1, '메일')).toBe('메일 1통을 메일로 옮겼습니다.')
})
it('일부만 처리했으면 뒤에 덧붙이고, 하나도 못 했으면 그 문장만', () => {
expect(movedMessageText(2, '보낸편지함', 1)).toBe(
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
expect(purgedMessageText(3)).toBe('메일 3통을 영구 삭제했습니다.')
expect(purgedMessageText(0, 2)).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
})
})
+90
View File
@@ -0,0 +1,90 @@
// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 · 알림 문구 (계약 2절 "여러 통 한꺼번에" — 순수 함수만 둔다)
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { directionParticle } from '@/shared/utils/korean'
/** size 개씩 나눈다 — 요청 하나의 ids 는 100개까지다 */
export function chunk<T>(items: readonly T[], size: number): T[][] {
const step = Math.max(1, Math.floor(size))
const result: T[][] = []
for (let start = 0; start < items.length; start += step) result.push(items.slice(start, start + step))
return result
}
/** 여러 통 처리의 결과 */
export interface BulkResult {
/** 처리한 메일 */
doneIds: string[]
/** 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds */
skippedIds: string[]
/** 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 */
unsentIds: string[]
}
export function emptyBulkResult(): BulkResult {
return { doneIds: [], skippedIds: [], unsentIds: [] }
}
/** 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 */
export type BulkChunkSender = (chunkIds: string[]) => Promise<{ done: readonly string[]; skipped: readonly string[] }>
/**
* ids 를 size 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다
* (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다)
*/
export async function sendInChunks(ids: readonly string[], size: number, send: BulkChunkSender): Promise<BulkResult> {
const result = emptyBulkResult()
const chunks = chunk(ids, size)
for (const [index, chunkIds] of chunks.entries()) {
try {
const { done, skipped } = await send(chunkIds)
result.doneIds.push(...done)
result.skippedIds.push(...skipped)
} catch {
result.unsentIds.push(...chunks.slice(index).flat())
break
}
}
return result
}
/** 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일 */
export interface BulkOrigin {
mailboxId: string
ids: string[]
}
/** 옮긴 메일을 원래 메일함별로 묶는다 (옮긴 순서대로). 원래 메일함을 모르는 메일은 뺀다 */
export function groupByOrigin(ids: readonly string[], originOf: ReadonlyMap<string, string>): BulkOrigin[] {
const groups = new Map<string, string[]>()
for (const id of ids) {
const origin = originOf.get(id)
if (!origin) continue
const group = groups.get(origin) ?? []
group.push(id)
groups.set(origin, group)
}
return [...groups].map(([mailboxId, groupIds]) => ({ mailboxId, ids: groupIds }))
}
/** 일부를 처리하지 못했으면 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." 를 덧붙인다 — 하나도 못 했으면 그 문장만 */
function withSkipped(text: string, done: number, skipped: number): string {
if (skipped <= 0) return text
const suffix = MAIL_BULK_TEXT.skipped(formatCount(skipped))
return done > 0 ? `${text} ${suffix}` : suffix
}
/** "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' */
export function movedMessageText(done: number, mailboxName: string, skipped = 0): string {
const place = `${mailboxName}${directionParticle(mailboxName)}`
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
}
export function purgedMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
}
export function restoredMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.restored(formatCount(done)), done, skipped)
}
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import type { MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import { mergeRefreshedList } from './mail-list-merge'
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: null,
to: [],
subject: id,
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 1,
...partial,
}
}
const ids = (list: readonly MessageSummary[]) => list.map((message) => message.id)
const range = (prefix: string, from: number, to: number) =>
Array.from({ length: to - from + 1 }, (_, index) => summary(`${prefix}${from + index}`))
const page = (hasNext: boolean, nextCursor: string | null, total: number | null): PageInfo => ({
hasNext,
nextCursor,
total,
})
describe('mergeRefreshedList (새 메일 확인으로 목록 맞추기)', () => {
it('새 메일이 위에 생기면 그 수를 센다 — 다시 받은 범위는 서버 기준(읽음 · 다른 창에서 지운 메일)', () => {
const current = [summary('a'), summary('b', { isRead: false }), summary('c'), summary('d')]
const fetched = [summary('new1'), summary('new2'), summary('a'), summary('b', { isRead: true }), summary('d')]
const result = mergeRefreshedList({
current,
currentPageInfo: page(true, 'old-cursor', 40),
fetched,
fetchedPageInfo: page(true, 'new-cursor', 41),
})
expect(ids(result.messages)).toEqual(['new1', 'new2', 'a', 'b', 'd'])
expect(result.messages[3]?.isRead).toBe(true)
expect(result.addedAbove).toBe(2)
expect(result.pageInfo).toEqual(page(true, 'new-cursor', 41))
})
it('새 메일이 와서 다시 받은 범위 밖으로 밀려난 옛 메일은 그대로 두고, "더 보기"는 그 뒤부터(옛 커서) 잇는다', () => {
const current = range('m', 1, 20)
const fetched = [summary('new1'), ...range('m', 1, 19)]
const result = mergeRefreshedList({
current,
currentPageInfo: page(true, 'after-m20', 50),
fetched,
fetchedPageInfo: page(true, 'after-m19', 51),
})
expect(ids(result.messages)).toEqual(['new1', ...ids(range('m', 1, 20))])
expect(result.pageInfo).toEqual(page(true, 'after-m20', 51))
})
it('100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다', () => {
const current = range('m', 1, 140)
const fetched = [summary('new1'), ...range('m', 1, 99)]
const result = mergeRefreshedList({
current,
currentPageInfo: page(true, 'after-m140', 300),
fetched,
fetchedPageInfo: page(true, 'after-m99', 301),
})
expect(result.messages).toHaveLength(141)
expect(ids(result.messages).slice(0, 3)).toEqual(['new1', 'm1', 'm2'])
expect(ids(result.messages).slice(-1)).toEqual(['m140'])
expect(result.pageInfo.nextCursor).toBe('after-m140')
expect(result.addedAbove).toBe(1)
})
it('겹치는 메일이 없으면(새 메일이 다시 받은 범위보다 많다) 다시 받은 것만 쓴다 — 사이가 빈 목록을 만들지 않는다', () => {
const result = mergeRefreshedList({
current: range('m', 1, 20),
currentPageInfo: page(true, 'after-m20', 50),
fetched: range('new', 1, 20),
fetchedPageInfo: page(true, 'after-new20', 70),
})
expect(ids(result.messages)).toEqual(ids(range('new', 1, 20)))
expect(result.pageInfo.nextCursor).toBe('after-new20')
expect(result.addedAbove).toBe(20)
})
it('달라진 것이 없으면 그대로 — 새 메일 0', () => {
const current = range('m', 1, 3)
const result = mergeRefreshedList({
current,
currentPageInfo: page(false, null, 3),
fetched: range('m', 1, 3),
fetchedPageInfo: page(false, null, 3),
})
expect(ids(result.messages)).toEqual(['m1', 'm2', 'm3'])
expect(result.addedAbove).toBe(0)
})
})
@@ -0,0 +1,49 @@
// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 (계약 2절 "새 메일 확인" — 순수 함수만 둔다)
// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다 (새 메일 · 다른 창의 읽음 · 이동 · 삭제)
// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일
// (밀려난 메일을 버리면 보던 행이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다
import type { MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
export interface ListMergeInput {
current: readonly MessageSummary[]
currentPageInfo: PageInfo | null
fetched: readonly MessageSummary[]
fetchedPageInfo: PageInfo
}
export interface ListMergeResult {
messages: MessageSummary[]
pageInfo: PageInfo
/** 목록 맨 위에 새로 생긴 메일 수 — 전에 없던 메일이 맨 앞에 몇 통 이어지는지 ("새 메일 N통") */
addedAbove: number
}
export function mergeRefreshedList(input: ListMergeInput): ListMergeResult {
const { current, fetched } = input
const fetchedIds = new Set(fetched.map((message) => message.id))
const currentIds = new Set(current.map((message) => message.id))
// 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다
let lastOverlap = -1
current.forEach((message, index) => {
if (fetchedIds.has(message.id)) lastOverlap = index
})
const tail =
lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((message) => !fetchedIds.has(message.id))
// 남겨 둔 옛 메일이 있으면 "더 보기"는 그 뒤부터 이어야 한다 — 옛 커서(마지막으로 받은 메일 다음)를 쓴다. 전체 개수는 새 값
const pageInfo =
tail.length > 0 && input.currentPageInfo
? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
: input.fetchedPageInfo
let addedAbove = 0
for (const message of fetched) {
if (currentIds.has(message.id)) break
addedAbove += 1
}
return { messages: [...fetched, ...tail], pageInfo, addedAbove }
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { isRowUnread, mailboxOfMessage, rowWho, whoColumnLabel } from './mail-list-row'
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 },
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 0, unreadCount: 0 },
{ id: 'project', name: '프로젝트', role: null, parentId: null, sortOrder: 9, totalCount: 0, unreadCount: 0 },
]
const MESSAGE: Pick<MessageSummary, 'from' | 'to' | 'isRead' | 'mailboxIds'> = {
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [
{ name: '정하늘', email: 'haneul@arasystem.test' },
{ name: null, email: 'ops@arasystem.test' },
],
isRead: false,
mailboxIds: ['inbox'],
}
describe('mail-list-row (목록 행의 표시)', () => {
it('받은 메일 — 보낸 사람 (이름이 없으면 주소, 없으면 "(보낸 사람 없음)")', () => {
expect(rowWho(MESSAGE, { role: 'inbox', mixed: false, view: 'split' })).toEqual({ text: '이도윤', placeholder: false })
expect(rowWho({ ...MESSAGE, from: null }, { role: 'inbox', mixed: false, view: 'table' }).text).toBe(
'(보낸 사람 없음)',
)
})
it('보낸편지함 — 분할 보기는 "받는 사람: …", 표는 머리가 받는 사람이라 이름만, 전체 검색 결과의 표는 "받는 사람: …"', () => {
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람: 정하늘 외 1명')
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'table' }).text).toBe('정하늘 외 1명')
expect(rowWho(MESSAGE, { role: 'sent', mixed: true, view: 'table' }).text).toBe('받는 사람: 정하늘 외 1명')
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람 없음')
})
it('임시보관함 — 받는 사람을 쉼표로(전체 검색 결과는 "받는 사람: "), 없으면 흐린 "(받는 사람 없음)"', () => {
expect(rowWho(MESSAGE, { role: 'drafts', mixed: false, view: 'split' })).toEqual({
text: '정하늘, ops@arasystem.test',
placeholder: false,
})
expect(rowWho(MESSAGE, { role: 'drafts', mixed: true, view: 'split' }).text).toBe(
'받는 사람: 정하늘, ops@arasystem.test',
)
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'drafts', mixed: true, view: 'table' })).toEqual({
text: '(받는 사람 없음)',
placeholder: true,
})
})
it('임시 저장본은 읽음 여부를 보이지 않는다', () => {
expect(isRowUnread(MESSAGE, 'inbox')).toBe(true)
expect(isRowUnread(MESSAGE, 'drafts')).toBe(false)
expect(isRowUnread({ isRead: true }, null)).toBe(false)
})
it('표 머리의 보낸 사람 칸 — 한 메일함의 보낸편지함 · 임시보관함만 "받는 사람"', () => {
expect(whoColumnLabel('sent', false)).toBe('받는 사람')
expect(whoColumnLabel('drafts', false)).toBe('받는 사람')
expect(whoColumnLabel('sent', true)).toBe('보낸 사람')
expect(whoColumnLabel('inbox', false)).toBe('보낸 사람')
expect(whoColumnLabel(null, false)).toBe('보낸 사람')
})
it('메일의 메일함 — 목록이 가리키는 메일함에 있으면 그것, 아니면 아는 첫 메일함, 모르면 null', () => {
expect(mailboxOfMessage({ mailboxIds: ['unknown', 'project', 'inbox'] }, MAILBOXES)?.name).toBe('프로젝트')
expect(mailboxOfMessage({ mailboxIds: ['project', 'inbox'] }, MAILBOXES, 'inbox')?.name).toBe('받은편지함')
expect(mailboxOfMessage({ mailboxIds: ['project'] }, MAILBOXES, 'inbox')?.name).toBe('프로젝트')
expect(mailboxOfMessage({ mailboxIds: ['unknown'] }, MAILBOXES)).toBeNull()
})
})
@@ -0,0 +1,77 @@
// 목록 행의 표시 — 분할 보기(2줄 행)와 목록 보기(표)가 같은 규칙을 쓴다 (순수 함수만 둔다)
// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않고, 누르면 쓰기 화면에서 이어 쓴다
// - 보낸편지함: 받는 사람 — 분할 보기는 "받는 사람: …", 표는 머리가 "받는 사람"이라 이름만
// - 전체 메일함 검색 결과는 행마다 메일함이 다르다 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" (디자인 README "3단계 › 검색")
import {
DRAFT_NO_RECIPIENTS,
MAIL_SEARCH_TEXT,
OUTGOING_MAILBOX_ROLES,
} from '@/shared/constants/mail'
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
const NO_RECIPIENTS = '받는 사람 없음'
const NO_SENDER = '(보낸 사람 없음)'
export interface RowContext {
/** 행의 메일함 역할 — 메일함 목록 · 한 메일함 안의 검색은 그 메일함, 전체 메일함 검색은 그 메일의 메일함 */
role: MailboxRole | null
/** 전체 메일함 검색 결과 — 행마다 메일함이 다르다 */
mixed: boolean
view: 'split' | 'table'
}
export interface RowWho {
text: string
/** 비어 있는 값 — 흐린 글자로 */
placeholder: boolean
}
/**
* 메일이 속한 메일함 — 목록이 가리키는 메일함(preferredId)에 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함.
* 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null
*/
export function mailboxOfMessage(
message: Pick<MessageSummary, 'mailboxIds'>,
mailboxes: readonly Mailbox[],
preferredId: string | null = null,
): Mailbox | null {
const ids = preferredId && message.mailboxIds.includes(preferredId) ? [preferredId] : message.mailboxIds
for (const id of ids) {
const found = mailboxes.find((mailbox) => mailbox.id === id)
if (found) return found
}
return null
}
export function isDraftRole(role: MailboxRole | null): boolean {
return role === 'drafts'
}
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
export function isRowUnread(message: Pick<MessageSummary, 'isRead'>, role: MailboxRole | null): boolean {
return !isDraftRole(role) && !message.isRead
}
/** 보낸 사람 자리 */
export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: RowContext): RowWho {
const { role, mixed, view } = context
if (isDraftRole(role)) {
const names = draftRecipientNames(message.to)
if (!names) return { text: DRAFT_NO_RECIPIENTS, placeholder: true }
return { text: mixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${names}` : names, placeholder: false }
}
if (role && OUTGOING_MAILBOX_ROLES.includes(role)) {
const recipients = summarizeRecipients(message.to)
if (!recipients) return { text: NO_RECIPIENTS, placeholder: false }
const prefixed = view === 'split' || mixed
return { text: prefixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${recipients}` : recipients, placeholder: false }
}
return { text: displayName(message.from) || NO_SENDER, placeholder: false }
}
/** 표 머리의 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
export function whoColumnLabel(role: MailboxRole | null, mixed: boolean): string {
return !mixed && role && OUTGOING_MAILBOX_ROLES.includes(role) ? '받는 사람' : '보낸 사람'
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import {
exceedsSearchWords,
isAllMailboxSearch,
isSearchRouteName,
listKeyOf,
listLocationOf,
listQueryOf,
messageLocationOf,
normalizeSearchText,
sameListSource,
scopeMailboxIdOf,
searchSourceFromQuery,
type MailListSource,
} from './mail-list-source'
const INBOX: MailListSource = { kind: 'mailbox', mailboxId: 'inbox' }
const SEARCH_INBOX: MailListSource = { kind: 'search', q: '회의', scope: 'inbox' }
const SEARCH_ALL: MailListSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
describe('mail-list-source (보이는 목록의 출처)', () => {
it('GET /messages 쿼리 — 메일함은 mailboxId, 한 메일함 검색은 q + mailboxId, 전체 검색은 q 만', () => {
expect(listQueryOf(INBOX)).toEqual({ mailboxId: 'inbox' })
expect(listQueryOf(SEARCH_INBOX)).toEqual({ q: '회의', mailboxId: 'inbox' })
expect(listQueryOf(SEARCH_ALL)).toEqual({ q: '회의' })
})
it('가리키는 메일함 — 전체 메일함 검색이면 없다', () => {
expect(scopeMailboxIdOf(INBOX)).toBe('inbox')
expect(scopeMailboxIdOf(SEARCH_INBOX)).toBe('inbox')
expect(scopeMailboxIdOf(SEARCH_ALL)).toBeNull()
expect(scopeMailboxIdOf(null)).toBeNull()
expect(isAllMailboxSearch(SEARCH_ALL)).toBe(true)
expect(isAllMailboxSearch(SEARCH_INBOX)).toBe(false)
})
it('같은 목록인지 — 종류 · 메일함 · 검색어 · 범위가 모두 같아야 한다', () => {
expect(sameListSource(INBOX, { kind: 'mailbox', mailboxId: 'inbox' })).toBe(true)
expect(sameListSource(INBOX, SEARCH_INBOX)).toBe(false)
expect(sameListSource(SEARCH_INBOX, SEARCH_ALL)).toBe(false)
expect(sameListSource(SEARCH_ALL, { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL })).toBe(true)
expect(sameListSource(null, null)).toBe(true)
expect(sameListSource(null, INBOX)).toBe(false)
expect(listKeyOf(SEARCH_INBOX)).not.toBe(listKeyOf(SEARCH_ALL))
})
it('주소 — 검색 결과는 /mail/search?q=…&in=…, 결과에서 연 메일은 /mail/search/<id>?q=…&in=…', () => {
expect(listLocationOf(INBOX)).toEqual({ name: 'mail-mailbox', params: { mailboxId: 'inbox' } })
expect(messageLocationOf(INBOX, 'm1')).toEqual({
name: 'mail-message',
params: { mailboxId: 'inbox', messageId: 'm1' },
})
expect(listLocationOf(SEARCH_ALL)).toEqual({ name: 'mail-search', query: { q: '회의', in: 'all' } })
expect(messageLocationOf(SEARCH_INBOX, 'm1')).toEqual({
name: 'mail-search-message',
params: { messageId: 'm1' },
query: { q: '회의', in: 'inbox' },
})
expect(isSearchRouteName('mail-search')).toBe(true)
expect(isSearchRouteName('mail-search-message')).toBe(true)
expect(isSearchRouteName('mail-mailbox')).toBe(false)
})
it('주소의 검색 쿼리 — 앞뒤 공백을 빼고, 비었으면 검색이 아니다. 범위가 없으면 전체', () => {
expect(searchSourceFromQuery({ q: ' 회의 일정 ', in: 'inbox' })).toEqual({
kind: 'search',
q: '회의 일정',
scope: 'inbox',
})
expect(searchSourceFromQuery({ q: '회의' })).toEqual({ kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL })
expect(searchSourceFromQuery({ q: ['회의', '무시'], in: ['sent'] })).toEqual({
kind: 'search',
q: '회의',
scope: 'sent',
})
expect(searchSourceFromQuery({ q: ' ' })).toBeNull()
expect(searchSourceFromQuery({})).toBeNull()
})
it('검색어는 서버 한도(200 — JS 문자열 길이)에서 자르고, 이모지를 가운데서 자르면 그 글자를 뺀다', () => {
expect(normalizeSearchText('가'.repeat(250))).toHaveLength(200)
const emoji = normalizeSearchText(`${'가'.repeat(199)}😀`)
expect(emoji).toBe('가'.repeat(199))
expect(() => encodeURIComponent(emoji)).not.toThrow()
expect(normalizeSearchText(' ')).toBe('')
})
it('낱말은 8개까지 — 띄어쓰기로 나눈 조각을 센다', () => {
expect(exceedsSearchWords('하나 둘 셋 넷 다섯 여섯 일곱 여덟')).toBe(false)
expect(exceedsSearchWords('하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉')).toBe(true)
expect(exceedsSearchWords(' "회의를 잡겠습니다" ')).toBe(false)
})
})
@@ -0,0 +1,109 @@
// 보이는 메일 목록의 출처 — 메일함 하나 또는 검색 결과 (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색").
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { MessageListQuery } from '@/shared/types/mail'
export interface MailboxListSource {
kind: 'mailbox'
mailboxId: string
}
export interface SearchListSource {
kind: 'search'
/** 앞뒤 공백을 뺀 검색어 */
q: string
/** 찾을 곳 — 메일함 id 또는 전체(SEARCH_SCOPE_ALL) */
scope: string
}
export type MailListSource = MailboxListSource | SearchListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
export const MAIL_SEARCH_MESSAGE_ROUTE = 'mail-search-message'
export function isSearchRouteName(name: unknown): boolean {
return name === MAIL_SEARCH_ROUTE || name === MAIL_SEARCH_MESSAGE_ROUTE
}
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 === 'search' && b.kind === 'search') return a.q === b.q && a.scope === b.scope
return false
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
export function listQueryOf(source: MailListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
}
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색이면 null */
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
if (!source) return null
if (source.kind === 'mailbox') return source.mailboxId
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
}
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 (행에 메일함 이름을 보인다) */
export function isAllMailboxSearch(source: MailListSource | null): boolean {
return source?.kind === 'search' && source.scope === SEARCH_SCOPE_ALL
}
/** 목록마다 다른 키 — 표 위치 기억 등 */
export function listKeyOf(source: MailListSource): string {
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
}
/** 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> */
export function searchRouteQuery(source: SearchListSource): Record<string, string> {
return { q: source.q, in: source.scope }
}
/** 목록 화면 주소 */
export function listLocationOf(source: MailListSource): RouteLocationRaw {
if (source.kind === 'mailbox') return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
return { name: MAIL_SEARCH_ROUTE, query: searchRouteQuery(source) }
}
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=… */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
if (source.kind === 'mailbox') {
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
}
return { name: MAIL_SEARCH_MESSAGE_ROUTE, params: { messageId }, query: searchRouteQuery(source) }
}
function firstValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
const first = Array.isArray(value) ? value[0] : value
return typeof first === 'string' ? first : ''
}
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
/**
* 검색어 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다
*/
export function normalizeSearchText(text: string): string {
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
}
/** 서버가 받는 낱말 수를 넘는가 — 띄어쓰기로 나눈 조각을 센다 (따옴표와 상관없이, 서버와 같다) */
export function exceedsSearchWords(text: string): boolean {
return text.split(/\s+/u).filter((word) => word !== '').length > MAIL_SEARCH_MAX_WORDS
}
/** 주소의 검색 쿼리 → 출처. 검색어가 없으면 null (범위가 비었으면 전체) */
export function searchSourceFromQuery(query: LocationQuery): SearchListSource | null {
const q = normalizeSearchText(firstValue(query.q))
if (!q) return null
const scope = firstValue(query.in).trim()
return { kind: 'search', q, scope: scope || SEARCH_SCOPE_ALL }
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { checkAllState, checkRange, pruneChecked, toggleChecked } from './mail-selection'
const ORDER = ['m1', 'm2', 'm3', 'm4', 'm5']
const sorted = (set: ReadonlySet<string>) => [...set].sort()
describe('mail-selection (여러 통 고르기)', () => {
it('하나 고르기 · 풀기 — 새 모음을 돌려준다', () => {
const empty = new Set<string>()
const one = toggleChecked(empty, 'm2')
expect(sorted(one)).toEqual(['m2'])
expect(empty.size).toBe(0)
expect(sorted(toggleChecked(one, 'm2'))).toEqual([])
})
it('Shift — 기준부터 누른 메일까지, 위로도 아래로도', () => {
expect(sorted(checkRange(new Set(['m2']), ORDER, 'm2', 'm4'))).toEqual(['m2', 'm3', 'm4'])
expect(sorted(checkRange(new Set(['m4']), ORDER, 'm4', 'm1'))).toEqual(['m1', 'm2', 'm3', 'm4'])
})
it('Shift — 누른 메일이 고른 것이었으면 범위를 푼다', () => {
const all = new Set(ORDER)
expect(sorted(checkRange(all, ORDER, 'm1', 'm3'))).toEqual(['m4', 'm5'])
})
it('Shift — 기준이 없거나 목록에 없으면 누른 메일 하나만', () => {
expect(sorted(checkRange(new Set(), ORDER, null, 'm3'))).toEqual(['m3'])
expect(sorted(checkRange(new Set(['m1']), ORDER, 'gone', 'm3'))).toEqual(['m1', 'm3'])
expect(sorted(checkRange(new Set(['m1']), ORDER, 'm1', 'gone'))).toEqual(['m1'])
})
it('표 머리 상자 — 하나도 · 일부만 · 모두', () => {
expect(checkAllState(new Set(), ORDER)).toBe('none')
expect(checkAllState(new Set(['m1']), ORDER)).toBe('some')
expect(checkAllState(new Set(ORDER), ORDER)).toBe('all')
expect(checkAllState(new Set(['gone']), ORDER)).toBe('none')
})
it('목록에서 빠진 메일은 뺀다 — 뺄 것이 없으면 같은 모음(다시 그리지 않게)', () => {
const checked = new Set(['m1', 'm3'])
expect(pruneChecked(checked, ORDER)).toBe(checked)
expect(sorted(pruneChecked(checked, ['m3', 'm4']))).toEqual(['m3'])
})
})
@@ -0,0 +1,52 @@
// 여러 통 고르기 — 고른 메일 id 모음을 다루는 순수 함수 (디자인 README "3단계 › 여러 통 처리")
// 고를 수 있는 것은 불러온 메일까지다 — 목록 순서(orderedIds)는 지금 보이는 목록 그대로 넘긴다 (계약 2절 "여러 통 한꺼번에")
export type CheckAllState = 'none' | 'some' | 'all'
/** 하나 고르기 · 풀기 (Ctrl · ⌘ + 클릭, 고르기 상자) */
export function toggleChecked(checked: ReadonlySet<string>, id: string): Set<string> {
const next = new Set(checked)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
}
/**
* Shift + 클릭 — 기준(마지막으로 누른 메일)부터 누른 메일까지를 누른 메일의 새 상태로 맞춘다
* (누른 메일이 고른 것이었으면 범위를 풀고, 아니면 범위를 고른다). 기준이 없거나 목록에 없으면 누른 메일 하나만 바꾼다
*/
export function checkRange(
checked: ReadonlySet<string>,
orderedIds: readonly string[],
anchorId: string | null,
targetId: string,
): Set<string> {
const target = orderedIds.indexOf(targetId)
const anchor = anchorId === null ? -1 : orderedIds.indexOf(anchorId)
if (target === -1) return new Set(checked)
if (anchor === -1) return toggleChecked(checked, targetId)
const select = !checked.has(targetId)
const [from, to] = anchor <= target ? [anchor, target] : [target, anchor]
const next = new Set(checked)
for (const id of orderedIds.slice(from, to + 1)) {
if (select) next.add(id)
else next.delete(id)
}
return next
}
/** 표 머리 상자의 상태 — 하나도 · 일부만(– 표시) · 모두 */
export function checkAllState(checked: ReadonlySet<string>, orderedIds: readonly string[]): CheckAllState {
const count = orderedIds.filter((id) => checked.has(id)).length
if (count === 0) return 'none'
return count === orderedIds.length ? 'all' : 'some'
}
/** 목록에 없는 id 를 뺀다 — 뺄 것이 없으면 같은 모음을 돌려준다 (다시 그리지 않게) */
export function pruneChecked(checked: ReadonlySet<string>, orderedIds: readonly string[]): ReadonlySet<string> {
if (checked.size === 0) return checked
const present = new Set(orderedIds)
const kept = [...checked].filter((id) => present.has(id))
return kept.length === checked.size ? checked : new Set(kept)
}
+9 -6
View File
@@ -78,14 +78,17 @@ describe('mail-view.store (보기 방식 — 사용자별 기억)', () => {
expect(parseMailViewMode('list')).toBe('list')
})
it('표 위치는 같은 메일함으로 돌아왔을 때만 한 번 돌려준다', () => {
it('표 위치는 같은 목록(메일함 · 검색 결과)으로 돌아왔을 때만 한 번 돌려준다', () => {
const store = useMailViewStore()
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
store.rememberListPosition({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
expect(store.takeListPosition('sent')).toBeNull()
expect(store.takeListPosition('mailbox:sent')).toBeNull()
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
expect(store.takeListPosition('inbox')).toEqual({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
expect(store.takeListPosition('inbox')).toBeNull()
store.rememberListPosition({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
expect(store.takeListPosition('mailbox:inbox')).toEqual({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
expect(store.takeListPosition('mailbox:inbox')).toBeNull()
store.rememberListPosition({ listKey: 'search:all:회의', scrollTop: 120, windowY: 0 })
expect(store.takeListPosition('search:inbox:회의')).toBeNull()
})
})
+5 -4
View File
@@ -41,7 +41,8 @@ function writeStored(key: string, value: string) {
/** 목록 보기에서 메일을 열 때 기억해 둔 표 위치 — "목록으로" 돌아오면 되살린다 */
export interface ListPosition {
mailboxId: string
/** 어느 목록의 위치인지 — 메일함 · 검색 결과마다 다르다 (mail-list-source.ts 의 listKeyOf) */
listKey: string
/** 표 칸의 스크롤 (넓은 화면 — 칸마다 따로 스크롤) */
scrollTop: number
/** 화면 전체 스크롤 (좁은 화면 — 칸이 접히고 화면이 스크롤) */
@@ -74,11 +75,11 @@ export const useMailViewStore = defineStore('mail-view', () => {
listPosition.value = position
}
/** 같은 메일함으로 돌아왔을 때만 위치를 돌려주고 비운다 */
function takeListPosition(mailboxId: string): ListPosition | null {
/** 같은 목록(메일함 · 검색 결과)으로 돌아왔을 때만 위치를 돌려주고 비운다 */
function takeListPosition(listKey: string): ListPosition | null {
const saved = listPosition.value
listPosition.value = null
return saved && saved.mailboxId === mailboxId ? saved : null
return saved && saved.listKey === listKey ? saved : null
}
function setReturnFocus(messageId: string | null) {
+566
View File
@@ -0,0 +1,566 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { 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'
// 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('junk', 'junk', '스팸메일함'),
mailbox('trash', 'trash', '휴지통'),
]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-27T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length) {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } })
}
const ids = (list: readonly { id: string }[]) => list.map((item) => item.id)
const patches = (requests: MockRequest[]) =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
describe('mail.store — 3단계 목록', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
/** 메일함 목록과 첫 페이지를 받은 상태로 */
async function openInbox(
items: MessageSummary[],
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | 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: 5 } })
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
if (request.url === '/messages' && request.method === 'GET') return page(items)
return ok({ updatedIds: [], failedIds: [] })
})
const store = useMailStore()
await store.loadMailboxes()
await store.openMailbox('inbox')
return { store, requests }
}
describe('검색 (계약 2절 "검색")', () => {
it('한 메일함에서 찾으면 q + mailboxId, 전체 메일함이면 q 만 — 가리키는 메일함은 그 범위다', async () => {
const requests = installMockApi(() => page([summary('r1')]))
const store = useMailStore()
await store.openSearch('회의', 'inbox')
expect(requests[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
expect(store.search).toEqual({ kind: 'search', q: '회의', scope: 'inbox' })
expect(store.currentMailboxId).toBe('inbox')
await store.openSearch('회의', SEARCH_SCOPE_ALL)
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
expect(store.currentMailboxId).toBeNull()
expect(ids(store.messages)).toEqual(['r1'])
})
it('같은 검색 결과가 이미 보이면 다시 찾지 않는다 — 같은 검색어로 다시 Enter(force)면 보이는 채로 다시 찾는다', async () => {
const requests = installMockApi(() => page([summary('r1')]))
const store = useMailStore()
await store.openSearch('회의', SEARCH_SCOPE_ALL)
await store.openSearch('회의', SEARCH_SCOPE_ALL)
expect(requests).toHaveLength(1)
await store.openSearch('회의', SEARCH_SCOPE_ALL, { force: true })
expect(requests).toHaveLength(2)
expect(store.listStatus).toBe('ready')
})
it('더 보기는 검색어 · 범위와 커서를 함께 보낸다', async () => {
const requests = installMockApi(({ params }) =>
params.cursor ? page([summary('r2')]) : page([summary('r1')], 'c2', 2),
)
const store = useMailStore()
await store.openSearch('회의', 'sent')
await store.loadMore()
expect(requests[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
expect(ids(store.messages)).toEqual(['r1', 'r2'])
})
it('메일함으로 돌아가면 보이던 결과를 버리고 그 메일함을 새로 연다', async () => {
const requests = installMockApi(({ params }) => (params.q ? page([summary('r1')]) : page([summary('m1')])))
const store = useMailStore()
await store.openMailbox('inbox')
await store.openSearch('회의', 'inbox')
const opening = store.openMailbox('inbox')
expect(store.listStatus).toBe('loading')
await opening
expect(store.search).toBeNull()
expect(ids(store.messages)).toEqual(['m1'])
expect(requests.at(-1)?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
})
})
describe('새 메일 확인으로 목록 맞추기 (계약 2절 "새 메일 확인")', () => {
it('지금까지 불러온 만큼(적어도 20) 다시 받아 맞추고, 새로 생긴 메일 수를 알린다 — 고른 메일은 그대로', async () => {
const first = Array.from({ length: 30 }, (_, index) => summary(`m${index + 1}`))
let fresh: MessageSummary[] = first
const { store, requests } = await openInbox(first, (request) =>
request.url === '/messages' && request.params.limit !== 20 ? page(fresh, 'c-next', 99) : undefined,
)
store.toggleCheck('m3')
fresh = [summary('new1'), ...first.slice(0, 29)]
await store.syncVisibleList()
const sync = requests.at(-1)
expect(sync?.params).toEqual({ mailboxId: 'inbox', limit: 30 })
expect(sync?.config.silent).toBe(true)
expect(ids(store.messages).slice(0, 2)).toEqual(['new1', 'm1'])
expect(store.messages).toHaveLength(31)
expect(store.listSync?.addedAbove).toBe(1)
expect([...store.checkedIds]).toEqual(['m3'])
})
it('100통보다 많이 불러왔으면 100통만 다시 받는다', async () => {
const loaded = Array.from({ length: 140 }, (_, index) => summary(`m${index + 1}`))
const { store, requests } = await openInbox([], (request) =>
request.url === '/messages' && request.params.limit === 100 ? page(loaded.slice(0, 100), 'c', 140) : undefined,
)
store.messages = loaded
await store.syncVisibleList()
expect(requests.at(-1)?.params).toEqual({ mailboxId: 'inbox', limit: 100 })
expect(store.messages).toHaveLength(140)
})
it('검색 결과는 저절로 바꾸지 않는다 — 되돌리기처럼 사용자가 한 일 뒤에만(includeSearch) 맞춘다', async () => {
const requests = installMockApi(() => page([summary('r1')]))
const store = useMailStore()
await store.openSearch('회의', SEARCH_SCOPE_ALL)
await store.syncVisibleList()
expect(requests).toHaveLength(1)
await store.syncVisibleList({ includeSearch: true })
expect(requests).toHaveLength(2)
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
})
it('실패해도 알리지 않고 목록을 그대로 둔다', async () => {
let broken = false
const { store } = await openInbox([summary('m1')], (request) =>
broken && request.url === '/messages' ? fail(503, 'MAIL_001') : undefined,
)
broken = true
await store.syncVisibleList()
expect(ids(store.messages)).toEqual(['m1'])
expect(useToastStore().toasts).toEqual([])
})
it('보내는 중인 중요 표시를 옛 값으로 되돌려 보이지 않는다', async () => {
let releasePatch: (result: MockResult) => void = () => undefined
const { store } = await openInbox([summary('m1')], (request) => {
if (request.method === 'PATCH') {
return new Promise<MockResult>((resolve) => {
releasePatch = resolve
})
}
return undefined
})
const toggling = store.toggleFlag('m1')
await store.syncVisibleList()
expect(store.messages[0]?.isFlagged).toBe(true)
releasePatch(ok(summary('m1', { isFlagged: true })))
await toggling
expect(store.messages[0]?.isFlagged).toBe(true)
})
})
describe('고르기', () => {
it('하나씩 · 범위 · 모두 — 메일함을 바꾸면 풀고, 목록에서 빠진 메일은 뺀다', async () => {
const { store } = await openInbox([summary('m1'), summary('m2'), summary('m3'), summary('m4')])
store.toggleCheck('m1')
store.checkRangeTo('m3')
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
expect(store.checkState).toBe('some')
store.toggleCheckAll()
expect(store.checkState).toBe('all')
store.messages = store.messages.filter((message) => message.id !== 'm2')
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3', 'm4'])
await store.openMailbox('sent')
expect(store.checkedCount).toBe(0)
})
})
describe('여러 통 처리 (계약 2절 "여러 통 한꺼번에")', () => {
it('읽음 표시 — 화면을 먼저 바꾸고 failedIds 만 되돌린다 · 안 읽은 개수도 · 고른 것은 그대로', async () => {
const { store, requests } = await openInbox(
[summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
(request) => (request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] }) : undefined),
)
store.toggleCheckAll()
const marking = store.patchChecked({ isRead: true })
expect(store.messages.every((message) => message.isRead)).toBe(true)
expect(store.mailboxByRole('inbox')?.unreadCount).toBe(0)
await marking
expect(patches(requests)).toEqual([{ ids: ['m1', 'm2', 'm3'], isRead: true }])
expect(store.messages.map((message) => message.isRead)).toEqual([true, false, true])
expect(store.checkedCount).toBe(3)
})
it('100통이 넘으면 100통씩 차례로 보낸다 — 요청이 실패한 묶음은 되돌린다', async () => {
const many = Array.from({ length: 230 }, (_, index) => summary(`m${index + 1}`))
let calls = 0
const { store, requests } = await openInbox(many, (request) => {
if (request.method !== 'PATCH') return undefined
calls += 1
const patch = request.data as MessagesPatch
return calls === 3 ? fail(503, 'MAIL_001') : ok({ updatedIds: patch.ids, failedIds: [] })
})
store.toggleCheckAll()
const result = await store.patchChecked({ isFlagged: true })
expect(patches(requests).map((patch) => patch.ids.length)).toEqual([100, 100, 30])
expect(result.doneIds).toHaveLength(200)
expect(result.unsentIds).toHaveLength(30)
expect(store.messages.filter((message) => message.isFlagged)).toHaveLength(200)
})
it('이동 — updatedIds 만 목록에서 빼고 전체 개수를 줄인다 · 고른 것을 푼다 · 되돌리기는 원래 메일함으로', async () => {
const { store, requests } = await openInbox(
[summary('m1'), summary('m2'), summary('m3')],
(request) => (request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] }) : undefined),
)
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
store.toggleCheckAll()
const result = await store.moveChecked('archive')
expect(patches(requests)).toEqual([{ ids: ['m1', 'm2', 'm3'], mailboxId: 'archive' }])
expect(ids(store.messages)).toEqual(['m2'])
expect(store.pageInfo?.total).toBe(1)
expect(store.checkedCount).toBe(0)
expect(result.skippedIds).toEqual(['m2'])
expect(result.origins).toEqual([{ mailboxId: 'inbox', ids: ['m1', 'm3'] }])
})
it('전체 메일함 검색 결과 — 다른 메일함으로 옮기면 결과에 남고(메일함만 바뀐다), 휴지통 · 스팸메일함으로 옮기면 빠진다', async () => {
const requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.method === 'GET') {
return page([summary('r1'), summary('r2', { mailboxIds: ['sent'] }), summary('r3', { mailboxIds: ['archive'] })])
}
const patch = request.data as MessagesPatch
return ok({ updatedIds: patch.ids, failedIds: [] })
})
const store = useMailStore()
await store.loadMailboxes()
await store.openSearch('회의', SEARCH_SCOPE_ALL)
store.toggleCheck('r1')
store.toggleCheck('r2')
const moved = await store.moveChecked('archive')
expect(ids(store.messages)).toEqual(['r1', 'r2', 'r3'])
expect(store.messages.map((message) => message.mailboxIds)).toEqual([['archive'], ['archive'], ['archive']])
// 되돌리기 — 원래 메일함별로 묶는다 (전체 검색은 메일마다 다르다)
expect(moved.origins).toEqual([
{ mailboxId: 'inbox', ids: ['r1'] },
{ mailboxId: 'sent', ids: ['r2'] },
])
store.toggleCheck('r3')
await store.moveChecked('trash')
expect(ids(store.messages)).toEqual(['r1', 'r2'])
expect(patches(requests).at(-1)).toEqual({ ids: ['r3'], mailboxId: 'trash' })
})
it('되돌리기 — 원래 메일함별로 묶어 다시 옮긴 뒤 보이는 목록을 맞춘다', async () => {
let restored = false
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
if (request.method === 'PATCH') {
const patch = request.data as MessagesPatch
restored = true
return ok({ updatedIds: patch.ids, failedIds: [] })
}
if (request.url === '/messages' && restored) return page([summary('m1'), summary('m2'), summary('p1')])
return undefined
})
const result = await store.restoreMoved([
{ mailboxId: 'inbox', ids: ['m1'] },
{ mailboxId: 'project', ids: ['p1'] },
])
await flushPromises()
expect(patches(requests)).toEqual([
{ ids: ['m1'], mailboxId: 'inbox' },
{ ids: ['p1'], mailboxId: 'project' },
])
expect(result.doneIds).toEqual(['m1', 'p1'])
expect(ids(store.messages)).toEqual(['m1', 'm2', 'p1'])
})
it('영구 삭제 — POST /message-deletions 로 100통씩, deletedIds 만 빼고 skippedIds 는 남긴다 · DELETE 는 쓰지 않는다', async () => {
const trashItems = Array.from({ length: 120 }, (_, index) => summary(`t${index + 1}`, { mailboxIds: ['trash'] }))
const requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.url === '/message-deletions') {
const body = request.data as { ids: string[] }
return ok({ deletedIds: body.ids.filter((id) => id !== 't2'), skippedIds: body.ids.filter((id) => id === 't2') })
}
if (request.url === '/messages') return page(trashItems)
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
await store.loadMailboxes()
await store.openMailbox('trash')
store.toggleCheckAll()
const result = await store.purgeChecked()
const deletions = requests.filter((request) => request.url === '/message-deletions')
expect(deletions.map((request) => request.method)).toEqual(['POST', 'POST'])
expect(deletions.map((request) => (request.data as { ids: string[] }).ids.length)).toEqual([100, 20])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
expect(result.doneIds).toHaveLength(119)
expect(result.skippedIds).toEqual(['t2'])
expect(ids(store.messages)).toEqual(['t2'])
})
it('영구 삭제 중 한 묶음이 실패하면(503 — 일부를 지운 뒤일 수 있다) 보이는 목록을 다시 맞춘다 — 휴지통 안의 검색 결과도', async () => {
const trashItems = Array.from({ length: 120 }, (_, index) => summary(`t${index + 1}`, { mailboxIds: ['trash'] }))
let deletions = 0
const requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.url === '/message-deletions') {
deletions += 1
return deletions === 2 ? fail(503, 'MAIL_001') : ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
}
if (request.url === '/messages') return page(trashItems.slice(100))
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
await store.loadMailboxes()
store.listSource = { kind: 'search', q: '백업', scope: 'trash' }
store.messages = trashItems
store.listStatus = 'ready'
store.toggleCheckAll()
const result = await store.purgeChecked()
await flushPromises()
expect(result.doneIds).toHaveLength(100)
expect(result.unsentIds).toHaveLength(20)
const resync = requests.filter((request) => request.url === '/messages').at(-1)
expect(resync?.params).toEqual({ q: '백업', mailboxId: 'trash', limit: 20 })
expect(resync?.config.silent).toBe(true)
expect(ids(store.messages)).toEqual(ids(trashItems.slice(100)))
})
})
describe('경합 — 늦게 온 응답 · 그사이 바뀐 목록', () => {
it('목록 맞추기 응답이 이 탭의 이동보다 늦게 오면 버리고 한 번 더 맞춘다 — 옮긴 메일이 되살아나지 않는다', async () => {
let hold = false
let release: (result: MockResult) => void = () => undefined
let server = [summary('m1'), summary('m2'), summary('m3')]
const { store, requests } = await openInbox(server, (request) => {
if (request.method === 'PATCH') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url !== '/messages') return undefined
if (hold) {
hold = false
return new Promise<MockResult>((resolve) => {
release = resolve
})
}
return page(server)
})
const listsBefore = requests.filter((request) => request.method === 'GET' && request.url === '/messages').length
hold = true
const syncing = store.syncVisibleList()
store.toggleCheck('m1')
await store.moveChecked('archive')
server = [summary('m2'), summary('m3')]
// 이동 전에 떠난 응답 — 옮긴 메일(m1)이 아직 들어 있다
release(page([summary('m1'), summary('m2'), summary('m3')]))
await syncing
await flushPromises()
expect(ids(store.messages)).toEqual(['m2', 'm3'])
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages').length).toBe(listsBefore + 2)
})
it('목록 맞추기 응답이 읽음 표시보다 늦게 오면 버린다 — 목록과 열린 메일의 읽음이 되돌아가지 않는다', async () => {
let hold = false
let release: (result: MockResult) => void = () => undefined
let serverRead = false
const { store } = await openInbox([summary('m1', { isRead: false })], (request) => {
if (request.method === 'PATCH') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url !== '/messages') return undefined
if (hold) {
hold = false
return new Promise<MockResult>((resolve) => {
release = resolve
})
}
return page([summary('m1', { isRead: serverRead })])
})
store.selected = { ...summary('m1', { isRead: false }), cc: [], bcc: [], replyTo: [], sentAt: null, messageId: null, references: [], htmlBody: null, textBody: '본문', hasBlockedImages: false, attachments: [] }
store.selectedId = 'm1'
hold = true
const syncing = store.syncVisibleList()
store.toggleCheck('m1')
await store.patchChecked({ isRead: true })
serverRead = true
release(page([summary('m1', { isRead: false })]))
await syncing
await flushPromises()
expect(store.messages[0]?.isRead).toBe(true)
expect(store.selected?.isRead).toBe(true)
})
it('여러 통 이동 중에 다른 메일함을 열면 — 끝난 이동은 새 목록과 그 목록에서 고른 것을 건드리지 않는다', async () => {
let releasePatch: (result: MockResult) => void = () => undefined
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
if (request.method === 'PATCH') {
return new Promise<MockResult>((resolve) => {
releasePatch = resolve
})
}
if (request.url === '/messages' && request.params.mailboxId === 'archive') {
return page([summary('m1', { mailboxIds: ['archive'] }), summary('a1', { mailboxIds: ['archive'] })])
}
return undefined
})
store.toggleCheck('m1')
const moving = store.moveChecked('archive')
await store.openMailbox('archive')
store.toggleCheck('a1')
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
const result = await moving
await flushPromises()
expect(result.stale).toBe(true)
expect(ids(store.messages)).toEqual(['m1', 'a1'])
expect([...store.checkedIds]).toEqual(['a1'])
// 개수는 서버 기준으로 다시 맞춘다
expect(requests.filter((request) => request.url === '/mailboxes').length).toBeGreaterThan(1)
})
it('읽기 화면의 한 통 삭제 중에 검색 결과로 옮겨 가면 — 결과에서 빼지 않고 다시 찾지도 않는다', async () => {
let releasePatch: (result: MockResult) => void = () => undefined
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
if (request.method === 'PATCH') {
return new Promise<MockResult>((resolve) => {
releasePatch = resolve
})
}
if (request.url === '/messages' && request.params.q === '회의') return page([summary('m1')])
return undefined
})
const open = store.messages[0]
if (!open) throw new Error('메일이 없다')
const trashing = store.moveMessages([open], 'trash')
await store.openSearch('회의', SEARCH_SCOPE_ALL)
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
const result = await trashing
await flushPromises()
expect(result.stale).toBe(true)
expect(ids(store.messages)).toEqual(['m1'])
// 검색 결과는 저절로 다시 찾지 않는다
expect(requests.filter((request) => request.method === 'GET' && request.params.q === '회의')).toHaveLength(1)
})
it('이동 중에 다른 메일함에 갔다 돌아오면 — 이동이 닿기 전에 다시 받은 목록을 끝난 뒤 서버 기준으로 맞춘다 (옮긴 메일이 남지 않는다)', async () => {
let releasePatch: (result: MockResult) => void = () => undefined
let inbox = [summary('m1'), summary('m2')]
const { store, requests } = await openInbox(inbox, (request) => {
if (request.method === 'PATCH') {
return new Promise<MockResult>((resolve) => {
releasePatch = resolve
})
}
if (request.url === '/messages' && request.params.mailboxId === 'inbox') return page(inbox)
return undefined
})
store.toggleCheck('m1')
const moving = store.moveChecked('archive')
await store.openMailbox('sent')
await store.openMailbox('inbox')
expect(ids(store.messages)).toEqual(['m1', 'm2'])
inbox = [summary('m2')]
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
const result = await moving
await flushPromises()
expect(result.stale).toBe(true)
expect(ids(store.messages)).toEqual(['m2'])
const inboxLists = requests.filter((request) => request.method === 'GET' && request.params.mailboxId === 'inbox')
expect(inboxLists.at(-1)?.config.silent).toBe(true)
})
})
})
+25 -15
View File
@@ -230,23 +230,31 @@ describe('mail.store', () => {
expect(store.messages[0]?.isFlagged).toBe(true)
})
it('삭제하면 목록에서 빼고 다음 메일(없으면 이전 메일) id 를 돌려준다', async () => {
const requests = installMockApi(({ method, url }) => {
if (method === 'DELETE') return { status: 204 }
it('한 통 삭제 — 휴지통으로 옮기고(PATCH /messages) 목록에서 뺀다, 이어서 볼 메일은 다음 메일(없으면 이전 메일) · DELETE 는 쓰지 않는다', async () => {
const requests = installMockApi(({ method, url, data }) => {
if (method === 'PATCH') return ok({ updatedIds: (data as { ids: string[] }).ids, failedIds: [] })
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
return page([summary('m1'), summary('m2'), summary('m3')])
})
const store = useMailStore()
await store.openMailbox('inbox')
await expect(store.removeMessage('m2')).resolves.toBe('m3')
await expect(store.removeMessage('m3')).resolves.toBe('m1')
expect(store.neighborAfterRemoval('m2')).toBe('m3')
const second = store.messages[1]
if (second) await store.moveMessages([second], 'trash')
expect(store.neighborAfterRemoval('m3')).toBe('m1')
const third = store.messages[1]
if (third) await store.moveMessages([third], 'trash')
expect(store.messages.map((message) => message.id)).toEqual(['m1'])
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/messages/m2',
'/messages/m3',
expect(
requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data]),
).toEqual([
['/messages', { ids: ['m2'], mailboxId: 'trash' }],
['/messages', { ids: ['m3'], mailboxId: 'trash' }],
])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
expect(store.neighborAfterRemoval('gone')).toBeNull()
})
it('다른 메일함으로 옮긴다(되돌리기) — 개수를 다시 맞추고, 보이는 목록이 옮긴 곳 · 휴지통이면 새로 고친다', async () => {
@@ -323,22 +331,24 @@ describe('mail.store', () => {
expect(store.attachmentTotalMaxBytes).toBe(MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES)
})
it('삭제한 뒤에는 메일함 개수와 사용량을 함께 다시 맞춘다', async () => {
const requests = installMockApi(({ method, url }) => {
if (method === 'DELETE') return { status: 204 }
it('영구 삭제한 뒤에는 메일함 개수와 사용량을 함께 다시 맞춘다 — POST /message-deletions, DELETE 는 쓰지 않는다', async () => {
const requests = installMockApi(({ url, data }) => {
if (url === '/message-deletions') return ok({ deletedIds: (data as { ids: string[] }).ids, skippedIds: [] })
if (url === '/mail-quota') return ok(QUOTA)
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
return page([summary('m1'), summary('m2')])
return page([summary('m1', { mailboxIds: ['trash'] }), summary('m2', { mailboxIds: ['trash'] })])
})
const store = useMailStore()
await store.openMailbox('trash')
await store.removeMessage('m1')
await store.purgeMessages(['m1'])
await flushPromises()
expect(requests.map((request) => request.url)).toEqual(
expect.arrayContaining(['/messages/m1', '/mailboxes', '/mail-quota']),
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(
expect.arrayContaining(['POST /message-deletions', 'GET /mailboxes', 'GET /mail-quota']),
)
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
})
})
+93 -402
View File
@@ -1,66 +1,42 @@
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMail } from '@/composables/useMail'
import {
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
} from '@/shared/constants/mail'
import type {
MailQuota,
Mailbox,
MailboxRole,
MessageDetail,
MessageSummary,
} from '@/shared/types/mail'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import { findMailboxByRole, orderMailboxes } from '@/shared/utils/mailbox'
import { useAuthStore } from '@/stores/auth.store'
import { createBulkActions } from './mail/bulk'
import { createListState } from './mail/list'
import { createMailboxesState } from './mail/mailboxes'
import { createMessageActions, createOpenedMessage } from './mail/message'
import { createSelection } from './mail/selection'
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
export type { BulkResult } from '@/shared/utils/mail-bulk'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, SearchOrigin } from './mail/types'
/** 서버가 준 한도가 양수면 그 값, 없거나 이상하면 대체값 */
function positiveOr(value: number | undefined, fallback: number): number {
return typeof value === 'number' && value > 0 ? value : fallback
}
// 메일 도메인 스토어 — 메일함 · 사용량 · 목록 · 선택한 메일
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 포함)
//
// 오류 처리 원칙:
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
// - 사용량은 보조 정보라 silent 로 부르고, 못 받으면 패널 하단을 숨긴다 (첨부 한도는 대체값)
// - 그 밖의 동작(더 보기·읽음·중요·삭제)은 인터셉터 알림에 맡기고 화면 상태만 되돌린다
// - 메일함을 빠르게 바꿀 때 늦게 온 응답이 덮어쓰지 않도록 요청마다 순번을 매긴다
// - 새 메일 확인으로 목록을 맞추는 조회는 알리지 않는다 (다음 확인에서 다시 맞춘다)
// - 그 밖의 동작(더 보기·읽음·중요·삭제·여러 통 처리)은 인터셉터 알림에 맡기고 화면 상태만 되돌린다
// - 늦게 온 응답이 덮어쓰지 않도록 순번을 둔다 (list.ts 머리말)
//
// "고른 메일"(checkedIds — 여러 통 처리의 체크)과 "열린 메일"(selectedId — 읽기 화면)은 다르다.
export const useMailStore = defineStore('mail', () => {
const mailApi = useMail()
const authStore = useAuthStore()
// ── 메일함 ─────────────────────────────────────────
const mailboxes = ref<Mailbox[]>([])
const mailboxesStatus = ref<LoadStatus>('idle')
const mailboxesError = ref<ApiError | null>(null)
const mailboxState = createMailboxesState(mailApi)
const opened = createOpenedMessage()
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 })
// ── 사용량 (GET /mail-quota) ──────────────────────────
const quota = ref<MailQuota | null>(null)
// ── 목록 ──────────────────────────────────────────
const currentMailboxId = ref<string | null>(null)
const messages = ref<MessageSummary[]>([])
const pageInfo = ref<PageInfo | null>(null)
const listStatus = ref<LoadStatus>('idle')
const listError = ref<ApiError | null>(null)
/** 목록을 보이는 채로 다시 불러오는 중 (새로고침) */
const isRefreshing = ref<boolean>(false)
const isLoadingMore = ref<boolean>(false)
// ── 선택한 메일 ────────────────────────────────────
const selectedId = ref<string | null>(null)
const selected = ref<MessageDetail | null>(null)
const selectedStatus = ref<LoadStatus>('idle')
const selectedError = ref<ApiError | null>(null)
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
const lastBrowseLocation = ref<string | null>(null)
/**
@@ -70,305 +46,6 @@ export const useMailStore = defineStore('mail', () => {
*/
const draftSuccessors = new Map<string, string>()
let mailboxSeq = 0
let quotaSeq = 0
let listSeq = 0
let detailSeq = 0
/**
* 첨부 파일당 한도(바이트) — 서버 값(attachmentMaxBytes).
* 사용량을 아직 못 받았거나 값이 이상하면 대체값(20MB)으로 사전 검사·안내한다.
*/
const attachmentMaxBytes = computed<number>(() =>
positiveOr(quota.value?.attachmentMaxBytes, MAIL_ATTACHMENT_FALLBACK_MAX_BYTES),
)
/**
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(attachmentTotalMaxBytes). 발송 때 서버가 검사하는 값과 같다.
* 못 받았거나(이 값을 아직 주지 않는 서버 포함) 값이 이상하면 대체값(25MB)을 쓴다.
*/
const attachmentTotalMaxBytes = computed<number>(() =>
positiveOr(quota.value?.attachmentTotalMaxBytes, MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES),
)
const orderedMailboxes = computed(() => orderMailboxes(mailboxes.value))
const currentMailbox = computed<Mailbox | null>(
() => mailboxes.value.find((mailbox) => mailbox.id === currentMailboxId.value) ?? null,
)
/** /mail 로 들어왔을 때 열 메일함 — 받은편지함, 없으면 첫 메일함 */
const defaultMailbox = computed<Mailbox | null>(
() =>
findMailboxByRole(mailboxes.value, 'inbox') ?? orderedMailboxes.value[0]?.mailbox ?? null,
)
const hasMore = computed<boolean>(
() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor),
)
function mailboxByRole(role: MailboxRole): Mailbox | null {
return findMailboxByRole(mailboxes.value, role)
}
/** 응답을 기다리는 메일함 목록 요청 수 — 그 응답이 화면에서 바꾼 개수를 덮어쓸 수 있는지 판단한다 */
let mailboxLoadsInFlight = 0
/**
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
*/
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
mailboxSeq += 1
const seq = mailboxSeq
if (!options.background) {
mailboxesStatus.value = 'loading'
mailboxesError.value = null
}
mailboxLoadsInFlight += 1
try {
const result = await mailApi.listMailboxes({ silent: true })
if (seq !== mailboxSeq) return null
mailboxes.value = result.items
mailboxesStatus.value = 'ready'
mailboxesError.value = null
return null
} catch (error) {
const apiError = toApiError(error)
if (seq !== mailboxSeq) return apiError
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
if (options.background && mailboxesStatus.value === 'ready') return apiError
mailboxesStatus.value = 'error'
mailboxesError.value = apiError
return apiError
} finally {
mailboxLoadsInFlight -= 1
}
}
/** 지금 보이는 개수가 곧 도착할(또는 아직 없는) 메일함 응답에 덮어쓰일 수 있는지 */
function mailboxCountsMayBeOverwritten(): boolean {
return mailboxLoadsInFlight > 0 || mailboxesStatus.value !== 'ready'
}
/** 아직 불러오지 않았거나 실패했을 때만 불러온다 */
async function ensureMailboxes(): Promise<void> {
if (mailboxesStatus.value === 'idle' || mailboxesStatus.value === 'error') {
await loadMailboxes()
}
}
/** 사용량 · 첨부 한도 — 실패해도 알리지 않고 이전 값을 둔다 (보조 정보) */
async function loadQuota(): Promise<void> {
quotaSeq += 1
const seq = quotaSeq
try {
const result = await mailApi.getQuota({ silent: true })
if (seq === quotaSeq) quota.value = result
} catch {
// 못 받으면 패널 하단 사용량을 숨기고, 첨부 한도는 대체값을 쓴다
}
}
async function ensureQuota(): Promise<void> {
if (!quota.value) await loadQuota()
}
/** 보내기·삭제·새로고침 뒤 — 메일함 개수와 사용량을 서버 기준으로 다시 맞춘다 (화면은 그대로 둔 채) */
function syncCounts() {
void loadMailboxes({ background: true })
void loadQuota()
}
/**
* 메일함 열기 — 첫 페이지를 불러온다.
* 같은 메일함으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
*/
async function openMailbox(mailboxId: string): Promise<void> {
const keepVisible =
mailboxId === currentMailboxId.value && listStatus.value === 'ready'
currentMailboxId.value = mailboxId
listSeq += 1
const seq = listSeq
if (keepVisible) {
isRefreshing.value = true
} else {
messages.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
}
try {
const page = await mailApi.listMessages(
{ mailboxId, limit: DEFAULT_PAGE_SIZE },
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
{ silent: keepVisible ? undefined : true },
)
if (seq !== listSeq) return
messages.value = page.items
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
listError.value = null
} catch (error) {
if (seq !== listSeq || keepVisible) return
listStatus.value = 'error'
listError.value = toApiError(error)
} finally {
if (seq === listSeq) isRefreshing.value = false
}
}
/** 새로고침 — 메일함 개수 · 사용량 · 지금 목록을 함께 다시 불러온다 */
async function refresh(): Promise<void> {
syncCounts()
if (currentMailboxId.value) await openMailbox(currentMailboxId.value)
}
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
async function loadMore(): Promise<void> {
const cursor = pageInfo.value?.nextCursor
const mailboxId = currentMailboxId.value
if (!cursor || !mailboxId || isLoadingMore.value) return
const seq = listSeq
isLoadingMore.value = true
try {
const page = await mailApi.listMessages({ mailboxId, cursor, limit: DEFAULT_PAGE_SIZE })
if (seq !== listSeq) return
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
const known = new Set(messages.value.map((message) => message.id))
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
pageInfo.value = page.pageInfo
} catch {
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
} finally {
isLoadingMore.value = false
}
}
function setLocalFlags(id: string, changes: Partial<Pick<MessageSummary, 'isRead' | 'isFlagged'>>) {
messages.value = messages.value.map((message) =>
message.id === id ? { ...message, ...changes } : message,
)
if (selected.value?.id === id) selected.value = { ...selected.value, ...changes }
}
function adjustUnread(mailboxIds: readonly string[], delta: number) {
mailboxes.value = mailboxes.value.map((mailbox) =>
mailboxIds.includes(mailbox.id)
? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount + delta) }
: mailbox,
)
}
/** 서버가 돌려준 요약으로 목록·선택 메일을 맞춘다 */
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
if (selected.value?.id === summary.id) selected.value = { ...selected.value, ...summary }
}
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function markRead(message: Pick<MessageSummary, 'id' | 'isRead' | 'mailboxIds'>) {
if (message.isRead) return
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
const resync = mailboxCountsMayBeOverwritten()
setLocalFlags(message.id, { isRead: true })
adjustUnread(message.mailboxIds, -1)
try {
applySummary(await mailApi.updateMessage(message.id, { isRead: true }))
if (resync || mailboxCountsMayBeOverwritten()) void loadMailboxes({ background: true })
} catch {
setLocalFlags(message.id, { isRead: false })
adjustUnread(message.mailboxIds, 1)
}
}
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
async function openMessage(id: string): Promise<void> {
selectedId.value = id
if (selected.value?.id === id && selectedStatus.value === 'ready') return
detailSeq += 1
const seq = detailSeq
selected.value = null
selectedStatus.value = 'loading'
selectedError.value = null
try {
const detail = await mailApi.getMessage(id, { silent: true })
if (seq !== detailSeq) return
selected.value = detail
selectedStatus.value = 'ready'
if (!detail.isRead) void markRead(detail)
} catch (error) {
if (seq !== detailSeq) return
selectedStatus.value = 'error'
selectedError.value = toApiError(error)
}
}
function closeMessage() {
detailSeq += 1
selectedId.value = null
selected.value = null
selectedStatus.value = 'idle'
selectedError.value = null
}
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function toggleFlag(id: string): Promise<void> {
const current =
messages.value.find((message) => message.id === id) ??
(selected.value?.id === id ? selected.value : null)
if (!current) return
const next = !current.isFlagged
setLocalFlags(id, { isFlagged: next })
try {
applySummary(await mailApi.updateMessage(id, { isFlagged: next }))
} catch {
setLocalFlags(id, { isFlagged: !next })
}
}
/**
* 삭제 — 휴지통이 아니면 휴지통으로, 휴지통이면 영구 삭제 (서버가 판단).
* 성공하면 이어서 보여 줄 메일 id(다음 메일, 없으면 이전 메일)를 돌려준다.
* 실패하면 예외가 그대로 올라간다 (알림은 인터셉터가 띄웠다).
*/
async function removeMessage(id: string): Promise<string | null> {
await mailApi.deleteMessage(id)
const index = messages.value.findIndex((message) => message.id === id)
const rest = messages.value.filter((message) => message.id !== id)
const next = index === -1 ? null : (rest[index] ?? rest[index - 1] ?? null)
messages.value = rest
if (pageInfo.value && pageInfo.value.total !== null) {
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - 1) }
}
if (selectedId.value === id) closeMessage()
// 휴지통 개수 · 사용량(영구 삭제)까지 서버 기준으로 다시 맞춘다
syncCounts()
return next?.id ?? null
}
/**
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 목록이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
* 실패하면 false (알림은 인터셉터가 띄웠다).
*/
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
try {
await mailApi.updateMessage(id, { mailboxId })
} catch {
return false
}
syncCounts()
const shown = currentMailboxId.value
const trashId = mailboxByRole('trash')?.id ?? null
if (shown !== null && (shown === mailboxId || shown === trashId)) void openMailbox(shown)
return true
}
function rememberBrowseLocation(fullPath: string) {
lastBrowseLocation.value = fullPath
}
@@ -391,25 +68,10 @@ export const useMailStore = defineStore('mail', () => {
}
function reset() {
mailboxSeq += 1
quotaSeq += 1
listSeq += 1
detailSeq += 1
mailboxes.value = []
mailboxesStatus.value = 'idle'
mailboxesError.value = null
quota.value = null
currentMailboxId.value = null
messages.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
isRefreshing.value = false
isLoadingMore.value = false
selectedId.value = null
selected.value = null
selectedStatus.value = 'idle'
selectedError.value = null
mailboxState.reset()
list.reset()
selection.clearChecked()
opened.closeMessage()
lastBrowseLocation.value = null
draftSuccessors.clear()
}
@@ -421,43 +83,72 @@ export const useMailStore = defineStore('mail', () => {
)
return {
mailboxes,
mailboxesStatus,
mailboxesError,
quota,
attachmentMaxBytes,
attachmentTotalMaxBytes,
currentMailboxId,
messages,
pageInfo,
listStatus,
listError,
isRefreshing,
isLoadingMore,
selectedId,
selected,
selectedStatus,
selectedError,
// 메일함 · 사용량
mailboxes: mailboxState.mailboxes,
mailboxesStatus: mailboxState.mailboxesStatus,
mailboxesError: mailboxState.mailboxesError,
quota: mailboxState.quota,
attachmentMaxBytes: mailboxState.attachmentMaxBytes,
attachmentTotalMaxBytes: mailboxState.attachmentTotalMaxBytes,
orderedMailboxes: mailboxState.orderedMailboxes,
defaultMailbox: mailboxState.defaultMailbox,
inboxUnreadCount: mailboxState.inboxUnreadCount,
mailboxByRole: mailboxState.mailboxByRole,
resolveMailboxByRole: mailboxState.resolveMailboxByRole,
loadMailboxes: mailboxState.loadMailboxes,
ensureMailboxes: mailboxState.ensureMailboxes,
loadQuota: mailboxState.loadQuota,
ensureQuota: mailboxState.ensureQuota,
syncCounts: mailboxState.syncCounts,
// 보이는 목록
listSource: list.listSource,
currentMailboxId: list.currentMailboxId,
currentMailbox: list.currentMailbox,
search: list.search,
searchOrigin: list.searchOrigin,
messages: list.messages,
pageInfo: list.pageInfo,
listStatus: list.listStatus,
listError: list.listError,
isRefreshing: list.isRefreshing,
isLoadingMore: list.isLoadingMore,
listSync: list.listSync,
hasMore: list.hasMore,
neighborAfterRemoval: list.neighborAfterRemoval,
openMailbox: list.openMailbox,
openSearch: list.openSearch,
rememberSearchOrigin: list.rememberSearchOrigin,
refresh: list.refresh,
loadMore: list.loadMore,
syncVisibleList: list.syncVisibleList,
// 고른 메일
checkedIds: selection.checkedIds,
checkedCount: selection.checkedCount,
checkedMessages: selection.checkedMessages,
checkState: selection.checkState,
toggleCheck: selection.toggleCheck,
checkRangeTo: selection.checkRangeTo,
toggleCheckAll: selection.toggleCheckAll,
clearChecked: selection.clearChecked,
// 열린 메일
selectedId: opened.selectedId,
selected: opened.selected,
selectedStatus: opened.selectedStatus,
selectedError: opened.selectedError,
openMessage: message.openMessage,
closeMessage: opened.closeMessage,
markRead: message.markRead,
toggleFlag: message.toggleFlag,
moveMessage: message.moveMessage,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
moveMessages: bulk.moveMessages,
moveChecked: bulk.moveChecked,
purgeMessages: bulk.purgeMessages,
purgeChecked: bulk.purgeChecked,
restoreMoved: bulk.restoreMoved,
// 쓰기 화면
lastBrowseLocation,
orderedMailboxes,
currentMailbox,
defaultMailbox,
hasMore,
mailboxByRole,
loadMailboxes,
ensureMailboxes,
loadQuota,
ensureQuota,
syncCounts,
openMailbox,
refresh,
loadMore,
openMessage,
closeMessage,
markRead,
toggleFlag,
removeMessage,
moveMessage,
rememberBrowseLocation,
recordDraftSuccessor,
latestDraftId,
+216
View File
@@ -0,0 +1,216 @@
// 메일 스토어 모듈 — 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에", mail.store.ts 가 조립한다)
// 읽기 화면의 한 통 삭제 · 영구 삭제도 이 길(moveMessages · purgeMessages)을 쓴다 — DELETE /api/messages/{id} 는 쓰지 않는다
// (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다).
// 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있으면(sourceSeq) 그 목록 · 고른 것은 건드리지 않고, 개수와
// (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞춘다 (settleStale).
import { MAIL_BULK_MAX_IDS } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import {
emptyBulkResult,
groupByOrigin,
sendInChunks,
type BulkChunkSender,
type BulkOrigin,
type BulkResult,
} from '@/shared/utils/mail-bulk'
import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { SelectionState } from './selection'
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult } from './types'
interface BulkDeps {
mailApi: MailApi
mailboxes: MailboxesState
list: ListState
selection: SelectionState
}
/** 요청 하나의 ids 한도(100)로 나눠 차례로 보낸다 */
function sendBulk(ids: readonly string[], send: BulkChunkSender): Promise<BulkResult> {
return sendInChunks(ids, MAIL_BULK_MAX_IDS, send)
}
function hasLeftovers(result: BulkResult): boolean {
return result.skippedIds.length > 0 || result.unsentIds.length > 0
}
export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkDeps) {
/** 지금 목록이 시작할 때의 목록인지 — 아니면 끝난 일을 그 목록에 적용하지 않는다 */
function captureList(): () => boolean {
const identity = list.sourceSeq.value
return () => list.sourceSeq.value !== identity
}
/**
* 끝났을 때 다른 목록으로 바뀌어 있었다 — 그 목록은 고치지 않고 개수를 맞춘다. 메일함 목록이면 서버 기준으로 다시 맞춘다:
* 이 일이 서버에 닿기 전에 새로 받은 목록에는 옮기거나 지운 메일 · 옛 표시가 남아 있을 수 있다 (다음 새 메일 확인까지 남지 않게).
* 검색 결과는 저절로 바꾸지 않는다 (syncVisibleList 가 거른다)
*/
function settleStale() {
mailboxes.syncCounts()
void list.syncVisibleList()
}
/**
* 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고, 서버가 바꾸지 못한 메일(failedIds)과 보내지 못한 메일만 되돌린다.
* 고른 것은 그대로 둔다. 알림은 띄우지 않는다 (목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
*/
async function patchChecked(changes: FlagChanges): Promise<MailBulkResult> {
const targets = selection.checkedMessages.value
if (targets.length === 0) return { ...emptyBulkResult(), stale: false }
const isStale = captureList()
const ids = targets.map((message) => message.id)
const before = new Map(targets.map((message) => [message.id, message]))
const flip = (message: MessageSummary, direction: 1 | -1) => {
if (changes.isRead !== undefined && changes.isRead !== message.isRead) {
mailboxes.adjustUnread(message.mailboxIds, (changes.isRead ? -1 : 1) * direction)
}
}
for (const message of targets) {
flip(message, 1)
list.setLocalFlags(message.id, 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 }
}
for (const id of [...result.skippedIds, ...result.unsentIds]) {
const original = before.get(id)
if (!original) continue
flip(original, -1)
list.setLocalFlags(id, { isRead: original.isRead, isFlagged: original.isFlagged })
}
if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
return { ...result, stale: false }
}
/** 되돌리기에 쓸 원래 메일함 — 목록이 가리키는 메일함, 없으면(전체 검색) 옮길 곳이 아닌 첫 메일함 */
function originMailboxOf(message: MessageSummary, 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
}
/** 전체 메일함 검색에서 빠지는 메일함 — 휴지통 · 스팸메일함 */
function isExcludedFromAllSearch(mailboxId: string): boolean {
const role = mailboxes.mailboxes.value.find((mailbox) => mailbox.id === mailboxId)?.role ?? null
return role === 'trash' || role === 'junk'
}
/**
* 옮긴 메일을 목록에 반영한다 — 메일함 목록 · 한 메일함 안의 검색에서는 뺀다.
* 여전히 이 목록에 드는 메일은 메일함만 바꾼다: 전체 메일함 검색 결과(휴지통 · 스팸메일함으로 옮긴 것은 뺀다),
* 이 목록의 메일함으로 옮긴 메일(열린 메일의 메일함 정보가 낡았을 때 — 이미 휴지통에 있던 메일을 휴지통으로)
*/
function applyMoved(ids: readonly string[], targetId: string) {
if (ids.length === 0) return
const moved = new Set(ids)
const source = list.listSource.value
const staysInAll = isAllMailboxSearch(source) && !isExcludedFromAllSearch(targetId)
if (staysInAll || scopeMailboxIdOf(source) === targetId) list.relocate(moved, targetId)
else list.removeFromList(moved)
}
/**
* 메일을 옮긴다 (PATCH /messages { mailboxId }) — 휴지통 밖의 "삭제"도 휴지통으로 옮기는 것이다.
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
*/
async function moveMessages(targets: readonly MessageSummary[], targetId: string): Promise<BulkMoveResult> {
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false }
const isStale = captureList()
const origins = new Map<string, string>()
for (const message of targets) {
const origin = originMailboxOf(message, targetId)
if (origin && origin !== targetId) origins.set(message.id, origin)
}
const result = await sendBulk(
targets.map((message) => message.id),
async (chunkIds) => {
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId })
return { done: patched.updatedIds, skipped: patched.failedIds }
},
)
const stale = isStale()
if (stale) {
settleStale()
} else {
applyMoved(result.doneIds, targetId)
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale }
}
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
async function moveChecked(targetId: string): Promise<BulkMoveResult> {
const result = await moveMessages(selection.checkedMessages.value, targetId)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
/**
* 휴지통에서 영구 삭제 (POST /message-deletions) — 확인 대화상자를 거친 뒤에만 부른다.
* 지운 메일(deletedIds)만 목록에서 빼고 개수 · 사용량을 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
* (503 은 일부를 지운 뒤일 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다
*/
async function purgeMessages(ids: readonly string[]): Promise<MailBulkResult> {
if (ids.length === 0) return { ...emptyBulkResult(), stale: false }
const isStale = captureList()
const result = await sendBulk(ids, async (chunkIds) => {
const deleted = await mailApi.deleteMessages({ ids: chunkIds })
return { done: deleted.deletedIds, skipped: deleted.skippedIds }
})
const stale = isStale()
if (stale) {
settleStale()
} else {
list.removeFromList(new Set(result.doneIds))
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, stale }
}
/** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 */
async function purgeChecked(): Promise<MailBulkResult> {
const result = await purgeMessages(selection.checkedMessages.value.map((message) => message.id))
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
/**
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다
*/
async function restoreMoved(origins: readonly BulkOrigin[]): Promise<BulkResult> {
const total = emptyBulkResult()
for (const [index, group] of origins.entries()) {
const result = await sendBulk(group.ids, async (chunkIds) => {
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId })
return { done: patched.updatedIds, skipped: patched.failedIds }
})
total.doneIds.push(...result.doneIds)
total.skippedIds.push(...result.skippedIds)
total.unsentIds.push(...result.unsentIds)
if (result.unsentIds.length > 0) {
total.unsentIds.push(...origins.slice(index + 1).flatMap((rest) => rest.ids))
break
}
}
mailboxes.syncCounts()
await list.syncVisibleList({ includeSearch: true })
return total
}
return { patchChecked, moveMessages, moveChecked, purgeMessages, purgeChecked, restoreMoved }
}
+363
View File
@@ -0,0 +1,363 @@
// 메일 스토어 모듈 — 보이는 목록(메일함 또는 검색 결과): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것 (mail.store.ts 가 조립한다)
//
// 늦게 온 응답 막기:
// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다
// - sourceSeq: 목록의 출처(메일함 · 검색)가 바뀔 때마다 — 고른 것을 풀고(selection.ts), 끝난 여러 통 처리가
// 아직 같은 목록인지 본다(bulk.ts — 다른 목록이면 그 목록을 건드리지 않는다)
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
// 그 전에 떠난 새 메일 확인의 응답은 옛 상태라 버리고 한 번 더 맞춘다 (옮긴 메일이 되살아나거나 표시가 되돌아가지 않게)
import { computed, ref, watch } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
import {
listQueryOf,
sameListSource,
scopeMailboxIdOf,
type MailListSource,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
import type { MailboxesState } from './mailboxes'
import type { OpenedMessage } from './message'
import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
/** 목록 · 열린 메일에 바로 적는 표시 */
type LocalChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
interface ListDeps {
mailApi: MailApi
mailboxes: MailboxesState
opened: OpenedMessage
}
export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/** 목록의 출처 — 메일함 하나 또는 검색 결과 (mail-list-source.ts) */
const listSource = ref<MailListSource | null>(null)
/** 목록의 출처가 바뀐 횟수 (위 설명) */
const sourceSeq = ref<number>(0)
/**
* 목록이 가리키는 메일함 — 메일함 목록이면 그 메일함, 한 메일함 안의 검색이면 그 메일함, 전체 메일함 검색이면 null.
* 메일함 패널의 현재 표시 · 휴지통 판단이 이 값을 본다. 값을 넣으면 그 메일함 목록을 출처로 둔다 (불러오지는 않는다)
*/
const currentMailboxId = computed<string | null>({
get: () => scopeMailboxIdOf(listSource.value),
set: (id) => {
listSource.value = id ? { kind: 'mailbox', mailboxId: id } : null
},
})
const currentMailbox = computed<Mailbox | null>(
() => mailboxes.mailboxes.value.find((mailbox) => mailbox.id === currentMailboxId.value) ?? null,
)
/** 검색 결과를 보이는 중이면 그 검색 */
const search = computed<SearchListSource | null>(() =>
listSource.value?.kind === 'search' ? listSource.value : null,
)
const searchOrigin = ref<SearchOrigin | null>(null)
const messages = ref<MessageSummary[]>([])
const pageInfo = ref<PageInfo | null>(null)
const listStatus = ref<LoadStatus>('idle')
const listError = ref<ApiError | null>(null)
/** 목록을 보이는 채로 다시 불러오는 중 (새로고침) */
const isRefreshing = ref<boolean>(false)
const isLoadingMore = ref<boolean>(false)
/** 새 메일 확인으로 목록을 맞출 때마다 바뀐다 — 화면이 "새 메일 N통 · 맨 위로" 알림 줄을 정한다 */
const listSync = ref<{ seq: number; addedAbove: number } | null>(null)
const hasMore = computed<boolean>(
() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor),
)
let listSeq = 0
let listSyncSeq = 0
let localGeneration = 0
/**
* 보내는 중인 읽음 · 중요 표시 — 그사이 도착한 목록(새 메일 확인)이 옛 값으로 되돌려 보이지 않게 덮어쓴다.
* 화면에 그리지 않으므로 반응형이 아니다
*/
const pendingFlags = new Map<string, FlagChanges>()
// 출처가 바뀌면(어떻게 바꿨든) 그 수를 세고 알림 줄 정보를 비운다
watch(
listSource,
(next, previous) => {
if (sameListSource(next, previous)) return
sourceSeq.value += 1
listSync.value = null
},
{ flush: 'sync' },
)
/** 같은 출처를 새 목록으로 다시 연다 (같은 검색어로 다시 찾기) — 출처가 바뀐 것과 같게 다룬다 */
function renewSource() {
sourceSeq.value += 1
listSync.value = null
}
// ── 이 탭이 바꾼 것 ─────────────────────────────────
function bumpLocalChange() {
localGeneration += 1
}
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 }
bumpLocalChange()
}
/** 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 */
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
bumpLocalChange()
}
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 */
function removeFromList(ids: ReadonlySet<string>) {
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
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<string>, mailboxId: string) {
if (ids.size === 0) return
messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, mailboxIds: [mailboxId] } : message))
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, mailboxIds: [mailboxId] }
bumpLocalChange()
}
function beginPending(ids: readonly string[], changes: FlagChanges) {
for (const id of ids) pendingFlags.set(id, { ...pendingFlags.get(id), ...changes })
}
/** 보내던 표시가 끝났다(성공이든 실패든) — 그 전에 떠난 새 메일 확인은 옛 값을 들고 온다 */
function endPending(ids: readonly string[], changes: FlagChanges) {
for (const id of ids) {
const pending = pendingFlags.get(id)
if (!pending) continue
const rest: FlagChanges = { ...pending }
if ('isRead' in changes) delete rest.isRead
if ('isFlagged' in changes) delete rest.isFlagged
if (Object.keys(rest).length > 0) pendingFlags.set(id, rest)
else pendingFlags.delete(id)
}
bumpLocalChange()
}
/** 보내는 중인 읽음 · 중요 표시를 덮어쓴다 */
function withPendingFlags(items: readonly MessageSummary[]): MessageSummary[] {
if (pendingFlags.size === 0) return [...items]
return items.map((item) => {
const pending = pendingFlags.get(item.id)
return pending ? { ...item, ...pending } : item
})
}
/** 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함)를 맞춘다 */
function refreshSelectedFrom(list: readonly MessageSummary[]) {
const current = opened.selected.value
if (!current) return
const fresh = list.find((message) => message.id === current.id)
if (!fresh) return
const changed =
fresh.isRead !== current.isRead ||
fresh.isFlagged !== current.isFlagged ||
fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n')
if (changed) {
opened.selected.value = { ...current, isRead: fresh.isRead, isFlagged: fresh.isFlagged, mailboxIds: fresh.mailboxIds }
}
}
/** 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null) */
function neighborAfterRemoval(id: string): string | null {
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
}
// ── 목록 열기 · 맞추기 ───────────────────────────────
/**
* 목록 열기 — 첫 페이지를 불러온다. 다른 목록이면 고른 것이 풀린다 (메일함을 바꾸거나 검색하면 선택을 푼다).
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
*/
async function openList(next: MailListSource): Promise<void> {
const keepVisible = sameListSource(listSource.value, next) && listStatus.value === 'ready'
listSource.value = next
listSeq += 1
const seq = listSeq
if (keepVisible) {
isRefreshing.value = true
} else {
messages.value = []
pageInfo.value = null
listStatus.value = 'loading'
listError.value = null
}
try {
const page = await mailApi.listMessages(
{ ...listQueryOf(next), limit: DEFAULT_PAGE_SIZE },
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
{ silent: keepVisible ? undefined : true },
)
if (seq !== listSeq) return
messages.value = page.items
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
listError.value = null
} catch (error) {
if (seq !== listSeq || keepVisible) return
listStatus.value = 'error'
listError.value = toApiError(error)
} finally {
if (seq === listSeq) isRefreshing.value = false
}
}
/** 메일함 열기 — 같은 메일함으로 돌아오면 보이던 목록을 둔 채 새로 고친다 */
async function openMailbox(mailboxId: string): Promise<void> {
await openList({ kind: 'mailbox', mailboxId })
}
/**
* 검색 결과 열기 (계약 2절 "검색") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL).
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
* force 면(같은 검색어로 다시 Enter) 보이는 채로 다시 찾는다 — 다시 찾는 것도 검색이라 고른 것을 푼다
*/
async function openSearch(q: string, scope: string, options: { force?: boolean } = {}): Promise<void> {
const next: SearchListSource = { kind: 'search', q, scope }
const shown = sameListSource(listSource.value, next)
if (shown && !options.force && (listStatus.value === 'ready' || listStatus.value === 'loading')) return
if (shown && options.force) renewSource()
await openList(next)
}
/** 검색을 시작한 곳을 적어 둔다 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
function rememberSearchOrigin(origin: SearchOrigin) {
searchOrigin.value = origin
}
/** 새로고침 — 메일함 개수 · 사용량 · 지금 목록을 함께 다시 불러온다 */
async function refresh(): Promise<void> {
mailboxes.syncCounts()
if (listSource.value) await openList(listSource.value)
}
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
async function loadMore(): Promise<void> {
const cursor = pageInfo.value?.nextCursor
const source = listSource.value
if (!cursor || !source || isLoadingMore.value) return
const seq = listSeq
isLoadingMore.value = true
try {
const page = await mailApi.listMessages({ ...listQueryOf(source), cursor, limit: DEFAULT_PAGE_SIZE })
if (seq !== listSeq) return
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
const known = new Set(messages.value.map((message) => message.id))
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
pageInfo.value = page.pageInfo
} catch {
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
} finally {
isLoadingMore.value = false
}
}
/**
* 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 (계약 2절 "새 메일 확인") —
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다
*/
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise<void> {
const source = listSource.value
if (!source || listStatus.value !== 'ready') return
if (source.kind === 'search' && !options.includeSearch) return
const seq = listSeq
const generation = localGeneration
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
try {
const page = await mailApi.listMessages({ ...listQueryOf(source), limit }, { silent: true })
if (seq !== listSeq || listStatus.value !== 'ready') return
if (generation !== localGeneration) {
if (attempt === 0) await syncVisibleList(options, attempt + 1)
return
}
const merged = mergeRefreshedList({
current: messages.value,
currentPageInfo: pageInfo.value,
fetched: withPendingFlags(page.items),
fetchedPageInfo: page.pageInfo,
})
messages.value = merged.messages
pageInfo.value = merged.pageInfo
refreshSelectedFrom(merged.messages)
listSyncSeq += 1
listSync.value = { seq: listSyncSeq, addedAbove: merged.addedAbove }
} catch {
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
}
}
function reset() {
listSeq += 1
listSource.value = null
searchOrigin.value = null
messages.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
isRefreshing.value = false
isLoadingMore.value = false
listSync.value = null
pendingFlags.clear()
}
return {
listSource,
sourceSeq,
currentMailboxId,
currentMailbox,
search,
searchOrigin,
messages,
pageInfo,
listStatus,
listError,
isRefreshing,
isLoadingMore,
listSync,
hasMore,
setLocalFlags,
applySummary,
removeFromList,
relocate,
beginPending,
endPending,
neighborAfterRemoval,
openList,
openMailbox,
openSearch,
rememberSearchOrigin,
refresh,
loadMore,
syncVisibleList,
reset,
}
}
export type ListState = ReturnType<typeof createListState>
+178
View File
@@ -0,0 +1,178 @@
// 메일 스토어 모듈 — 메일함 목록 · 사용량 · 첨부 한도 · 안 읽은 개수 (mail.store.ts 가 조립한다)
import { computed, ref } from 'vue'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import {
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
} from '@/shared/constants/mail'
import type { MailQuota, Mailbox, MailboxRole } from '@/shared/types/mail'
import { findMailboxByRole, orderMailboxes } from '@/shared/utils/mailbox'
import type { LoadStatus, MailApi } from './types'
/** 서버가 준 한도가 양수면 그 값, 없거나 이상하면 대체값 */
function positiveOr(value: number | undefined, fallback: number): number {
return typeof value === 'number' && value > 0 ? value : fallback
}
export function createMailboxesState(mailApi: MailApi) {
const mailboxes = ref<Mailbox[]>([])
const mailboxesStatus = ref<LoadStatus>('idle')
const mailboxesError = ref<ApiError | null>(null)
/** GET /mail-quota */
const quota = ref<MailQuota | null>(null)
let mailboxSeq = 0
let quotaSeq = 0
/** 응답을 기다리는 메일함 목록 요청 수 — 그 응답이 화면에서 바꾼 개수를 덮어쓸 수 있는지 판단한다 */
let mailboxLoadsInFlight = 0
/**
* 첨부 파일당 한도(바이트) — 서버 값(attachmentMaxBytes).
* 사용량을 아직 못 받았거나 값이 이상하면 대체값(20MB)으로 사전 검사·안내한다.
*/
const attachmentMaxBytes = computed<number>(() =>
positiveOr(quota.value?.attachmentMaxBytes, MAIL_ATTACHMENT_FALLBACK_MAX_BYTES),
)
/**
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(attachmentTotalMaxBytes). 발송 때 서버가 검사하는 값과 같다.
* 못 받았거나(이 값을 아직 주지 않는 서버 포함) 값이 이상하면 대체값(25MB)을 쓴다.
*/
const attachmentTotalMaxBytes = computed<number>(() =>
positiveOr(quota.value?.attachmentTotalMaxBytes, MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES),
)
const orderedMailboxes = computed(() => orderMailboxes(mailboxes.value))
/** /mail 로 들어왔을 때 열 메일함 — 받은편지함, 없으면 첫 메일함 */
const defaultMailbox = computed<Mailbox | null>(
() => findMailboxByRole(mailboxes.value, 'inbox') ?? orderedMailboxes.value[0]?.mailbox ?? null,
)
/** 탭 제목의 "(N) " — 받은편지함의 안 읽은 메일 수 */
const inboxUnreadCount = computed<number>(() => findMailboxByRole(mailboxes.value, 'inbox')?.unreadCount ?? 0)
function mailboxByRole(role: MailboxRole): Mailbox | null {
return findMailboxByRole(mailboxes.value, role)
}
/**
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
*/
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
mailboxSeq += 1
const seq = mailboxSeq
if (!options.background) {
mailboxesStatus.value = 'loading'
mailboxesError.value = null
}
mailboxLoadsInFlight += 1
try {
const result = await mailApi.listMailboxes({ silent: true })
if (seq !== mailboxSeq) return null
mailboxes.value = result.items
mailboxesStatus.value = 'ready'
mailboxesError.value = null
return null
} catch (error) {
const apiError = toApiError(error)
if (seq !== mailboxSeq) return apiError
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
if (options.background && mailboxesStatus.value === 'ready') return apiError
mailboxesStatus.value = 'error'
mailboxesError.value = apiError
return apiError
} finally {
mailboxLoadsInFlight -= 1
}
}
/** 지금 보이는 개수가 곧 도착할(또는 아직 없는) 메일함 응답에 덮어쓰일 수 있는지 */
function mailboxCountsMayBeOverwritten(): boolean {
return mailboxLoadsInFlight > 0 || mailboxesStatus.value !== 'ready'
}
/** 아직 불러오지 않았거나 실패했을 때만 불러온다 */
async function ensureMailboxes(): Promise<void> {
if (mailboxesStatus.value === 'idle' || mailboxesStatus.value === 'error') {
await loadMailboxes()
}
}
/**
* 역할의 메일함(휴지통 등) — 아직 모르면 보이는 패널은 그대로 둔 채 메일함 목록을 받아 본다.
* 그래도 모르면 오류를 돌려준다(알리지 않았다): 메일함 목록을 못 받았으면 그 오류, 받았는데 그 메일함이 없으면 BIZ_001
*/
async function resolveMailboxByRole(role: MailboxRole): Promise<{ mailbox: Mailbox } | { error: ApiError }> {
const known = mailboxByRole(role)
if (known) return { mailbox: known }
const loadError = await loadMailboxes({ background: true })
const loaded = mailboxByRole(role)
if (loaded) return { mailbox: loaded }
return { error: loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') } }
}
/** 사용량 · 첨부 한도 — 실패해도 알리지 않고 이전 값을 둔다 (보조 정보) */
async function loadQuota(): Promise<void> {
quotaSeq += 1
const seq = quotaSeq
try {
const result = await mailApi.getQuota({ silent: true })
if (seq === quotaSeq) quota.value = result
} catch {
// 못 받으면 패널 하단 사용량을 숨기고, 첨부 한도는 대체값을 쓴다
}
}
async function ensureQuota(): Promise<void> {
if (!quota.value) await loadQuota()
}
/** 보내기·삭제·새로고침·새 메일 확인 뒤 — 메일함 개수와 사용량을 서버 기준으로 다시 맞춘다 (화면은 그대로 둔 채) */
function syncCounts() {
void loadMailboxes({ background: true })
void loadQuota()
}
/** 안 읽은 개수를 화면에서 먼저 바꾼다 (읽음 처리 — 서버 기준은 다음 메일함 조회가 맞춘다) */
function adjustUnread(mailboxIds: readonly string[], delta: number) {
mailboxes.value = mailboxes.value.map((mailbox) =>
mailboxIds.includes(mailbox.id)
? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount + delta) }
: mailbox,
)
}
function reset() {
mailboxSeq += 1
quotaSeq += 1
mailboxes.value = []
mailboxesStatus.value = 'idle'
mailboxesError.value = null
quota.value = null
}
return {
mailboxes,
mailboxesStatus,
mailboxesError,
quota,
attachmentMaxBytes,
attachmentTotalMaxBytes,
orderedMailboxes,
defaultMailbox,
inboxUnreadCount,
mailboxByRole,
loadMailboxes,
mailboxCountsMayBeOverwritten,
ensureMailboxes,
resolveMailboxByRole,
loadQuota,
ensureQuota,
syncCounts,
adjustUnread,
reset,
}
}
export type MailboxesState = ReturnType<typeof createMailboxesState>
+135
View File
@@ -0,0 +1,135 @@
// 메일 스토어 모듈 — 열린 메일(읽기 화면)과 그 동작: 열기 · 읽음 처리 · 중요 표시 · 다른 메일함으로 옮기기 (mail.store.ts 가 조립한다)
// "열린 메일"(selectedId)은 여러 통 처리의 "고른 메일"(checkedIds — selection.ts)과 다르다.
import { ref } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MessageDetail } from '@/shared/types/mail'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { FlagChanges, LoadStatus, MailApi } from './types'
/** 열린 메일의 상태 — 목록 맞추기(list.ts)와 읽기 화면의 동작(아래)이 함께 고친다 */
export function createOpenedMessage() {
const selectedId = ref<string | null>(null)
const selected = ref<MessageDetail | null>(null)
const selectedStatus = ref<LoadStatus>('idle')
const selectedError = ref<ApiError | null>(null)
/** 메일을 빠르게 바꿀 때 늦게 온 본문 응답이 덮어쓰지 않게 */
let detailSeq = 0
function nextDetailSeq(): number {
detailSeq += 1
return detailSeq
}
function isCurrentDetail(seq: number): boolean {
return seq === detailSeq
}
function closeMessage() {
detailSeq += 1
selectedId.value = null
selected.value = null
selectedStatus.value = 'idle'
selectedError.value = null
}
return { selectedId, selected, selectedStatus, selectedError, nextDetailSeq, isCurrentDetail, closeMessage }
}
export type OpenedMessage = ReturnType<typeof createOpenedMessage>
interface MessageActionsDeps {
mailApi: MailApi
mailboxes: MailboxesState
list: ListState
opened: OpenedMessage
}
export function createMessageActions({ mailApi, mailboxes, list, opened }: MessageActionsDeps) {
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function markRead(message: Pick<MessageDetail, 'id' | 'isRead' | 'mailboxIds'>) {
if (message.isRead) return
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
const resync = mailboxes.mailboxCountsMayBeOverwritten()
const changes: FlagChanges = { isRead: true }
list.setLocalFlags(message.id, changes)
mailboxes.adjustUnread(message.mailboxIds, -1)
list.beginPending([message.id], changes)
try {
const summary = await mailApi.updateMessage(message.id, changes)
list.endPending([message.id], changes)
list.applySummary(summary)
if (resync || mailboxes.mailboxCountsMayBeOverwritten()) void mailboxes.loadMailboxes({ background: true })
} catch {
list.endPending([message.id], changes)
list.setLocalFlags(message.id, { isRead: false })
mailboxes.adjustUnread(message.mailboxIds, 1)
}
}
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
async function openMessage(id: string): Promise<void> {
opened.selectedId.value = id
if (opened.selected.value?.id === id && opened.selectedStatus.value === 'ready') return
const seq = opened.nextDetailSeq()
opened.selected.value = null
opened.selectedStatus.value = 'loading'
opened.selectedError.value = null
try {
const detail = await mailApi.getMessage(id, { silent: true })
if (!opened.isCurrentDetail(seq)) return
opened.selected.value = detail
opened.selectedStatus.value = 'ready'
if (!detail.isRead) void markRead(detail)
} catch (error) {
if (!opened.isCurrentDetail(seq)) return
opened.selectedStatus.value = 'error'
opened.selectedError.value = toApiError(error)
}
}
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function toggleFlag(id: string): Promise<void> {
const current =
list.messages.value.find((message) => message.id === id) ??
(opened.selected.value?.id === id ? opened.selected.value : null)
if (!current) return
const changes: FlagChanges = { isFlagged: !current.isFlagged }
list.setLocalFlags(id, changes)
list.beginPending([id], changes)
try {
const summary = await mailApi.updateMessage(id, changes)
list.endPending([id], changes)
list.applySummary(summary)
} catch {
list.endPending([id], changes)
list.setLocalFlags(id, { isFlagged: current.isFlagged })
}
}
/**
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 메일함이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
* 실패하면 false (알림은 인터셉터가 띄웠다).
*/
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
try {
await mailApi.updateMessage(id, { mailboxId })
} catch {
return false
}
mailboxes.syncCounts()
const source = list.listSource.value
const trashId = mailboxes.mailboxByRole('trash')?.id ?? null
if (source?.kind === 'mailbox' && (source.mailboxId === mailboxId || source.mailboxId === trashId)) {
void list.openList(source)
}
return true
}
return { markRead, openMessage, toggleFlag, moveMessage }
}
+81
View File
@@ -0,0 +1,81 @@
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
import { computed, shallowRef, watch } from 'vue'
import type { MessageSummary } from '@/shared/types/mail'
import {
checkAllState,
checkRange,
pruneChecked,
toggleChecked,
type CheckAllState,
} from '@/shared/utils/mail-selection'
import type { ListState } from './list'
export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>) {
/** 고른 메일 id — 바꿀 때는 새 모음으로 바꿔 끼운다 */
const checkedIds = shallowRef<ReadonlySet<string>>(new Set())
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
let checkAnchorId: string | null = null
const checkedCount = computed<number>(() => checkedIds.value.size)
/** 고른 메일 — 목록 순서 그대로 */
const checkedMessages = computed<MessageSummary[]>(() =>
checkedIds.value.size === 0 ? [] : list.messages.value.filter((message) => checkedIds.value.has(message.id)),
)
/** 표 머리 고르기 상자의 상태 */
const checkState = computed<CheckAllState>(() =>
checkAllState(
checkedIds.value,
list.messages.value.map((message) => message.id),
),
)
function visibleIds(): string[] {
return list.messages.value.map((message) => message.id)
}
function clearChecked() {
if (checkedIds.value.size > 0) checkedIds.value = new Set()
checkAnchorId = null
}
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭 */
function toggleCheck(id: string) {
checkedIds.value = toggleChecked(checkedIds.value, id)
checkAnchorId = id
}
/** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 */
function checkRangeTo(id: string) {
checkedIds.value = checkRange(checkedIds.value, visibleIds(), checkAnchorId, id)
checkAnchorId = id
}
/** 표 머리 상자 — 불러온 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
function toggleCheckAll() {
if (checkState.value === 'all') clearChecked()
else checkedIds.value = new Set(visibleIds())
}
// 목록에서 빠진 메일은 고른 것에서도 뺀다
watch(
list.messages,
(messages) => {
const next = pruneChecked(
checkedIds.value,
messages.map((message) => message.id),
)
if (next !== checkedIds.value) checkedIds.value = next
},
{ flush: 'sync' },
)
// 다른 목록으로 바뀌면 모두 푼다
watch(list.sourceSeq, clearChecked, { flush: 'sync' })
return { checkedIds, checkedCount, checkedMessages, checkState, clearChecked, toggleCheck, checkRangeTo, toggleCheckAll }
}
export type SelectionState = ReturnType<typeof createSelection>
+32
View File
@@ -0,0 +1,32 @@
// 메일 스토어(mail.store.ts)를 이루는 모듈이 함께 쓰는 타입
import type { useMail } from '@/composables/useMail'
import type { MessageSummary } from '@/shared/types/mail'
import type { BulkOrigin, BulkResult } from '@/shared/utils/mail-bulk'
export type MailApi = ReturnType<typeof useMail>
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
/** 읽음 · 중요 표시만 바꾸는 변경 */
export type FlagChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged'>>
/** 여러 통 처리(한 통 삭제 포함)의 결과 */
export interface MailBulkResult extends BulkResult {
/**
* 끝났을 때 보이는 목록이 다른 목록(다른 메일함 · 검색)으로 바뀌어 있었다 — 그 목록을 직접 고치지 않았고
* 개수와 (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞췄다. 부르는 쪽도 목록에 기댄 일(고른 것 풀기 · 다음 메일로 넘어가기)을 하지 않는다
*/
stale: boolean
}
export interface BulkMoveResult extends MailBulkResult {
/** 되돌리기 — 원래 메일함별로 묶은 메일 */
origins: BulkOrigin[]
}
/** 검색을 시작한 곳 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
export interface SearchOrigin {
mailboxId: string | null
/** 돌아갈 화면의 fullPath — 모르면(주소로 바로 들어옴) null */
location: string | null
}
+40
View File
@@ -1,9 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import ToastHost from '@/components/feedback/ToastHost.vue'
import { useToast } from '@/composables/useToast'
import { createTestRouter } from '@/test-utils/router'
import { useAuthStore } from './auth.store'
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
@@ -81,4 +84,41 @@ describe('toast.store', () => {
expect(messages()).toEqual([])
wrapper.unmount()
})
describe('세션이 바뀌면 — 이전 세션의 되돌리기를 누를 수 없다', () => {
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
it.each([
['로그아웃', () => useAuthStore().clearAuth()],
['세션 만료', () => useAuthStore().expireSession()],
['다른 계정 로그인', () => useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })],
])('%s — 단추가 있는 알림은 닫고, 단추 없는 알림은 둔다', async (_label, endSession) => {
useAuthStore().startSession(USER)
const store = useToastStore()
store.show({ kind: 'success', message: '옮겼습니다', action: undo })
store.show({ kind: 'success', message: '보냈습니다' })
endSession()
await nextTick()
expect(messages()).toEqual(['보냈습니다'])
})
it('useToast 의 단추는 띄운 세션에서만 움직인다 — 그사이 세션이 바뀌었으면 눌러도 아무것도 하지 않는다', async () => {
useAuthStore().startSession(USER)
const run = vi.fn()
useToast().success('메일 1통을 휴지통으로 옮겼습니다.', { label: '되돌리기', run })
const action = useToastStore().toasts[0]?.action
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
expect(run).toHaveBeenCalledTimes(1)
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
await nextTick()
action.run()
expect(run).toHaveBeenCalledTimes(1)
})
})
})
+13 -1
View File
@@ -1,11 +1,15 @@
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import type { RouteLocationRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
export type ToastKind = 'error' | 'success'
@@ -126,5 +130,13 @@ export const useToastStore = defineStore('toast', () => {
toasts.value = []
}
/** 단추가 있는 알림을 닫는다 — 세션이 바뀌었을 때 */
function dismissActions() {
toasts.value.filter((toast) => toast.action !== null).forEach((toast) => dismiss(toast.id))
}
const authStore = useAuthStore()
watch(() => authStore.generation, dismissActions)
return { toasts, show, dismiss, hold, release, clear }
})
+21 -3
View File
@@ -43,6 +43,7 @@
--color-brand: #0e7c86; /* 강조 버튼 · 링크 · 진행률 */
--color-brand-strong: #0a5f67; /* 강조 글자 · 눌린 상태 */
--color-brand-subtle: #e3f2f3; /* 선택한 행 · 현재 메일함 · 아바타 */
--color-brand-tint: #f3f8f8; /* 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 (디자인 원본 BulkSelect · ListStates) */
--color-on-brand: #ffffff;
--color-on-brand-text: #e6eff0;
--color-on-brand-muted: #a3c2c6;
@@ -63,6 +64,8 @@
--color-surface-muted: #f2f4f7; /* 받는 사람 토큰 · 파일 아이콘 */
--color-surface-sunken: #fbfbfc; /* 목록 표 머리 줄 */
--color-row-hover: #f3f8f8; /* 목록 표 행 hover */
--color-row-checked: #e8f4f5; /* 고른 행 (여러 통 처리) */
--color-row-checked-hover: #ddeff0; /* 고른 행에 마우스를 올렸을 때 */
--color-border: #e6e8ec;
--color-border-subtle: #eef0f3;
--color-border-strong: #d0d5dd; /* 보기 전환 세그먼트 테두리 */
@@ -71,6 +74,7 @@
/* ── 색: 상태 ───────────────────────────── */
--color-danger: #b42318;
--color-danger-strong: #912018; /* 영구 삭제 단추에 마우스를 올렸을 때 */
--color-danger-surface: #fef3f2;
--color-danger-border: #fbd7d3;
--color-warning-surface: #fff7e6;
@@ -100,6 +104,7 @@
--space-44: 2.75rem;
--space-48: 3rem;
--space-56: 3.5rem;
--space-72: 4.5rem;
--space-80: 5rem;
/* ── 모서리 ─────────────────────────────── */
@@ -131,7 +136,18 @@
--size-compact-control: 2rem; /* 32px */
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
--size-segment-item-lg: 2.25rem; /* 36px — 밀도 예외: 설정 화면의 전환 단추(그림 크기 · 미리 볼 메일) · 그림 빼기 (디자인 원본 Settings) */
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 · 여러 통 처리 도구(아이콘만일 때) 버튼 폭 (높이는 44px) */
--size-search-box: 2.5rem; /* 40px — 상단 검색 칸 높이 */
/*
* 3단계(새 메일 · 검색 · 여러 통 처리) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "3단계", 사용자 확정 캔버스).
* 목록 표의 고르기 칸 32(--table-col-check — 별 칸과 같다) · 분할 보기 행의 고르기 칸 폭 36 ·
* 검색어 지우기 40×38 · 새 메일 알림 줄 높이 40 · 검색 범위 전환 36(--size-segment-item-lg) · 이동 메뉴 항목 40(--size-editor-menu-item)
*/
--size-row-check: 2.25rem; /* 36px — 분할 보기 행 왼쪽의 고르기 칸 폭 · 밀도 예외 */
--size-search-clear-width: 2.5rem; /* 40px — 검색어 지우기(✕) 폭 · 밀도 예외 */
--size-search-clear-height: 2.375rem; /* 38px — 검색 칸(40) 안쪽 높이를 채운다 · 밀도 예외 */
--size-notice-bar: 2.5rem; /* 40px — "새 메일 N통 · 맨 위로" 줄 높이 · 밀도 예외 */
--size-checkbox: 1rem; /* 16px — 고르기 상자 */
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
--size-rail-item: 3.75rem; /* 60px */
--size-row-min: 2.5rem; /* 40px — 메일함 항목 */
@@ -143,13 +159,15 @@
--size-table-head: 2rem; /* 32px — 목록 보기 표 머리 줄 */
--size-table-row: 2.375rem; /* 38px — 목록 보기 표 행 */
--table-gap: 0.75rem; /* 12px — 표 칸 사이 */
--table-col-check: 2rem; /* 32px — 고르기 칸 · 밀도 예외 (별 칸과 같다) */
--table-col-star: 2rem; /* 32px */
--table-col-mailbox: 6rem; /* 96px — 전체 메일함 검색 결과의 메일함 칸 (보낸 사람 뒤) */
--table-col-who: 10.5rem; /* 168px */
--table-col-who-narrow: 7rem; /* 112px — 600px 미만 */
--table-col-date: 5.25rem; /* 84px */
--table-col-date-narrow: 4.5rem; /* 72px — 600px 미만 */
--table-col-size: 4rem; /* 64px */
--table-min-width: 25rem; /* 400px — 이보다 좁으면 가로 스크롤 */
--table-min-width: 27.5rem; /* 440px — 이보다 좁으면 가로 스크롤 (고르기 칸 포함, 디자인 원본 BulkSelect) */
/*
* ── 콘텐츠 열 (읽기 · 쓰기 공통) ─────────────
@@ -172,7 +190,7 @@
* 툴바 아이콘 버튼 40×44 · 메뉴 항목 높이 40 · 색 견본 칸 40×40 · 표 고르기 칸 22(간격 3, 키보드는 화살표)
*/
--size-editor-tool: 2.5rem; /* 40px — 툴바 아이콘 버튼 폭 (높이는 --size-touch 44px) · 밀도 예외 */
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 */
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 (여러 통 처리의 이동 메뉴도 같은 메뉴를 쓴다) */
--size-editor-swatch-cell: 2.5rem; /* 40px — 색 견본 칸 · 밀도 예외 */
--size-editor-table-cell: 1.375rem; /* 22px — 표 고르기 격자 칸 · 밀도 예외 */
--editor-table-cell-gap: 0.1875rem; /* 3px */
+7
View File
@@ -21,6 +21,13 @@ export function createTestRouter(): Router {
{ path: '/login', name: 'login', component: Empty },
{ path: '/mail', name: 'mail', component: Empty, meta: { requiresAuth: true } },
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { requiresAuth: true } },
{ path: '/mail/search', name: 'mail-search', component: Empty, meta: { requiresAuth: true } },
{
path: '/mail/search/:messageId',
name: 'mail-search-message',
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { requiresAuth: true } },
{
path: '/mail/:mailboxId/:messageId',