feat(frontend): 전역 셸·로그인·세션 처리와 웹메일 화면

확정된 디자인(docs/design/webmail)을 기준으로 만든 웹메일 화면이다.

- 앞으로 붙일 사내 모듈(캘린더·파일·ERP 등)을 위한 전역 앱 레일과 셸,
  Pretendard 자체 호스팅, 바다 청록 브랜드 토큰과 콘텐츠 열 토큰
- 로그인과 세션 처리: 401 이면 한 번 갱신한 뒤 다시 보내고, 실패하면 세션 만료 대화상자를 띄운다.
  로그아웃은 다른 탭에도 알리고, 로그아웃 뒤에 늦게 온 갱신 결과는 버린다
- 메일함(분할·목록 보기), 읽기(외부 HTML 은 sandbox iframe 으로만 그린다),
  쓰기(발송 키 규칙, 첨부 형식·크기·합계·개수 사전 검사, 답장·전달 초안 길이 맞춤)
- 오류는 코드로만 분기하고, 문구는 생성된 에러 사전에서 가져온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 01:31:59 +09:00
parent 71f2e01d83
commit 468a4377ee
104 changed files with 14136 additions and 747 deletions
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
// 선 아이콘 — 도형 목록은 shared/constants/icons.ts 에 있다.
// 장식용이면 스크린리더에서 숨기고, 의미가 있으면 label 을 넘겨 이름을 읽게 한다.
// 사용 예: <AppIcon name="paperclip" :size="13" label="첨부 있음" />
import { computed } from 'vue'
import { ICONS, REM_BASE_PX, type IconName } from '@/shared/constants/icons'
interface Props {
name: IconName
/** 한 변의 크기 (디자인 원본의 px 값) — rem 으로 바꿔 그린다 */
size?: number
strokeWidth?: number
/** 안을 채운다 (중요 표시 별) */
filled?: boolean
/** 의미가 있는 아이콘이면 읽어 줄 이름. 비우면 장식으로 숨긴다 */
label?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 16,
strokeWidth: 1.75,
filled: false,
label: '',
})
const dimension = computed(() => `${props.size / REM_BASE_PX}rem`)
const shapes = computed(() => ICONS[props.name])
</script>
<template>
<svg
class="app-icon"
:width="dimension"
:height="dimension"
viewBox="0 0 24 24"
:fill="filled ? 'currentColor' : 'none'"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
:role="label ? 'img' : undefined"
:aria-label="label || undefined"
:aria-hidden="label ? undefined : 'true'"
>
<component
:is="shape.tag"
v-for="(shape, index) in shapes"
:key="index"
v-bind="shape.attrs"
/>
</svg>
</template>
<style scoped>
.app-icon {
flex: 0 0 auto;
display: block;
}
</style>
+14 -8
View File
@@ -50,12 +50,14 @@ function handleClick(event: MouseEvent) {
</template>
<style scoped>
/* 모든 치수는 rem 기준 (16px = 1rem) */
/* 색·간격·글자 크기는 styles/tokens.css 의 변수만 쓴다. 다른 치수는 rem 기준 (16px = 1rem) */
.base-button {
padding: 0.5rem 1rem;
font-size: 1rem;
min-height: var(--size-touch);
padding: 0 var(--space-16);
font-size: var(--font-size-emphasis);
font-weight: 600;
border: none;
border-radius: 0.375rem;
border-radius: var(--radius-md);
cursor: pointer;
}
@@ -65,12 +67,16 @@ function handleClick(event: MouseEvent) {
}
.base-button--primary {
background-color: #4f46e5;
color: #ffffff;
background-color: var(--color-brand);
color: var(--color-on-brand);
}
.base-button--primary:hover:not(:disabled) {
background-color: var(--color-brand-strong);
}
.base-button--secondary {
background-color: #e5e7eb;
color: #111827;
background-color: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } 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 { useAuthStore } from '@/stores/auth.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import SessionExpiredDialog from './SessionExpiredDialog.vue'
describe('SessionExpiredDialog', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
it('세션이 만료되면 사전 문구(AUTH_002)로 열리고, 확인하면 로그인 화면으로 보낸다 (돌아올 주소 포함)', async () => {
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
const wrapper = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
const authStore = useAuthStore()
authStore.markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
authStore.expireSession()
await flushPromises()
const dialog = wrapper.get('dialog')
expect(dialog.attributes('open')).toBeDefined()
expect(dialog.text()).toContain(ERROR_CODE_MESSAGES.AUTH_002)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(authStore.sessionExpired).toBe(false)
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/mail/inbox/m1')
})
})
@@ -0,0 +1,136 @@
<script setup lang="ts">
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getErrorMessage } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth.store'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 문구는 에러 코드 사전에서만 온다 */
const message = getErrorMessage('AUTH_002')
function open() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
}
function close() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
}
async function goToLogin() {
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
authStore.acknowledgeSessionExpired()
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
}
function onCancel(event: Event) {
event.preventDefault()
void goToLogin()
}
watch(
() => authStore.sessionExpired,
(expired) => (expired ? open() : close()),
{ flush: 'post' },
)
onMounted(() => {
if (authStore.sessionExpired) open()
})
</script>
<template>
<dialog
ref="dialogRef"
class="session-dialog"
aria-labelledby="session-expired-title"
aria-describedby="session-expired-message"
@cancel="onCancel"
>
<h2
id="session-expired-title"
class="session-dialog__title"
>
다시 로그인해 주세요
</h2>
<p
id="session-expired-message"
class="session-dialog__message"
>
{{ message }}
</p>
<button
type="button"
class="session-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</dialog>
</template>
<style scoped>
.session-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.session-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.session-dialog::backdrop {
background: var(--color-backdrop);
}
.session-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.session-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
.session-dialog__action {
align-self: flex-end;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
margin-top: var(--space-4);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.session-dialog__action:hover {
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
// 영역 안의 상태 안내 — 빈 메일함 · 메일 미선택 · 불러오기 실패 (디자인 원본 States)
// 오류 문구는 호출부가 ApiError.message(에러 코드 사전)를 그대로 넘긴다. 여기서 문구를 만들지 않는다.
// 사용 예: <StateMessage tone="danger" icon="alert-circle" :title="error.message" :trace-id="error.traceId">
// <button …>다시 시도</button>
// </StateMessage>
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
import { formatTraceCode } from '@/shared/utils/format'
interface Props {
icon: IconName
title: string
hint?: string
/** danger 면 빨간 아이콘 + 스크린리더에 바로 알린다 */
tone?: 'neutral' | 'danger'
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
traceId?: string | null
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택하세요") */
fill?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hint: '',
tone: 'neutral',
traceId: null,
fill: false,
})
const traceCode = computed(() => formatTraceCode(props.traceId))
</script>
<template>
<div
:class="['state-message', `state-message--${tone}`, { 'state-message--fill': fill }]"
:role="tone === 'danger' ? 'alert' : undefined"
>
<AppIcon
:name="icon"
:size="36"
:stroke-width="1.5"
class="state-message__icon"
/>
<p class="state-message__title">
{{ title }}
</p>
<p
v-if="hint"
class="state-message__hint"
>
{{ hint }}
</p>
<slot />
<span
v-if="traceCode"
class="state-message__trace"
>문의 코드 {{ traceCode }}</span>
</div>
</template>
<style scoped>
.state-message {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-6);
padding: var(--space-80) var(--space-24);
text-align: center;
}
.state-message--fill {
flex: 1 1 auto;
justify-content: center;
min-height: 32.5rem;
padding: var(--space-24);
}
.state-message__icon {
color: var(--color-icon-muted);
}
.state-message--danger .state-message__icon {
color: var(--color-danger);
}
.state-message__title {
margin: var(--space-6) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.state-message__hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.state-message__trace {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
import AppIcon from '@/components/AppIcon.vue'
import { formatTraceCode } from '@/shared/utils/format'
import { useToastStore } from '@/stores/toast.store'
const toastStore = useToastStore()
</script>
<template>
<section
class="toast-host"
aria-label="알림"
>
<TransitionGroup
name="toast"
tag="div"
class="toast-host__stack"
>
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
:class="['toast', `toast--${toast.kind}`]"
:role="toast.kind === 'error' ? 'alert' : 'status'"
>
<AppIcon
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
:size="toast.kind === 'error' ? 18 : 16"
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
class="toast__icon"
/>
<div class="toast__body">
<span class="toast__message">{{ toast.message }}</span>
<span
v-if="formatTraceCode(toast.traceId)"
class="toast__trace"
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
</div>
<RouterLink
v-if="toast.action"
:to="toast.action.to"
class="toast__action"
@click="toastStore.dismiss(toast.id)"
>
{{ toast.action.label }}
</RouterLink>
<button
v-if="toast.kind === 'error'"
type="button"
class="toast__close"
aria-label="알림 닫기"
@click="toastStore.dismiss(toast.id)"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
</TransitionGroup>
</section>
</template>
<style scoped>
.toast-host {
position: fixed;
right: var(--space-20);
bottom: var(--space-20);
z-index: 20;
width: min(25rem, calc(100vw - var(--space-40)));
pointer-events: none;
}
.toast-host__stack {
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--space-10);
pointer-events: auto;
}
.toast--error {
padding: var(--space-12) var(--space-6) var(--space-12) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-toast);
}
.toast--success {
align-items: center;
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
border-radius: var(--radius-lg);
background: var(--color-brand-surface);
color: var(--color-on-brand);
box-shadow: var(--shadow-toast-strong);
}
.toast__icon {
margin-top: var(--space-1);
}
.toast--error .toast__icon {
color: var(--color-danger);
}
.toast--success .toast__icon {
margin-top: 0;
color: var(--color-on-brand-success);
}
.toast__body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.toast__message {
font-weight: 500;
}
.toast__trace {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.toast__action {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border-radius: var(--radius-sm);
color: var(--color-on-brand-link);
font-weight: 600;
text-decoration: none;
}
.toast__action:hover {
color: var(--color-on-brand);
}
.toast__action:focus-visible {
outline-color: var(--color-on-brand-link);
}
.toast__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-36);
height: var(--space-36);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.toast__close:hover {
background: var(--color-surface-muted);
}
.toast-enter-active,
.toast-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(var(--space-8));
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 쓰기 화면의 첨부 목록 — 파일마다 진행률, 실패한 파일은 사유와 함께 빨갛게 (디자인 원본 States "첨부 업로드")
// 어느 첨부든 할 수 있는 일은 "빼기"(✕) 하나다 — 올린 파일은 빼고 다시 고르면 되고, 전달 원본은 다시 올릴 수 없다.
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import { formatFileSize } from '@/shared/utils/format'
interface Props {
items: UploadItem[]
}
interface Emits {
(e: 'remove', key: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const listRef = ref<HTMLUListElement | null>(null)
/** 그 첨부의 빼기 버튼으로 초점을 옮긴다 — 발송이 찾지 못한 전달 원본을 빼게 할 때 쓴다 */
function focusRemove(key: number) {
listRef.value
?.querySelector<HTMLButtonElement>(`[data-key="${key}"] .upload-item__remove`)
?.focus()
}
defineExpose({ focusRemove })
</script>
<template>
<ul
ref="listRef"
class="upload-list"
>
<li
v-for="item in items"
:key="item.key"
:class="['upload-item', `upload-item--${item.status}`]"
:data-key="item.key"
>
<div class="upload-item__row">
<AppIcon
name="file"
class="upload-item__icon"
/>
<span class="upload-item__name">{{ item.name }}</span>
<span class="upload-item__meta">
<template v-if="item.status === 'uploading'">올리는 중 {{ item.progress }}%</template>
<template v-else>{{ formatFileSize(item.size) }}</template>
</span>
<button
type="button"
class="upload-item__remove"
:aria-label="`${item.name} 첨부 취소`"
@click="emit('remove', item.key)"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
<progress
v-if="item.status === 'uploading'"
class="upload-item__progress"
max="100"
:value="item.progress"
:aria-label="`${item.name} 올리는 중`"
/>
<p
v-if="item.status === 'failed' && item.errorMessage"
class="upload-item__error"
role="alert"
>
{{ item.errorMessage }}
</p>
</li>
</ul>
</template>
<style scoped>
.upload-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
gap: var(--space-6);
margin: 0;
padding: 0;
list-style: none;
}
.upload-item {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-10);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
.upload-item__row {
display: flex;
align-items: center;
gap: var(--space-10);
}
.upload-item__icon {
color: var(--color-text-secondary);
}
.upload-item__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.upload-item__meta {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.upload-item__remove {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-40);
height: var(--space-40);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.upload-item__remove:hover {
background: var(--color-surface-muted);
}
/* 진행률 — 3px 막대 (인라인 스타일 대신 <progress> 를 꾸민다) */
.upload-item__progress {
appearance: none;
width: calc(100% - var(--space-8));
height: var(--space-3);
margin: 0 0 var(--space-6);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.upload-item__progress::-webkit-progress-bar {
background: var(--color-skeleton);
border-radius: var(--space-2);
}
.upload-item__progress::-webkit-progress-value {
background: var(--color-brand);
border-radius: var(--space-2);
}
.upload-item__progress::-moz-progress-bar {
background: var(--color-brand);
border-radius: var(--space-2);
}
.upload-item--failed {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.upload-item--failed .upload-item__name,
.upload-item--failed .upload-item__meta,
.upload-item--failed .upload-item__icon {
color: var(--color-danger);
}
.upload-item__error {
margin: 0 0 var(--space-6);
font-size: var(--font-size-label);
color: var(--color-danger);
}
</style>
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import type { MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ListReadNav from './ListReadNav.vue'
function summary(id: string): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-26T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
}
}
describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
const store = useMailStore()
store.currentMailboxId = 'inbox'
store.messages = [summary('m1'), summary('m2'), summary('m3')]
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 }
store.listStatus = 'ready'
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
async function mountNav(messageId: string) {
const router = createTestRouter()
// 목록에서 메일을 연 것과 같은 기록: /mail/inbox → /mail/inbox/<id>
await router.push('/mail/inbox')
await router.push(`/mail/inbox/${messageId}`)
await router.isReady()
const wrapper = mount(ListReadNav, {
props: { mailboxId: 'inbox', messageId },
global: { plugins: [pinia, router] },
})
return { wrapper, router }
}
it('위치를 "n / 전체" 로 보인다 (전체는 서버가 준 total)', async () => {
const { wrapper } = await mountNav('m2')
expect(wrapper.get('.nav-position').text()).toBe('2 / 5')
})
it('첫 메일이면 이전 버튼이 꺼진다', async () => {
const { wrapper } = await mountNav('m1')
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
})
it('이전 메일로 넘어가도 기록을 늘리지 않는다 — 뒤로 가기는 목록으로 간다', async () => {
const { wrapper, router } = await mountNav('m2')
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
router.back()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('불러온 범위의 끝에서 다음을 누르면 다음 페이지를 불러온 뒤 넘어간다', async () => {
const requests = installMockApi(() =>
ok({ items: [summary('m4'), summary('m5')], pageInfo: { hasNext: false, nextCursor: null, total: 5 } }),
)
const { wrapper, router } = await mountNav('m3')
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await flushPromises()
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
})
it('마지막 메일이고 서버에도 더 없으면 다음 버튼이 꺼진다', async () => {
useMailStore().pageInfo = { hasNext: false, nextCursor: null, total: 3 }
const { wrapper } = await mountNav('m3')
expect(wrapper.get('.nav-position').text()).toBe('3 / 3')
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeDefined()
})
it('목록으로 — 바로 앞 기록이 그 목록이면 뒤로 가기를 쓴다 (브라우저 기록을 쌓지 않는다)', async () => {
// 메모리 history 는 history.state 를 쓰지 않으므로 이 경우만 실제 브라우저 history 로 확인한다
const router = createRouter({ history: createWebHistory(), routes: createTestRouter().options.routes })
await router.push('/mail/inbox')
await router.push('/mail/inbox/m2')
await router.isReady()
const wrapper = mount(ListReadNav, {
props: { mailboxId: 'inbox', messageId: 'm2' },
global: { plugins: [pinia, router] },
})
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe('/mail/inbox'))
// 뒤로 간 것이라 앞으로 갈 기록(보던 메일)이 남아 있다 — 바꿔 끼웠다면 없다
expect(router.options.history.state.forward).toBe('/mail/inbox/m2')
})
it('목록으로 — 목록으로 돌아가고, 보던 메일로 초점을 돌려주게 한다', async () => {
const { wrapper, router } = await mountNav('m2')
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
})
})
@@ -0,0 +1,148 @@
<script setup lang="ts">
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
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 { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
interface Props {
mailboxId: string
messageId: string
}
const props = defineProps<Props>()
const router = useRouter()
const mailStore = useMailStore()
const viewStore = useMailViewStore()
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
const loadingNext = ref<boolean>(false)
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
const total = computed(
() =>
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
)
const position = computed(() =>
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
)
const hasPrev = computed(() => index.value > 0)
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
const hasNext = computed(
() =>
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
)
async function goPrev() {
const previous = mailStore.messages[index.value - 1]
if (previous) await showSibling(props.mailboxId, previous.id)
}
async function goNext() {
if (loadingNext.value || index.value === -1) return
const current = index.value
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
loadingNext.value = true
try {
await mailStore.loadMore()
} finally {
loadingNext.value = false
}
}
const next = mailStore.messages[current + 1]
if (next) await showSibling(props.mailboxId, next.id)
}
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
function onBackToList(event: MouseEvent) {
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
event.preventDefault()
viewStore.setReturnFocus(props.messageId)
void backToList(props.mailboxId)
}
</script>
<template>
<MailToolButton
icon="arrow-left"
label="목록으로"
tone="accent"
:href="listHref"
:icon-stroke-width="1.9"
@click="onBackToList"
/>
<MailToolbarDivider />
<button
type="button"
class="nav-button"
aria-label="이전 메일"
:disabled="!hasPrev"
@click="goPrev"
>
<AppIcon
name="chevron-left"
:stroke-width="1.9"
/>
</button>
<span
v-if="position"
class="nav-position"
>{{ position }}</span>
<button
type="button"
class="nav-button"
aria-label="다음 메일"
:disabled="!hasNext || loadingNext"
:aria-busy="loadingNext"
@click="goNext"
>
<AppIcon
name="chevron-right"
:stroke-width="1.9"
/>
</button>
<MailToolbarDivider />
</template>
<style scoped>
.nav-button {
width: var(--size-icon-button);
height: var(--size-touch);
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
}
.nav-button:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.nav-button:disabled {
color: var(--color-icon-muted);
cursor: default;
}
.nav-position {
min-width: var(--space-48);
font-size: var(--font-size-small);
color: var(--color-text-muted);
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { MAIL_FRAME_CSP } from '@/shared/utils/mail-html'
import MailBodyFrame from './MailBodyFrame.vue'
// 보안 핵심 — 본문 iframe 의 격리 조건이 바뀌면 이 테스트가 깨져야 한다.
describe('MailBodyFrame', () => {
function frameOf(props: { htmlBody?: string | null; textBody?: string | null }) {
const wrapper = mount(MailBodyFrame, { props })
return wrapper.get('iframe').element as HTMLIFrameElement
}
it('sandbox 는 allow-same-origin · allow-popups · allow-popups-to-escape-sandbox 뿐이다', () => {
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
const tokens = (frame.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean).sort()
expect(tokens).toEqual(['allow-popups', 'allow-popups-to-escape-sandbox', 'allow-same-origin'])
// 스크립트·폼은 절대 허용하지 않는다 (allow-same-origin 과 allow-scripts 를 같이 쓰면 격리가 무너진다)
expect(tokens).not.toContain('allow-scripts')
expect(tokens).not.toContain('allow-forms')
expect(tokens).not.toContain('allow-top-navigation')
})
it('본문은 srcdoc 으로만 넣고 외부 주소(src)를 쓰지 않는다', () => {
const frame = frameOf({ htmlBody: '<p>안녕하세요</p>' })
expect(frame.hasAttribute('src')).toBe(false)
expect(frame.getAttribute('srcdoc')).toContain('<p>안녕하세요</p>')
})
it('srcdoc 문서에 내부 CSP 와 <base target="_blank"> 가 들어간다', () => {
const srcdoc = frameOf({ htmlBody: '<p>본문</p>' }).getAttribute('srcdoc') ?? ''
expect(srcdoc).toContain(`content="${MAIL_FRAME_CSP}"`)
expect(srcdoc).toContain('<base target="_blank">')
})
it('서버가 놓친 스크립트도 그리기 전에 지운다', () => {
const srcdoc =
frameOf({ htmlBody: '<p>본문</p><script>alert(1)</script><img src=x onerror=alert(2)>' }).getAttribute(
'srcdoc',
) ?? ''
expect(srcdoc).not.toContain('alert(')
expect(srcdoc).not.toContain('<script')
})
it('HTML 이 없으면 텍스트를 이스케이프해 보여 준다', () => {
const srcdoc = frameOf({ htmlBody: null, textBody: '<b>굵게</b> 아님' }).getAttribute('srcdoc') ?? ''
expect(srcdoc).toContain('&lt;b&gt;굵게&lt;/b&gt; 아님')
expect(srcdoc).not.toContain('<b>')
})
})
@@ -0,0 +1,124 @@
<script setup lang="ts">
// 메일 본문을 격리된 iframe(srcdoc)에만 그린다 — 보안 핵심 (shared/utils/mail-html.ts 머리말 참고)
//
// sandbox 에 넣는 것과 넣지 않는 것:
// - allow-scripts · allow-forms 는 절대 넣지 않는다. 본문 안 스크립트·폼 제출이 모두 막힌다.
// - allow-same-origin: 부모(우리 앱)가 contentDocument 에 접근해 내용 높이를 재기 위해서만 넣는다.
// 이 iframe 은 스크립트가 꺼져 있어(allow-scripts 없음) 같은 출처 권한을 쓸 코드가 실행될 수 없다.
// ⚠️ allow-scripts 와 함께 넣으면 iframe 안 스크립트가 sandbox 속성을 지우고 빠져나올 수 있으므로
// 둘을 같이 넣는 변경은 금지한다.
// - allow-popups · allow-popups-to-escape-sandbox: 링크를 새 창으로 열고, 새 창은 sandbox 없이 정상 동작하게 한다.
// 링크는 정화 단계에서 http·https·mailto 만 남기고 rel="noopener noreferrer" 를 붙인다.
// 문서 안에는 CSP(default-src 'none'; img-src data:; style-src 'unsafe-inline')와 <base target="_blank"> 가 들어간다.
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { buildMailDocument } from '@/shared/utils/mail-html'
interface Props {
/** 서버가 정화한 HTML — 여기서 한 번 더 정화한다 */
htmlBody?: string | null
/** HTML 이 없을 때 이스케이프해서 보여 줄 텍스트 */
textBody?: string | null
/** iframe 이름 (스크린리더) */
title?: string
}
const props = withDefaults(defineProps<Props>(), {
htmlBody: null,
textBody: null,
title: '메일 본문',
})
/** sandbox 값 — 위 설명 외의 권한을 추가하지 않는다 */
const SANDBOX = 'allow-same-origin allow-popups allow-popups-to-escape-sandbox'
const frameRef = ref<HTMLIFrameElement | null>(null)
/** 내용 높이(px) — iframe 의 height 속성으로 준다 (인라인 스타일을 쓰지 않는다) */
const contentHeight = ref<number | null>(null)
const srcdoc = computed(() =>
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody }),
)
let resizeObserver: ResizeObserver | null = null
let lastWidth = 0
let measureFrame = 0
/** 지금 srcdoc 문서가 다 열렸는지 — 열리기 전(빈 about:blank)에 재면 높이가 0 이 된다 */
let documentLoaded = false
/** 문서 높이를 재서 iframe 높이로 맞춘다 (같은 출처라 접근할 수 있다 — 위 sandbox 설명) */
function measure() {
const frame = frameRef.value
const doc = frame?.contentDocument
if (!documentLoaded || !frame || !doc?.body) return
// 넓은 표 때문에 가로 스크롤 막대가 생기면 그 두께만큼 더한다
const viewportHeight = frame.contentWindow?.innerHeight ?? 0
const scrollbar = Math.max(0, viewportHeight - doc.documentElement.clientHeight)
contentHeight.value = Math.ceil(doc.body.scrollHeight) + scrollbar
}
function scheduleMeasure() {
cancelAnimationFrame(measureFrame)
measureFrame = requestAnimationFrame(measure)
}
function onLoad() {
documentLoaded = true
measure()
}
onMounted(() => {
if (typeof ResizeObserver === 'undefined' || !frameRef.value) return
// 폭이 바뀌면 줄바꿈이 달라져 높이가 바뀐다 — 높이 변화(우리가 바꾼 것)에는 반응하지 않는다
resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 0
if (width === lastWidth) return
lastWidth = width
scheduleMeasure()
})
resizeObserver.observe(frameRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
cancelAnimationFrame(measureFrame)
})
// 다른 메일로 바뀌면 새 문서가 load 될 때 다시 잰다 — 그 전까지 이전 높이가 남지 않게 비운다
watch(srcdoc, () => {
documentLoaded = false
contentHeight.value = null
})
</script>
<template>
<!-- sandbox 를 srcdoc 보다 먼저 둔다 — 문서가 sandbox 없이 먼저 열리는 순서를 만들지 않기 위해서다 -->
<iframe
ref="frameRef"
:sandbox="SANDBOX"
class="mail-body-frame"
:class="{ 'is-measured': contentHeight !== null }"
:title="title"
referrerpolicy="no-referrer"
:height="contentHeight ?? undefined"
:srcdoc="srcdoc"
@load="onLoad"
/>
</template>
<style scoped>
.mail-body-frame {
display: block;
width: 100%;
/* 본문 글줄 최대 폭 (콘텐츠 열 공통값) */
max-width: var(--content-measure);
border: 0;
background: var(--color-surface);
}
/* 높이를 재기 전(또는 잴 수 없을 때)에도 본문이 보이게 최소 높이를 둔다 */
.mail-body-frame:not(.is-measured) {
min-height: 12rem;
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { formatCount } from '@/shared/utils/format'
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 title = computed(() => mailStore.currentMailbox?.name ?? '메일함')
const countText = computed(() => {
const box = mailStore.currentMailbox
if (!box) return ''
const total = `전체 ${formatCount(box.totalCount)}`
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
})
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
</script>
<template>
<div :class="['list-header', `list-header--${variant}`]">
<h1
:id="titleId"
class="list-header__title"
>
{{ title }}
</h1>
<span class="list-header__count">{{ countText }}</span>
<ViewModeSwitch class="list-header__switch" />
<button
type="button"
class="list-header__refresh"
aria-label="새로고침"
:disabled="busy"
@click="mailStore.refresh()"
>
<AppIcon name="refresh" />
</button>
</div>
</template>
<style scoped>
.list-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 목록 보기 — 표의 첫 칸과 같은 선(20px)에서 시작한다 */
.list-header--list {
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
}
.list-header__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.list-header__count {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.list-header__switch {
margin-left: auto;
}
.list-header__refresh {
width: var(--size-icon-button);
height: var(--size-touch);
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
}
.list-header__refresh:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.list-header__refresh:disabled {
opacity: 0.5;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
// 메일 목록·본문을 못 불러왔을 때의 상태 (디자인 원본 States "목록 불러오기 실패")
// - 다시 하면 나아질 수 있는 오류(메일 서버 장애·네트워크 등): "다시 시도"
// - 없는 주소(404 RES_001 — 주소창에 남은 옛 메일함·메일 id 등): 다시 해도 같으므로 빠져나갈 버튼을 준다
// (목록은 "받은편지함으로", 본문은 "목록으로" — 이동은 부모가 useMailNavigation 규칙으로 바꿔 끼운다)
// 문구는 에러 코드 사전에서만 온다 (error.message).
import { computed } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import type { ApiError } from '@/composables/useApi'
interface Props {
error: ApiError
/** 없는 주소일 때 버튼 이름 */
leaveLabel: string
/** 영역을 세로로 가득 채운다 (본문 영역) */
fill?: boolean
}
interface Emits {
(e: 'retry'): void
(e: 'leave'): void
}
const props = withDefaults(defineProps<Props>(), {
fill: false,
})
const emit = defineEmits<Emits>()
/** 없는 주소 — 다시 시도해도 같은 결과다 */
const notFound = computed(() => props.error.code === 'RES_001')
function onAction() {
if (notFound.value) emit('leave')
else emit('retry')
}
</script>
<template>
<StateMessage
tone="danger"
icon="alert-circle"
:title="error.message"
:trace-id="error.traceId"
:fill="fill"
>
<button
type="button"
class="mail-load-error__action"
@click="onAction"
>
{{ notFound ? leaveLabel : '다시 시도' }}
</button>
</StateMessage>
</template>
<style scoped>
.mail-load-error__action {
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;
}
.mail-load-error__action:hover {
background: var(--color-brand-subtle);
}
</style>
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import MailQuotaMeter from './MailQuotaMeter.vue'
const GB = 1024 ** 3
describe('MailQuotaMeter', () => {
it('디자인 원본처럼 "메일함 사용량 0.9 / 5 GB" 와 가는 막대를 보인다 (뱃지 없이)', () => {
const wrapper = mount(MailQuotaMeter, {
props: { quota: { usedBytes: 0.9 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
})
const meter = wrapper.get('meter')
expect(wrapper.text()).toContain('메일함 사용량')
expect(wrapper.get('.quota__value').text()).toBe('0.9 / 5 GB')
expect(meter.attributes('max')).toBe(String(5 * GB))
expect(meter.attributes('aria-valuetext')).toBe('18%')
})
it('한도가 없으면(null) 사용량만 보이고 막대는 그리지 않는다', () => {
const wrapper = mount(MailQuotaMeter, {
props: { quota: { usedBytes: 0.9 * GB, limitBytes: null, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
})
expect(wrapper.get('.quota__value').text()).toBe('0.9 GB')
expect(wrapper.find('meter').exists()).toBe(false)
})
it('한도를 넘은 사용량은 막대를 꽉 채운 값으로 그린다', () => {
const wrapper = mount(MailQuotaMeter, {
props: { quota: { usedBytes: 6 * GB, limitBytes: 5 * GB, attachmentMaxBytes: 20 * 1024 ** 2, attachmentTotalMaxBytes: 25 * 1024 ** 2 } },
})
expect(wrapper.get('meter').attributes('value')).toBe(String(5 * GB))
})
})
@@ -0,0 +1,117 @@
<script setup lang="ts">
// 메일함 사용량 — 메일함 패널 하단 (디자인 원본 Main: "메일함 사용량 0.9 / 5 GB" + 가는 막대)
// 뱃지 없이 숫자와 막대만. 한도가 없으면(limitBytes null) 사용량만 보이고 막대는 그리지 않는다.
// 막대는 인라인 스타일 대신 <meter> 를 꾸민다 — 값·범위가 스크린리더에도 그대로 전달된다.
import { computed } from 'vue'
import { MAIL_QUOTA_WARNING_RATIO } from '@/shared/constants/mail'
import type { MailQuota } from '@/shared/types/mail'
import { formatQuotaUsage } from '@/shared/utils/format'
interface Props {
quota: MailQuota
}
const props = defineProps<Props>()
const PERCENT = 100
const usage = computed(() => formatQuotaUsage(props.quota.usedBytes, props.quota.limitBytes))
/** 한도가 있을 때만 막대를 그린다 */
const limit = computed(() => {
const value = props.quota.limitBytes
return value !== null && value > 0 ? value : null
})
const used = computed(() => Math.min(Math.max(props.quota.usedBytes, 0), limit.value ?? 0))
/** 이 값을 넘으면 경고색 (meter 의 low=high 구간 경계) */
const warnAt = computed(() => (limit.value ?? 0) * MAIL_QUOTA_WARNING_RATIO)
const percentText = computed(() =>
limit.value ? `${Math.round((used.value / limit.value) * PERCENT)}%` : '',
)
</script>
<template>
<div class="quota">
<div class="quota__row">
<span id="mail-quota-label">메일함 사용량</span>
<span class="quota__value">{{ usage }}</span>
</div>
<meter
v-if="limit"
class="quota__meter"
min="0"
:max="limit"
:low="warnAt"
:high="warnAt"
optimum="0"
:value="used"
aria-labelledby="mail-quota-label"
:aria-valuetext="percentText"
:title="usage"
/>
</div>
</template>
<style scoped>
.quota {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--space-6);
padding: var(--space-12) var(--space-10) var(--space-2);
border-top: var(--border-width) solid var(--color-border);
}
.quota__row {
display: flex;
justify-content: space-between;
gap: var(--space-8);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.quota__value {
font-variant-numeric: tabular-nums;
}
/* 4px 막대 — 브라우저 기본 모양을 지우고 토큰 색으로 그린다 */
.quota__meter {
appearance: none;
display: block;
width: 100%;
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.quota__meter::-webkit-meter-bar {
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
}
.quota__meter::-webkit-meter-optimum-value {
border-radius: var(--space-2);
background: var(--color-brand);
}
/* 한도의 90% 를 넘으면 경고색 — 용량 부족(MAIL_003)으로 발송이 막히기 전에 알아차리게 한다 */
.quota__meter::-webkit-meter-suboptimum-value,
.quota__meter::-webkit-meter-even-less-good-value {
border-radius: var(--space-2);
background: var(--color-danger);
}
.quota__meter::-moz-meter-bar {
border-radius: var(--space-2);
background: var(--color-brand);
}
.quota__meter:-moz-meter-sub-optimum::-moz-meter-bar,
.quota__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
background: var(--color-danger);
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
// 본문 툴바 버튼 — 분할 보기 읽기 · 목록 보기 읽기가 같은 모양을 쓴다 (디자인 원본 Main · MainListRead)
// 안쪽 좌우 여백은 --toolbar-button-inline(10px) — 툴바 안쪽 여백(22px)과 더해 첫 아이콘이 콘텐츠 열 32px 선에 온다.
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
label: string
/** 앱 안 이동 — 있으면 RouterLink */
to?: RouteLocationRaw | null
/** 직접 처리하는 링크(예: 목록으로) — 있으면 <a href>, 누르면 click 을 내보낸다 */
href?: string | null
/** accent: 강조 글자(목록으로) · danger: 삭제 */
tone?: 'default' | 'accent' | 'danger'
disabled?: boolean
iconStrokeWidth?: number
}
interface Emits {
(e: 'click', event: MouseEvent): void
}
withDefaults(defineProps<Props>(), {
to: null,
href: null,
tone: 'default',
disabled: false,
iconStrokeWidth: 1.75,
})
const emit = defineEmits<Emits>()
</script>
<template>
<RouterLink
v-if="to"
:to="to"
:class="['tool-button', `tool-button--${tone}`]"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
</RouterLink>
<a
v-else-if="href"
:href="href"
:class="['tool-button', `tool-button--${tone}`]"
@click="emit('click', $event)"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
</a>
<button
v-else
type="button"
:class="['tool-button', `tool-button--${tone}`]"
:disabled="disabled"
@click="emit('click', $event)"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
</button>
</template>
<style scoped>
.tool-button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
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;
text-decoration: none;
}
.tool-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.tool-button:disabled {
opacity: 0.5;
}
.tool-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
}
.tool-button--accent:hover:not(:disabled) {
color: var(--color-brand-strong);
}
.tool-button--danger {
color: var(--color-danger);
}
.tool-button--danger:hover:not(:disabled) {
background: var(--color-danger-surface);
color: var(--color-danger);
}
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
// 본문 툴바의 묶음 구분선 (디자인 원본: 1px × 20px, 좌우 6px)
</script>
<template>
<span
class="toolbar-divider"
aria-hidden="true"
/>
</template>
<style scoped>
.toolbar-divider {
width: var(--space-1);
height: var(--space-20);
margin: 0 var(--space-6);
background: var(--color-border);
}
</style>
@@ -0,0 +1,258 @@
<script setup lang="ts">
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
import { mailboxIcon } from '@/shared/utils/mailbox'
import { formatCount } from '@/shared/utils/format'
import { useMailStore } from '@/stores/mail.store'
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
const MAX_INDENT_DEPTH = 3
const route = useRoute()
const mailStore = useMailStore()
const isComposing = computed(() => route.name === 'mail-compose')
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
function depthClass(depth: number): string {
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
}
</script>
<template>
<nav
class="mailbox-panel"
aria-label="메일함"
>
<span
v-if="isComposing"
class="mailbox-panel__primary mailbox-panel__primary--current"
aria-current="page"
>
<AppIcon
name="edit"
:size="15"
:stroke-width="2"
/>
<span>메일 쓰는 중</span>
</span>
<RouterLink
v-else
:to="{ name: 'mail-compose' }"
class="mailbox-panel__primary"
>
<AppIcon
name="edit"
:size="15"
:stroke-width="2"
/>
<span>메일 쓰기</span>
</RouterLink>
<ul
v-if="mailStore.orderedMailboxes.length > 0"
class="mailbox-panel__list"
>
<li
v-for="node in mailStore.orderedMailboxes"
:key="node.mailbox.id"
>
<RouterLink
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
:class="[
'mailbox-link',
depthClass(node.depth),
{ 'is-active': node.mailbox.id === activeMailboxId },
]"
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
>
<AppIcon :name="mailboxIcon(node.mailbox)" />
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
<span
v-if="node.mailbox.unreadCount > 0"
class="mailbox-link__count"
>
<span class="visually-hidden">안 읽음</span>
{{ formatCount(node.mailbox.unreadCount) }}
</span>
</RouterLink>
</li>
</ul>
<ul
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
class="mailbox-panel__list"
aria-label="메일함 불러오는 중"
aria-busy="true"
>
<li
v-for="row in 5"
:key="row"
class="mailbox-panel__placeholder"
/>
</ul>
<div
v-else-if="mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError"
class="mailbox-panel__error"
role="alert"
>
<p class="mailbox-panel__error-text">
{{ mailStore.mailboxesError.message }}
</p>
<button
type="button"
class="mailbox-panel__retry"
@click="mailStore.loadMailboxes()"
>
다시 시도
</button>
</div>
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
<MailQuotaMeter
v-if="mailStore.quota"
:quota="mailStore.quota"
/>
</nav>
</template>
<style scoped>
.mailbox-panel {
flex: 1 1 13rem;
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-14) var(--space-10);
border-right: var(--border-width) solid var(--color-border);
background: var(--color-surface-panel);
}
.mailbox-panel__primary {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-8);
min-height: var(--size-touch);
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
text-decoration: none;
}
a.mailbox-panel__primary:hover {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
.mailbox-panel__primary--current {
background: var(--color-brand-strong);
}
.mailbox-panel__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.mailbox-link {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.mailbox-link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.mailbox-link.is-active {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
.mailbox-link--depth-1 {
padding-left: var(--space-28);
}
.mailbox-link--depth-2 {
padding-left: var(--space-44);
}
.mailbox-link--depth-3 {
padding-left: var(--space-56);
}
.mailbox-link__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-link__count {
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.mailbox-link.is-active .mailbox-link__count {
color: var(--color-brand-strong);
}
.mailbox-panel__placeholder {
height: var(--size-row-min);
margin: var(--space-2) 0;
border-radius: var(--radius-item);
background: var(--color-border-subtle);
}
.mailbox-panel__error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: 0 var(--space-10);
}
.mailbox-panel__error-text {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
}
.mailbox-panel__retry {
min-height: var(--size-touch);
padding: 0;
border: 0;
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.mailbox-panel {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,169 @@
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 { Attachment } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockResult,
} from '@/test-utils/mock-api'
import MessageAttachments from './MessageAttachments.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CHECKLIST: Attachment = {
id: 'att 1',
name: '체크리스트.xlsx',
type: 'application/vnd.ms-excel',
size: 49152,
}
/** 경로의 id 는 인코딩된다 (공백 → %20) */
const DOWNLOAD_URL = '/api/messages/m1/attachments/att%201'
let clock = Date.UTC(2026, 9, 26, 7, 0, 0)
interface StartedDownload {
href: string | null
download: string | null
}
describe('MessageAttachments 다운로드 — 이동 전 세션 확인 (계약 2절)', () => {
let pinia: Pinia
/** 실제 다운로드를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
let downloads: StartedDownload[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
downloads.push({ href: this.getAttribute('href'), download: this.getAttribute('download') })
})
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
function mountAttachments(handler: MockHandler) {
const requests = installMockApi(handler)
const wrapper = mount(MessageAttachments, {
props: { messageId: 'm1', attachments: [CHECKLIST] },
global: { plugins: [pinia] },
})
const link = wrapper.get<HTMLAnchorElement>('a.attachments__download').element
return { wrapper, link, requests }
}
function press(link: HTMLAnchorElement, type: 'click' | 'auxclick', init: MouseEventInit = {}) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init })
link.dispatchEvent(event)
return event
}
function requestLines(requests: { method: string; url: string }[]): string[] {
return requests.map((request) => `${request.method} ${request.url}`)
}
it('링크 주소는 그대로 두고, 누르면 세션을 확인한 뒤 같은 주소로 내려받는다 — 확인 중에 다시 눌러도 한 번만', async () => {
let releaseMe: (result: MockResult) => void = () => undefined
const meGate = new Promise<MockResult>((resolve) => {
releaseMe = resolve
})
const { link, requests } = mountAttachments(() => meGate)
expect(link.getAttribute('href')).toBe(DOWNLOAD_URL)
const first = press(link, 'click')
press(link, 'click')
await flushPromises()
// 원래 링크의 이동은 막고, 확인이 끝나기 전에는 받지 않는다
expect(first.defaultPrevented).toBe(true)
expect(downloads).toEqual([])
releaseMe(ok(USER))
await flushPromises()
expect(requestLines(requests)).toEqual(['GET /auth/me'])
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
})
it('액세스 토큰이 만료됐으면(AUTH_001) 알림 없이 갱신한 뒤 내려받는다', async () => {
const { link, requests } = mountAttachments(({ url }) =>
url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001'),
)
press(link, 'click')
await flushPromises()
expect(requestLines(requests)).toEqual(['GET /auth/me', 'POST /auth/refresh'])
expect(downloads).toEqual([{ href: DOWNLOAD_URL, download: '' }])
expect(useAuthStore().isLoggedIn).toBe(true)
expect(useToastStore().toasts).toEqual([])
})
it('갱신도 실패하면 내려받지 않고 세션 만료로 정리한다 (토스트 대신 대화상자)', async () => {
const { link } = mountAttachments(() => fail(401, 'AUTH_001'))
press(link, 'click')
await flushPromises()
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
expect(useToastStore().toasts).toEqual([])
})
it('확인이 장애로 실패하면 내려받지 않고 사전 문구로 알린다 — 다시 누르면 다시 확인한다', async () => {
let healthy = false
const { link, requests } = mountAttachments(() => (healthy ? ok(USER) : fail(500, 'SYS_001')))
press(link, 'click')
await flushPromises()
expect(downloads).toEqual([])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
ERROR_CODE_MESSAGES.SYS_001,
])
expect(useAuthStore().sessionExpired).toBe(false)
healthy = true
press(link, 'click')
await flushPromises()
expect(requestLines(requests)).toEqual(['GET /auth/me', 'GET /auth/me'])
expect(downloads).toHaveLength(1)
})
it('보조 클릭(Ctrl·가운데 버튼)도 같은 흐름으로 받고, 오른쪽 버튼은 브라우저 메뉴에 맡긴다', async () => {
const { link, requests } = mountAttachments(() => ok(USER))
const right = press(link, 'auxclick', { button: 2 })
await flushPromises()
expect(right.defaultPrevented).toBe(false)
expect(requests).toEqual([])
const middle = press(link, 'auxclick', { button: 1 })
await flushPromises()
const withCtrl = press(link, 'click', { ctrlKey: true })
await flushPromises()
expect(middle.defaultPrevented).toBe(true)
expect(withCtrl.defaultPrevented).toBe(true)
expect(downloads).toHaveLength(2)
})
})
@@ -0,0 +1,163 @@
<script setup lang="ts">
// 받은 메일의 첨부 목록 — 다운로드는 <a href> 이동으로 받는다 (계약 2절)
// 주소는 axios baseURL 과 같은 규칙으로 만들어야 같은 출처·쿠키 경로로 요청이 간다 (useMail.attachmentDownloadUrl)
// 이동은 axios 의 401 갱신 규칙을 타지 않으므로, 누르면 바로 이동하지 않고 세션부터 확인한 뒤 받는다.
// href 는 그대로 둔다 — "링크 주소 복사" 같은 브라우저 기본 기능을 지키기 위해서다.
import AppIcon from '@/components/AppIcon.vue'
import { useMail } from '@/composables/useMail'
import type { Attachment } from '@/shared/types/mail'
import { formatFileSize } from '@/shared/utils/format'
interface Props {
messageId: string
attachments: Attachment[]
}
const props = defineProps<Props>()
const { attachmentDownloadUrl, downloadAttachment } = useMail()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
/** 세션을 확인하는 중인 첨부 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
const pending = new Set<string>()
function onDownload(event: MouseEvent, attachmentId: string) {
// 오른쪽 버튼(메뉴)은 브라우저에 맡긴다
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
const key = `${props.messageId}/${attachmentId}`
if (pending.has(key)) return
pending.add(key)
void downloadAttachment(props.messageId, attachmentId).finally(() => {
pending.delete(key)
})
}
</script>
<template>
<section
class="attachments"
aria-labelledby="reader-attachments-title"
>
<h3
id="reader-attachments-title"
class="attachments__title"
>
첨부 파일 {{ attachments.length }}개
</h3>
<ul class="attachments__list">
<li
v-for="attachment in attachments"
:key="attachment.id"
class="attachments__item"
>
<span
class="attachments__icon"
aria-hidden="true"
>
<AppIcon name="file" />
</span>
<div class="attachments__meta">
<span class="attachments__name">{{ attachment.name }}</span>
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
</div>
<a
class="attachments__download"
:href="attachmentDownloadUrl(messageId, attachment.id)"
:aria-label="`${attachment.name} 다운로드`"
download
@click="onDownload($event, attachment.id)"
@auxclick="onDownload($event, attachment.id)"
>
<AppIcon name="download" />
</a>
</li>
</ul>
</section>
</template>
<style scoped>
.attachments {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.attachments__title {
margin: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.attachments__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16.25rem, 100%), 1fr));
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.attachments__item {
display: flex;
align-items: center;
gap: var(--space-10);
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-8);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
.attachments__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
.attachments__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.attachments__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.attachments__size {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.attachments__download {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
border-radius: var(--radius-md);
color: var(--color-text-strong);
}
.attachments__download:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -0,0 +1,148 @@
<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'
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 { useMailStore } from '@/stores/mail.store'
interface Props {
mailboxId: string
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const { replaceWithMailbox } = useMailNavigation()
const mailbox = computed(() => mailStore.currentMailbox)
const showRecipients = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
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)
}
</script>
<template>
<section
class="message-list"
aria-labelledby="message-list-title"
:aria-busy="busy"
>
<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"
@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>
</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);
}
.message-list__items {
margin: 0;
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>
@@ -0,0 +1,199 @@
<script setup lang="ts">
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { MessageSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
interface Props {
message: MessageSummary
mailboxId: string
/** 지금 열려 있는 메일 */
selected?: boolean
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
showRecipients?: boolean
}
interface Emits {
(e: 'toggle-flag', id: string): void
}
const props = withDefaults(defineProps<Props>(), {
selected: false,
showRecipients: false,
})
const emit = defineEmits<Emits>()
const who = computed(() => {
if (props.showRecipients) {
const recipients = summarizeRecipients(props.message.to)
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
}
return displayName(props.message.from) || '(보낸 사람 없음)'
})
const subject = computed(() => props.message.subject || '(제목 없음)')
const time = computed(() => formatMailListTime(props.message.receivedAt))
</script>
<template>
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
<RouterLink
class="message-row__main"
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
>
<span class="message-row__top">
<span class="message-row__who">
<span
v-if="!message.isRead"
class="visually-hidden"
>안 읽음, </span>{{ who }}
</span>
<time
class="message-row__time"
:datetime="message.receivedAt"
>{{ time }}</time>
</span>
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
<span class="message-row__bottom">
<span class="message-row__subject">{{ subject }}</span>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
label="첨부 있음"
class="message-row__clip"
/>
</span>
</RouterLink>
<button
type="button"
:class="['message-row__star', { 'is-flagged': message.isFlagged }]"
aria-label="중요 표시"
:aria-pressed="message.isFlagged"
@click="emit('toggle-flag', message.id)"
>
<AppIcon
name="star"
:size="15"
:filled="message.isFlagged"
/>
</button>
</li>
</template>
<style scoped>
.message-row {
display: flex;
align-items: stretch;
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.message-row:hover {
background: var(--color-surface-panel);
}
.message-row.is-selected {
background: var(--color-brand-subtle);
}
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
.message-row__main {
flex: 1 1 auto;
min-width: 0;
min-height: var(--size-list-row);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
line-height: var(--line-height-heading);
color: inherit;
text-decoration: none;
}
.message-row__main:hover {
color: inherit;
}
.message-row__main:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.message-row__top {
display: flex;
align-items: center;
gap: var(--space-6);
}
.message-row__who {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 450;
color: var(--color-text-strong);
}
.message-row__clip {
color: var(--color-text-muted);
}
.message-row__time {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.message-row__bottom {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.message-row__subject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.message-row.is-unread .message-row__who {
font-weight: 650;
color: var(--color-text);
}
.message-row.is-unread .message-row__subject,
.message-row.is-unread .message-row__time {
font-weight: 600;
color: var(--color-text);
}
.message-row__star {
flex: 0 0 auto;
width: var(--size-touch);
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--color-icon-muted);
}
.message-row__star.is-flagged {
color: var(--color-star);
}
.message-row__star:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
interface Props {
rows?: number
}
withDefaults(defineProps<Props>(), {
rows: 7,
})
</script>
<template>
<ul
class="list-skeleton"
aria-label="메일 목록 불러오는 중"
aria-busy="true"
>
<li
v-for="row in rows"
:key="row"
class="list-skeleton__row"
>
<span class="list-skeleton__bar list-skeleton__bar--who" />
<span class="list-skeleton__bar list-skeleton__bar--subject" />
</li>
</ul>
</template>
<style scoped>
.list-skeleton {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.list-skeleton__row {
display: flex;
flex-direction: column;
gap: var(--space-6);
padding: var(--space-12) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.list-skeleton__bar {
height: var(--space-10);
border-radius: var(--space-6);
}
.list-skeleton__bar--who {
width: 36%;
background: var(--color-skeleton);
}
.list-skeleton__bar--subject {
width: 82%;
background: var(--color-border-subtle);
}
/* 행마다 길이를 조금씩 달리한다 (인라인 스타일 대신 순서로) */
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--who {
width: 28%;
}
.list-skeleton__row:nth-child(3n + 2) .list-skeleton__bar--subject {
width: 70%;
}
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--who {
width: 44%;
}
.list-skeleton__row:nth-child(3n) .list-skeleton__bar--subject {
width: 76%;
}
</style>
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MessageDetail } from '@/shared/types/mail'
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 MessageReader from './MessageReader.vue'
const DETAIL: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
subject: '메일 서버 이전 일정 공유',
preview: '',
receivedAt: '2026-10-26T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
describe('MessageReader 도구 모음', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountReader() {
const store = useMailStore()
store.selected = DETAIL
store.selectedId = DETAIL.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
return mount(MessageReader, { global: { plugins: [pinia, router] } })
}
function linkHref(wrapper: Awaited<ReturnType<typeof mountReader>>, label: string): string | undefined {
return wrapper
.findAll('.tool-button')
.find((button) => button.text() === label)
?.attributes('href')
}
it('답장 · 전체 답장 · 전달이 쓰기 화면으로 원본 메일 id 를 넘긴다', async () => {
const wrapper = await mountReader()
expect(linkHref(wrapper, '답장')).toBe('/mail/compose?replyTo=m1')
expect(linkHref(wrapper, '전체 답장')).toBe('/mail/compose?replyTo=m1&all=1')
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
})
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
const store = useMailStore()
store.selected = null
store.selectedId = 'm1'
store.selectedStatus = 'error'
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: 'list' },
slots: { 'toolbar-start': () => h('a', { class: 'test-nav', href: '/mail/inbox' }, '목록으로') },
global: { plugins: [pinia, router] },
})
expect(wrapper.find('.test-nav').exists()).toBe(true)
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
})
it('전달은 더 이상 비활성("준비 중")이 아니다', async () => {
const wrapper = await mountReader()
expect(wrapper.find('[aria-disabled="true"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('준비 중')
})
})
describe('MessageReader 불러오기 실패', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
async function mountFailed(code: 'RES_001' | 'MAIL_001', layout: 'split' | 'list') {
const store = useMailStore()
store.currentMailboxId = 'inbox'
store.selected = null
store.selectedId = 'missing'
store.selectedStatus = 'error'
store.selectedError = { code, message: ERROR_CODE_MESSAGES[code] }
const router = createTestRouter()
await router.push('/mail/inbox/missing')
await router.isReady()
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
const wrapper = mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
const action = wrapper.get('.mail-load-error__action')
return { wrapper, router, push, replace, action }
}
it.each(['split', 'list'] as const)(
'%s 보기 — 없는 메일(404 RES_001)이면 "다시 시도" 대신 "목록으로" — 기록을 쌓지 않고 그 메일함 목록으로 간다',
async (layout) => {
const requests = installMockApi(() => ok({}))
const { wrapper, router, push, replace, action } = await mountFailed('RES_001', layout)
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(action.text()).toBe('목록으로')
await action.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(replace).toHaveBeenCalled()
expect(push).not.toHaveBeenCalled()
// 다시 불러오지 않는다 — 없는 메일이다
expect(requests).toEqual([])
},
)
it('메일 서버 장애 같은 일시적 오류는 "다시 시도" 그대로 — 누르면 다시 불러온다', async () => {
const requests = installMockApi(() => ok(DETAIL))
const { router, action } = await mountFailed('MAIL_001', 'split')
expect(action.text()).toBe('다시 시도')
await action.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toContain('GET /messages/missing')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
})
})
@@ -0,0 +1,440 @@
<script setup lang="ts">
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
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 { useMailNavigation } from '@/composables/useMailNavigation'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/**
* split: 분할 보기 — 삭제하면 다음 메일(없으면 이전 메일)을 연다
* list: 목록 보기에서 연 메일 — 툴바 앞에 "목록으로 · 이전/다음"(toolbar-start 슬롯), 삭제하면 목록으로
*/
layout?: 'split' | 'list'
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const router = useRouter()
const mailStore = useMailStore()
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
const deleting = ref<boolean>(false)
const message = computed(() => mailStore.selected)
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
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))
}
</script>
<template>
<div class="reader">
<!--
툴바 띠는 칸 전체 폭, 버튼 줄은 콘텐츠 열 안 — 첫 아이콘이 본문과 같은 32px 선에서 시작한다.
목록 보기에서는 메일을 불러오는 중이거나 실패해도 "목록으로 · 이전/다음"이 남아 있어야 하므로 항상 그린다.
-->
<div
v-if="message || layout === 'list'"
class="reader__toolbar-bar"
>
<div
class="reader__toolbar"
role="group"
aria-label="메일 동작"
>
<slot name="toolbar-start" />
<template v-if="message">
<MailToolButton
icon="reply"
label="답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MailToolButton
icon="trash"
:label="isTrash ? '영구 삭제' : '삭제'"
tone="danger"
:disabled="deleting"
@click="onDelete"
/>
</template>
</div>
</div>
<div
v-if="mailStore.selectedStatus === 'loading'"
:class="['reader__loading', { 'reader__loading--below-toolbar': layout === 'list' }]"
aria-busy="true"
aria-label="메일 불러오는 중"
>
<span class="reader__bar reader__bar--title" />
<span class="reader__bar reader__bar--meta" />
<span class="reader__bar" />
<span class="reader__bar" />
<span class="reader__bar reader__bar--short" />
</div>
<MailLoadError
v-else-if="mailStore.selectedStatus === 'error' && mailStore.selectedError"
:error="mailStore.selectedError"
leave-label="목록으로"
fill
@retry="retry"
@leave="leaveToList"
/>
<article
v-else-if="message"
class="reader__article"
aria-labelledby="reader-subject"
>
<div class="reader__content">
<div class="reader__subject-row">
<h2
id="reader-subject"
class="reader__subject"
>
{{ subject }}
</h2>
<button
type="button"
:class="['reader__star', { 'is-flagged': message.isFlagged }]"
aria-label="중요 표시"
:aria-pressed="message.isFlagged"
@click="mailStore.toggleFlag(message.id)"
>
<AppIcon
name="star"
:size="18"
:filled="message.isFlagged"
/>
</button>
</div>
<div class="reader__sender">
<span
class="reader__avatar"
aria-hidden="true"
>{{ initial }}</span>
<div class="reader__sender-info">
<div class="reader__from">
<span class="reader__from-name">{{ fromName }}</span>
<span
v-if="fromEmail && fromEmail !== fromName"
class="reader__from-email"
>{{ fromEmail }}</span>
<time
class="reader__date"
:datetime="message.receivedAt"
>{{ receivedAt }}</time>
</div>
<p
v-if="message.to.length > 0"
class="reader__recipients"
>
<span class="reader__recipients-label">받는 사람</span>
{{ formatAddressList(message.to) }}
</p>
<p
v-if="message.cc.length > 0"
class="reader__recipients"
>
<span class="reader__recipients-label">참조</span>
{{ formatAddressList(message.cc) }}
</p>
</div>
</div>
<p
v-if="message.hasBlockedImages"
class="reader__notice"
>
<AppIcon
name="shield"
:size="15"
class="reader__notice-icon"
/>
<span>외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
</p>
<MailBodyFrame
:html-body="message.htmlBody"
:text-body="message.textBody"
:title="`메일 본문: ${subject}`"
/>
<MessageAttachments
v-if="message.attachments.length > 0"
:message-id="message.id"
:attachments="message.attachments"
/>
</div>
</article>
<StateMessage
v-else
icon="mail"
title="메일을 선택하세요"
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
fill
/>
</div>
</template>
<style scoped>
.reader {
flex: 999 1 30rem;
min-width: 0;
display: flex;
flex-direction: column;
}
.reader__article {
display: flex;
flex-direction: column;
}
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
.reader__toolbar-bar {
position: sticky;
top: 0;
z-index: 1;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* 버튼 줄 — 콘텐츠 열 안. 안쪽 여백 22px + 버튼 안쪽 10px = 첫 아이콘이 32px 선 */
.reader__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
max-width: var(--content-max-width);
min-height: var(--size-header-min);
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
.reader__content {
display: flex;
flex-direction: column;
gap: var(--space-18);
max-width: var(--content-max-width);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
.reader__subject-row {
display: flex;
align-items: flex-start;
gap: var(--space-8);
}
.reader__subject {
flex: 1 1 auto;
margin: var(--space-4) 0 0;
font-size: var(--font-size-heading);
line-height: var(--line-height-heading);
font-weight: 650;
letter-spacing: var(--letter-spacing-tighter);
overflow-wrap: anywhere;
}
.reader__star {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-icon-muted);
}
.reader__star.is-flagged {
color: var(--color-star);
}
.reader__sender {
display: flex;
align-items: flex-start;
gap: var(--space-12);
}
.reader__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-36);
height: var(--space-36);
border-radius: var(--radius-round);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-size: var(--font-size-body);
font-weight: 600;
}
.reader__sender-info {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.reader__from {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.reader__from-name {
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.reader__from-email {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.reader__date {
margin-left: auto;
font-size: var(--font-size-label);
color: var(--color-text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.reader__recipients {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.reader__recipients-label {
margin-right: var(--space-6);
}
.reader__notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
}
.reader__notice-icon {
margin-top: var(--space-2);
}
/* 툴바가 없을 때(분할 보기)는 툴바 높이만큼 비워 두어 불러온 뒤 화면이 튀지 않게 한다 */
.reader__loading {
display: flex;
flex-direction: column;
gap: var(--space-12);
max-width: var(--content-max-width);
padding: calc(var(--size-header-min) + var(--content-block-start)) var(--content-inline)
var(--content-block-end);
}
.reader__loading--below-toolbar {
padding-top: var(--content-block-start);
}
.reader__bar {
width: 80%;
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.reader__bar--title {
width: 55%;
height: var(--space-18);
background: var(--color-skeleton);
}
.reader__bar--meta {
width: 35%;
margin-bottom: var(--space-12);
background: var(--color-skeleton);
}
.reader__bar--short {
width: 45%;
}
/* 넓은 화면에서는 본문 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.reader {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,176 @@
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, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageTable from './MessageTable.vue'
const INBOX: Mailbox = {
id: 'inbox',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 15,
unreadCount: 2,
}
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: 'junho.park@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2025-10-24T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 62 * 1024,
...partial,
}
}
describe('MessageTable (목록 보기)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountTable(
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
) {
const store = useMailStore()
store.mailboxes = [options.mailbox ?? INBOX]
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.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 () => {
const { wrapper } = await mountTable()
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
'중요 표시',
'보낸 사람',
'제목',
'날짜',
'크기',
])
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
})
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
const { wrapper } = await mountTable()
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
})
it('행 어디를 눌러도 메일을 연다', async () => {
const { wrapper, router } = await mountTable()
await wrapper.findAll('tbody tr')[1]?.get('.mail-table__who').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
})
it('별 버튼은 메일을 열지 않고 중요 표시만 바꾼다', async () => {
const requests = installMockApi(() => ok(summary('m2', { isFlagged: true })))
const { wrapper, router, store } = await mountTable()
await wrapper.findAll('.mail-table__star-button')[1]?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(requests[0]).toMatchObject({ method: 'PATCH', url: '/messages/m2', data: { isFlagged: true } })
expect(store.messages[1]?.isFlagged).toBe(true)
})
it('제목 칸은 키보드로 갈 수 있는 링크다', async () => {
const { wrapper } = await mountTable()
const link = wrapper.findAll('a.mail-table__link')[0]
expect(link?.attributes('href')).toBe('/mail/inbox/m1')
expect(link?.text()).toBe('제목 m1')
})
it('안 읽은 메일은 굵게(행 표시) 하고 스크린리더에는 "안 읽음" 을 읽힌다 · 첨부는 제목 뒤 클립', async () => {
const { wrapper } = await mountTable()
const [unread, read] = wrapper.findAll('tbody tr')
expect(unread?.classes()).toContain('is-unread')
expect(read?.classes()).not.toContain('is-unread')
expect(unread?.get('.mail-table__who').text()).toContain('안 읽음,')
expect(unread?.find('.mail-table__subject [aria-label="첨부 있음"]').exists()).toBe(true)
})
it('날짜 · 크기 칸 — 크기는 사람이 읽는 단위', async () => {
const { wrapper } = await mountTable()
const firstRow = wrapper.findAll('tbody tr')[0]
expect(firstRow?.get('.mail-table__date').text()).toBe('2025.10.24')
expect(firstRow?.get('td.mail-table__size').text()).toBe('62 KB')
})
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
})
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
const requests = installMockApi(() =>
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
)
const { wrapper } = await mountTable({ hasNext: true })
await wrapper.get('.message-table__more-button').trigger('click')
await flushPromises()
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
})
it('목록으로 돌아오면 보던 메일의 제목 링크로 초점을 돌려준다', async () => {
useMailViewStore().setReturnFocus('m2')
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
const { wrapper } = await mountTable()
await flushPromises()
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
scrollTo.mockRestore()
})
})
@@ -0,0 +1,486 @@
<script setup lang="ts">
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } 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 { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
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 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 whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
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 whoOf(message: MessageSummary): string {
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
return displayName(message.from) || '(보낸 사람 없음)'
}
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
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(messageLocation(props.mailboxId, message.id))
}
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"
class="message-table"
aria-labelledby="message-table-title"
:aria-busy="busy"
>
<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': !message.isRead }]"
@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">
<span
v-if="!message.isRead"
class="visually-hidden"
>안 읽음, </span>{{ whoOf(message) }}
</td>
<td class="mail-table__subject">
<div class="mail-table__subject-line">
<RouterLink
class="mail-table__link"
:to="messageLocation(mailboxId, message.id)"
:data-message-id="message.id"
>
{{ message.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>
</template>
<style scoped>
.message-table {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
/*
* 가로 스크롤만 맡는다. overflow-x 를 주면 세로도 스크롤 상자가 되어 세로 flex 안에서 줄어들고
* 이 상자가 세로 스크롤을 가로챈다 — 줄어들지 않게 해 칸(section) 전체가 스크롤되게 한다
* (분할 보기와 같이 "메일 더 보기"가 표 바로 뒤에 오고, 돌아올 때 칸의 스크롤 위치를 되살린다).
*/
.message-table__scroll {
flex: 0 0 auto;
overflow-x: auto;
}
.mail-table {
width: 100%;
min-width: var(--table-min-width);
table-layout: fixed;
border-collapse: collapse;
}
/*
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
*/
.mail-table__col-star {
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
}
.mail-table__col-who {
width: calc(var(--table-col-who) + var(--table-gap));
}
.mail-table__col-date {
width: calc(var(--table-col-date) + var(--table-gap));
}
.mail-table__col-size {
width: calc(var(--table-col-size) + var(--space-8));
}
.mail-table th,
.mail-table td {
padding: 0 var(--table-gap) 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
vertical-align: middle;
}
.mail-table th:first-child,
.mail-table td:first-child {
padding-left: var(--space-20);
}
.mail-table th:last-child,
.mail-table 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 {
text-align: right;
}
.mail-table__date,
.mail-table__size {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.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 {
display: none;
}
.mail-table__col-date {
width: calc(var(--table-col-date) + var(--space-8));
}
.mail-table th.mail-table__date,
.mail-table td.mail-table__date {
padding-right: var(--space-8);
}
}
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
@media (max-width: 37.4375rem) {
.mail-table__col-who {
width: calc(var(--table-col-who-narrow) + var(--table-gap));
}
.mail-table__col-date {
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,112 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import type { RecipientToken } from '@/shared/utils/mail-address'
import RecipientField from './RecipientField.vue'
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
const models = new WeakMap<VueWrapper, RecipientToken[]>()
function mountField(initial: RecipientToken[] = []): VueWrapper {
const wrapper: VueWrapper = mount(RecipientField, {
props: {
modelValue: initial,
label: '받는 사람',
inputId: 'compose-to',
errorId: 'compose-recipient-error',
'onUpdate:modelValue': (value: RecipientToken[]) => {
models.set(wrapper, value)
return wrapper.setProps({ modelValue: value })
},
},
})
models.set(wrapper, initial)
return wrapper
}
function tokensOf(wrapper: VueWrapper): RecipientToken[] {
return models.get(wrapper) ?? []
}
describe('RecipientField', () => {
it('Enter 로 토큰을 확정하고, Enter 가 폼을 제출하지 않게 막는다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.setValue('doyun.lee@arasystem.it.kr')
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
input.element.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(event.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('쉼표와 입력칸을 벗어날 때(blur)도 확정한다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.setValue('a@arasystem.it.kr')
await input.trigger('keydown', { key: ',' })
await input.setValue('b@arasystem.it.kr')
await input.trigger('blur')
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
'a@arasystem.it.kr',
'b@arasystem.it.kr',
])
})
it('한글 조합 중의 Enter 는 토큰으로 만들지 않는다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.setValue('이도')
await input.trigger('keydown', { key: 'Enter', isComposing: true })
expect(tokensOf(wrapper)).toEqual([])
})
it('형식이 틀린 주소는 빨간 물결 밑줄 토큰이 되고 입력칸이 오류 안내를 가리킨다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.setValue('kim@arasystem')
await input.trigger('keydown', { key: 'Enter' })
expect(wrapper.get('.recipient-chip').classes()).toContain('is-invalid')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBe('compose-recipient-error')
})
it('여러 주소를 붙여 넣으면 한 번에 나눠 토큰으로 만든다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
const paste = new Event('paste', { cancelable: true }) as ClipboardEvent
Object.defineProperty(paste, 'clipboardData', {
value: { getData: () => 'a@b.kr, 박준호 <junho.park@arasystem.it.kr>' },
})
input.element.dispatchEvent(paste)
await wrapper.vm.$nextTick()
expect(paste.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
'a@b.kr',
'박준호 <junho.park@arasystem.it.kr>',
])
})
it('빈 입력칸에서 Backspace 는 마지막 토큰을 지우고, 삭제 버튼은 그 토큰만 지운다', async () => {
const wrapper = mountField()
const input = wrapper.get('input')
await input.setValue('a@b.kr, c@d.kr, e@f.kr')
await input.trigger('blur')
await input.trigger('keydown', { key: 'Backspace' })
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['a@b.kr', 'c@d.kr'])
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
})
})
@@ -0,0 +1,243 @@
<script setup lang="ts">
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import {
hasOpenRecipientGroup,
mergeRecipientTokens,
parseRecipient,
recipientTokenKey,
splitRecipientInput,
type RecipientToken,
} from '@/shared/utils/mail-address'
interface Props {
modelValue: RecipientToken[]
label: string
inputId: string
placeholder?: string
disabled?: boolean
/** 오류 안내 문단 id — 형식이 틀린 주소가 있으면 입력칸과 연결한다 */
errorId?: string
}
interface Emits {
(e: 'update:modelValue', value: RecipientToken[]): void
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '',
disabled: false,
errorId: '',
})
const emit = defineEmits<Emits>()
/** 아직 토큰이 되지 않은 입력 */
const draft = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
const tokens = splitRecipientInput(text)
.map(parseRecipient)
.filter((token): token is RecipientToken => token !== null)
draft.value = ''
if (tokens.length === 0) return
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
}
function onKeydown(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
if (event.isComposing) return
const isSeparator =
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
if (event.key === 'Enter' || isSeparator) {
// Enter 가 폼을 제출(메일 발송)하지 않게 막는다
event.preventDefault()
commit()
return
}
if (event.key === 'Backspace' && draft.value === '' && props.modelValue.length > 0) {
emit('update:modelValue', props.modelValue.slice(0, -1))
}
}
function onPaste(event: ClipboardEvent) {
const text = event.clipboardData?.getData('text') ?? ''
// 주소 하나면 평소처럼 붙여 넣고, 여러 개면 바로 토큰으로 나눈다
if (!/[,;\n]/.test(text)) return
event.preventDefault()
commit(`${draft.value}${text}`)
}
function remove(token: RecipientToken) {
const key = recipientTokenKey(token)
emit(
'update:modelValue',
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
)
inputRef.value?.focus()
}
function focus() {
inputRef.value?.focus()
}
defineExpose({ commit, focus })
</script>
<template>
<div class="recipient-field">
<label
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
>
<span class="recipient-chip__label">{{ token.label }}</span>
<span
v-if="!token.valid"
class="visually-hidden"
>(주소 형식 오류)</span>
<button
type="button"
class="recipient-chip__remove"
:aria-label="`${token.label} 삭제`"
:disabled="disabled"
@click="remove(token)"
>
<AppIcon
name="x"
:size="12"
:stroke-width="2.25"
/>
</button>
</span>
<input
:id="inputId"
ref="inputRef"
v-model="draft"
type="text"
inputmode="email"
autocomplete="off"
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
:aria-invalid="hasInvalid"
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
@keydown="onKeydown"
@blur="commit()"
@paste="onPaste"
>
</div>
<slot name="trailing" />
</div>
</template>
<style scoped>
.recipient-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
min-height: var(--size-touch);
/* 쓰기 화면의 다른 줄과 같은 콘텐츠 열 32px 선 */
padding: var(--space-4) var(--content-toolbar-start) var(--space-4) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.recipient-field__label {
flex: 0 0 4rem;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.recipient-field__control {
flex: 1 1 20rem;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
}
.recipient-field__input {
flex: 1 1 12.5rem;
min-width: 7.5rem;
min-height: 2.125rem;
border: 0;
background: transparent;
color: var(--color-text);
}
.recipient-field__input:focus-visible {
outline: none;
}
/* 테두리 없는 넓은 입력 영역이라 쓰기 화면의 제목·본문 칸과 같이 1px 로 가볍게 표시한다 */
.recipient-field__control:focus-within {
outline: var(--border-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
border-radius: var(--radius-xs);
}
.recipient-chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: var(--space-28);
max-width: 100%;
padding: 0 var(--space-2) 0 var(--space-8);
border-radius: var(--radius-xs);
background: var(--color-surface-muted);
color: var(--color-text);
}
.recipient-chip__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 형식 오류 — 디자인 원본의 빨간 물결 밑줄 */
.recipient-chip.is-invalid {
background: var(--color-danger-surface);
color: var(--color-danger);
}
.recipient-chip.is-invalid .recipient-chip__label {
text-decoration: underline wavy var(--color-danger);
text-underline-offset: var(--space-3);
}
.recipient-chip__remove {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: inherit;
}
.recipient-chip__remove:hover {
background: var(--color-border);
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
// "분할 | 목록" 보기 전환 (디자인 원본 Main · MainList 목록 머리)
// 선택은 사용자별로 기억한다 (stores/mail-view.store.ts).
//
// 크기: 한 칸 높이 30px, 테두리·안쪽 여백을 더해 바깥 36px — 클릭 영역 44px 기준의 의도된 예외다.
// 목록 머리에 정보를 촘촘히 두기 위해 사용자가 확정했다 (디자인 README "디자인 원칙").
import AppIcon from '@/components/AppIcon.vue'
import { useMailViewStore, type MailViewMode } from '@/stores/mail-view.store'
const viewStore = useMailViewStore()
const OPTIONS: readonly { mode: MailViewMode; label: string; icon: 'view-split' | 'view-list' }[] = [
{ mode: 'split', label: '분할', icon: 'view-split' },
{ mode: 'list', label: '목록', icon: 'view-list' },
]
</script>
<template>
<div
class="view-switch"
role="group"
aria-label="보기 방식"
>
<button
v-for="option in OPTIONS"
:key="option.mode"
type="button"
:class="['view-switch__item', { 'is-active': viewStore.mode === option.mode }]"
:aria-pressed="viewStore.mode === option.mode"
@click="viewStore.setMode(option.mode)"
>
<AppIcon
:name="option.icon"
:size="14"
:stroke-width="1.9"
/>
<span>{{ option.label }}<span class="visually-hidden"> 보기</span></span>
</button>
</div>
</template>
<style scoped>
.view-switch {
display: inline-flex;
gap: var(--space-2);
padding: var(--space-2);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.view-switch__item {
display: inline-flex;
align-items: center;
gap: var(--space-4);
min-height: var(--size-segment-item);
padding: 0 var(--space-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-strong);
font-size: var(--font-size-small);
font-weight: 500;
}
.view-switch__item:hover:not(.is-active) {
background: var(--color-surface-muted);
}
.view-switch__item.is-active {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
</style>
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { APP_MODULES } from '@/shared/constants/app-modules'
import { createTestRouter } from '@/test-utils/router'
import AppRail from './AppRail.vue'
describe('AppRail', () => {
async function mountRail() {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(AppRail, { props: { activeModule: 'mail' }, global: { plugins: [router] } })
}
it('모듈 목록 하나로 그린다 — 메일 · 캘린더 · 파일 · ERP · 주소록 · 설정', async () => {
const wrapper = await mountRail()
const labels = wrapper.findAll('.rail-item').map((item) => item.text().replace('(준비 중)', '').trim())
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
})
it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
const wrapper = await mountRail()
const current = wrapper.get('a[aria-current="page"]')
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
})
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
const wrapper = await mountRail()
const disabled = wrapper.findAll('button[aria-disabled="true"]')
expect(disabled).toHaveLength(APP_MODULES.length - 1)
for (const item of disabled) {
expect(item.attributes('title')).toContain('준비 중')
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
}
})
})
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
// 전역 앱 레일 — 모듈 목록(APP_MODULES) 하나로 그린다. 새 모듈은 목록에만 추가하면 된다.
import { computed } from 'vue'
import AppRailItem from './AppRailItem.vue'
import logoMark from '@/assets/brand/logo-ara-white.png'
import { APP_MODULES, type AppModuleId } from '@/shared/constants/app-modules'
import { APP_NAME } from '@/shared/constants/brand'
interface Props {
/** 지금 보고 있는 모듈 */
activeModule: AppModuleId
}
defineProps<Props>()
const mainModules = computed(() => APP_MODULES.filter((module) => module.placement === 'main'))
const endModules = computed(() => APP_MODULES.filter((module) => module.placement === 'end'))
</script>
<template>
<nav
class="app-rail"
aria-label="사내 시스템"
>
<RouterLink
to="/"
class="app-rail__logo"
>
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 320×111 -->
<img
:src="logoMark"
:alt="APP_NAME"
width="320"
height="111"
class="app-rail__logo-image"
>
</RouterLink>
<ul class="app-rail__list">
<li
v-for="module in mainModules"
:key="module.id"
>
<AppRailItem
:module="module"
:active="module.id === activeModule"
/>
</li>
</ul>
<ul class="app-rail__list app-rail__list--end">
<li
v-for="module in endModules"
:key="module.id"
>
<AppRailItem
:module="module"
:active="module.id === activeModule"
/>
</li>
</ul>
</nav>
</template>
<style scoped>
.app-rail {
flex: 0 0 var(--size-rail);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-14);
padding: var(--space-10) 0 var(--space-12);
background: var(--color-brand-surface);
}
.app-rail__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-rail-item);
min-height: var(--size-touch);
border-radius: var(--radius-lg);
}
.app-rail__logo:focus-visible {
outline-color: var(--color-on-brand-link);
}
.app-rail__logo-image {
display: block;
width: 3rem;
height: auto;
}
.app-rail__list {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
}
.app-rail__list--end {
margin-top: auto;
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
// 앱 레일의 모듈 한 칸 — 라우트가 있으면 링크, 없으면 "준비 중" 비활성 칸 (뱃지 없이)
import AppIcon from '@/components/AppIcon.vue'
import { MODULE_NOT_READY_LABEL, type AppModule } from '@/shared/constants/app-modules'
interface Props {
module: AppModule
/** 지금 보고 있는 모듈인지 */
active?: boolean
}
withDefaults(defineProps<Props>(), {
active: false,
})
</script>
<template>
<RouterLink
v-if="module.routeName"
:to="{ name: module.routeName }"
:class="['rail-item', { 'is-active': active }]"
:aria-current="active ? 'page' : undefined"
>
<AppIcon
:name="module.icon"
:size="20"
/>
<span>{{ module.label }}</span>
</RouterLink>
<!-- 준비 중인 모듈 — 초점은 받아 존재를 알리되 눌러도 아무 일도 하지 않는다 -->
<button
v-else
type="button"
class="rail-item is-disabled"
aria-disabled="true"
:title="`${module.label} (${MODULE_NOT_READY_LABEL})`"
>
<AppIcon
:name="module.icon"
:size="20"
/>
<span>{{ module.label }}</span>
<span class="visually-hidden">({{ MODULE_NOT_READY_LABEL }})</span>
</button>
</template>
<style scoped>
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
width: var(--size-rail-item);
min-height: var(--space-56);
padding: 0;
border: 0;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-on-brand-muted);
font-size: var(--font-size-caption);
font-weight: 500;
text-decoration: none;
}
a.rail-item:hover {
color: var(--color-on-brand);
background: var(--color-brand-surface-active);
}
.rail-item.is-active {
background: var(--color-brand-surface-active);
color: var(--color-on-brand);
font-weight: 600;
}
.rail-item.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 어두운 바탕에서는 강조색 테두리가 잘 보이지 않아 밝은 색으로 바꾼다 */
.rail-item:focus-visible {
outline-color: var(--color-on-brand-link);
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
import { computed } from 'vue'
import AppRail from './AppRail.vue'
import AppTopBar from './AppTopBar.vue'
import { findAppModule, type AppModuleId } from '@/shared/constants/app-modules'
interface Props {
moduleId: AppModuleId
}
const props = defineProps<Props>()
const moduleLabel = computed(() => findAppModule(props.moduleId).label)
</script>
<template>
<div class="app-shell">
<AppRail :active-module="moduleId" />
<div class="app-shell__main">
<AppTopBar :title="moduleLabel" />
<div class="app-shell__module">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.app-shell {
display: flex;
min-height: 100vh;
background: var(--color-surface);
}
.app-shell__main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
/* 모듈 영역 — 좁은 화면에서는 칸이 아래로 접힌다 (디자인 원본의 flex-wrap) */
.app-shell__module {
flex: 1 1 auto;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
/*
* 넓은 화면(tokens.css 화면 폭 기준)에서는 화면 높이에 맞추고 칸마다 따로 스크롤한다.
* 줄바꿈(wrap)을 끄는 이유: 여러 줄 flex 는 줄 높이를 내용에 맞춰 늘리므로 칸이 화면 높이에 묶이지 않는다.
* 이 폭에서는 칸이 원래 한 줄에 다 들어간다.
*/
@media (min-width: 70rem) {
.app-shell {
height: 100vh;
overflow: hidden;
}
.app-shell__main,
.app-shell__module {
min-height: 0;
}
.app-shell__module {
flex-wrap: nowrap;
}
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { useAuth } from '@/composables/useAuth'
import { MODULE_NOT_READY_LABEL } from '@/shared/constants/app-modules'
import { initialOf } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
interface Props {
/** 지금 모듈 이름 (예: 메일) */
title: string
}
defineProps<Props>()
const router = useRouter()
const authStore = useAuthStore()
const { logout } = useAuth()
const loggingOut = ref<boolean>(false)
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
async function onLogout() {
if (loggingOut.value) return
loggingOut.value = true
try {
await logout()
} finally {
loggingOut.value = false
}
await router.push({ name: 'login' })
}
</script>
<template>
<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
>
</div>
<div
v-if="authStore.user"
class="top-bar__account"
>
<span
class="top-bar__avatar"
aria-hidden="true"
>{{ initial }}</span>
<div class="top-bar__who">
<span class="top-bar__name">{{ authStore.user.name }}</span>
<span class="top-bar__email">{{ authStore.user.email }}</span>
</div>
<button
type="button"
class="top-bar__logout"
:disabled="loggingOut"
@click="onLogout"
>
<AppIcon
name="log-out"
:size="15"
/>
<span>로그아웃</span>
</button>
</div>
</header>
</template>
<style scoped>
.top-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-16);
min-height: var(--size-topbar-min);
padding: var(--space-6) var(--space-12) var(--space-6) var(--space-20);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
.top-bar__title {
font-size: var(--font-size-title);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.top-bar__search {
flex: 1 1 17.5rem;
max-width: 32.5rem;
display: flex;
align-items: center;
gap: var(--space-8);
height: 2.5rem;
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
color: var(--color-text-muted);
cursor: not-allowed;
}
.top-bar__search-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
}
.top-bar__search-input:disabled {
cursor: not-allowed;
}
.top-bar__account {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-10);
}
.top-bar__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-round);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
.top-bar__who {
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.top-bar__name {
font-weight: 600;
}
.top-bar__email {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.top-bar__logout {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.top-bar__logout:hover {
background: var(--color-surface-muted);
}
</style>