feat(frontend): 설정(비밀번호 · 부재중 응답) · 메일함 관리 · 스팸 신고 · 원문 내려받기 화면

- 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인),
  메일함 패널의 부재중 안내(1분마다 다시 계산)
- 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기
  — 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다
- 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다
- 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 06:47:12 +09:00
parent 05fc3909dd
commit af1e581be1
86 changed files with 7114 additions and 302 deletions
@@ -1,19 +1,24 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
import { onMounted, ref, watch } from 'vue'
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
import { computed, onMounted, ref, watch } from 'vue'
interface Props {
open: boolean
title: string
message: string
/** 제목 아래 글 — 없으면 그리지 않는다 (입력 칸만 있는 대화상자) */
message?: string
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
titleId: string
messageId: string
messageId?: string
/** 글이 없을 때 aria-describedby 가 가리킬 요소의 id (입력 칸의 안내 등) */
describedBy?: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
}
@@ -23,9 +28,16 @@ interface Emits {
(e: 'cancel'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
message: '',
messageId: undefined,
describedBy: undefined,
role: undefined,
})
const emit = defineEmits<Emits>()
const descriptionId = computed(() => (props.message ? props.messageId : props.describedBy))
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
let returnFocus: HTMLElement | null = null
@@ -88,7 +100,7 @@ onMounted(() => {
class="modal-dialog"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="messageId"
:aria-describedby="descriptionId"
@cancel="onCancel"
@close="onClose"
>
@@ -99,11 +111,13 @@ onMounted(() => {
{{ title }}
</h2>
<p
v-if="message"
:id="messageId"
class="modal-dialog__message"
>
{{ message }}
</p>
<slot />
<div class="modal-dialog__actions">
<slot name="actions" />
</div>
@@ -164,16 +178,25 @@ onMounted(() => {
font-weight: 600;
}
:slotted(.modal-dialog__action:hover) {
:slotted(.modal-dialog__action:not(:disabled):hover) {
background: var(--color-brand-strong);
}
/*
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
*/
:slotted(.modal-dialog__action:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
:slotted(.modal-dialog__action--danger) {
background: var(--color-danger);
}
:slotted(.modal-dialog__action--danger:hover) {
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
background: var(--color-danger-strong);
}
@@ -184,7 +207,7 @@ onMounted(() => {
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:hover) {
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
+38 -6
View File
@@ -1,13 +1,22 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
import { formatTraceCode } from '@/shared/utils/format'
import { useToastStore, type Toast } from '@/stores/toast.store'
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
const toastStore = useToastStore()
/** 종류별 아이콘 — 오류 18 · 성공 ✓ 16 · 진행 원의 3/4 (돌아간다) */
const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; strokeWidth: number }>> = {
error: { name: 'alert-circle', size: 18, strokeWidth: 1.75 },
success: { name: 'check', size: 16, strokeWidth: 2.2 },
progress: { name: 'loader', size: 16, strokeWidth: 2 },
}
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
function onFocusOut(event: FocusEvent, id: number) {
const next = event.relatedTarget
@@ -44,9 +53,9 @@ function runAction(toast: Toast) {
@focusout="onFocusOut($event, toast.id)"
>
<AppIcon
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
:size="toast.kind === 'error' ? 18 : 16"
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
:name="TOAST_ICONS[toast.kind].name"
:size="TOAST_ICONS[toast.kind].size"
:stroke-width="TOAST_ICONS[toast.kind].strokeWidth"
class="toast__icon"
/>
<div class="toast__body">
@@ -121,7 +130,8 @@ function runAction(toast: Toast) {
box-shadow: var(--shadow-toast);
}
.toast--success {
.toast--success,
.toast--progress {
align-items: center;
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
border-radius: var(--radius-lg);
@@ -143,6 +153,28 @@ function runAction(toast: Toast) {
color: var(--color-on-brand-success);
}
/* 진행 — 돌아가는 아이콘 · 숫자 폭을 고정해 "600 / 1,234통"이 바뀔 때 글자가 흔들리지 않게 */
.toast--progress {
min-height: var(--size-touch);
padding-right: var(--space-14);
}
.toast--progress .toast__icon {
margin-top: 0;
color: var(--color-on-brand-link);
animation: toast-spin 0.9s linear infinite;
}
.toast--progress .toast__message {
font-variant-numeric: tabular-nums;
}
@keyframes toast-spin {
to {
transform: rotate(360deg);
}
}
.toast__body {
flex: 1 1 auto;
min-width: 0;
@@ -13,8 +13,8 @@ interface Props {
/** 툴팁 — 주지 않으면 이름 */
title?: string
labelMode?: 'always' | 'wide' | 'never'
/** danger: 삭제 · 영구 삭제 (빨강) */
tone?: 'default' | 'danger'
/** danger: 삭제 · 영구 삭제 (빨강) · accent: 스팸 아님 (강조 글자 굵게) */
tone?: 'default' | 'danger' | 'accent'
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
menu?: boolean
disabled?: boolean
@@ -108,6 +108,16 @@ defineExpose({
color: var(--color-danger-strong);
}
/* 스팸 아님 — 읽기 화면과 같은 강조 글자 (디자인 원본 MailActions4) */
.bulk-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
}
.bulk-button--accent:hover:not(:disabled) {
color: var(--color-brand-strong);
}
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
.bulk-button--label-never {
width: var(--size-icon-button);
+41 -12
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { formatCount } from '@/shared/utils/format'
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -29,6 +32,11 @@ const countText = computed(() => {
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
})
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
const emptiable = computed(() => {
const box = mailStore.currentMailbox
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
})
</script>
<template>
@@ -40,16 +48,23 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
{{ 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 class="list-header__end">
<MailboxEmptyButton
v-if="emptiable"
:mailbox="emptiable"
:variant="variant"
/>
<ViewModeSwitch />
<button
type="button"
class="list-header__refresh"
aria-label="새로고침"
:disabled="busy"
@click="mailStore.refresh()"
>
<AppIcon name="refresh" />
</button>
</div>
</div>
</template>
@@ -70,19 +85,33 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
}
.list-header__title {
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
/* 좁으면 이름보다 먼저 줄어든다 */
.list-header__count {
flex: 0 100 auto;
min-width: 0;
overflow: hidden;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-header__switch {
.list-header__end {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-8);
margin-left: auto;
}
@@ -109,7 +109,7 @@ describe('여러 통 처리 도구 줄', () => {
const bulkPatches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 스팸 신고 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
store.toggleCheck('m2')
@@ -117,7 +117,7 @@ describe('여러 통 처리 도구 줄', () => {
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '스팸 신고', '삭제', '선택 해제'])
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
@@ -376,4 +376,60 @@ describe('여러 통 처리 도구 줄', () => {
release(ok({ updatedIds: ['m1'], failedIds: [] }))
await flushPromises()
})
describe('스팸 신고 · 스팸 아님 (4단계 — 디자인 원본 MailActions4)', () => {
it('스팸 신고는 이동 ▾ 과 삭제 사이 — PATCH { ids, mailboxId: 스팸메일함, isJunk: true }, 알림 + [되돌리기](원래 메일함 · isJunk: false)', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
store.toggleCheck('m2')
await flushPromises()
const tools = wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))
expect(tools.slice(3, 6)).toEqual(['move', 'spam', 'delete'])
await wrapper.get('[data-tool="spam"]').trigger('click')
await flushPromises()
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], mailboxId: 'junk', isJunk: true }])
expect(store.messages.map((message) => message.id)).toEqual(['m3'])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 2통을 스팸메일함으로 옮겼습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
})
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
store.toggleCheck('j1')
await flushPromises()
const notSpam = wrapper.get('[data-tool="spam"]')
expect(notSpam.attributes('aria-label')).toBe('스팸 아님')
expect(notSpam.classes()).toContain('bulk-button--accent')
await notSpam.trigger('click')
await flushPromises()
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
})
it('스팸메일함을 모르면 스팸 단추를 그리지 않는다 — 로빙 tabindex 도 건너뛴다', async () => {
const { wrapper, store } = await mountView()
store.mailboxes = MAILBOXES.filter((mailbox) => mailbox.role !== 'junk')
store.toggleCheck('m1')
await flushPromises()
expect(wrapper.find('[data-tool="spam"]').exists()).toBe(false)
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement).toBe(wrapper.get('[data-tool="delete"]').element)
})
})
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 스팸 신고(스팸메일함에서는 "스팸 아님") ·
// 삭제(휴지통에서는 "영구 삭제", 빨강) … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
// 스팸 단추는 옮길 메일함(스팸메일함 · 받은편지함)을 알 때만 그린다 (디자인 README "4단계 › 스팸 · 원문")
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
@@ -14,7 +15,7 @@ import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { useMailStore } from '@/stores/mail.store'
@@ -29,8 +30,8 @@ const props = defineProps<Props>()
const mailStore = useMailStore()
const bulk = useBulkMailActions()
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'spam' | 'delete' | 'clear'
const ALL_TOOLS: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'spam', 'delete', 'clear']
interface LeadingTool {
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
@@ -46,17 +47,23 @@ const purgeOpen = ref<boolean>(false)
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
let lastFocusedTool: ToolId | null = null
/** 그리는 단추 — 스팸 단추는 옮길 메일함을 알 때만 */
const toolOrder = computed<ToolId[]>(() => ALL_TOOLS.filter((id) => id !== 'spam' || bulk.spamTarget.value !== null))
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
const disabledTools = computed<ReadonlySet<ToolId>>(
() => new Set<ToolId>(bulk.busy.value ? ['move', 'spam', 'delete'] : []),
)
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
const enabledTools = computed<ToolId[]>(() => toolOrder.value.filter((id) => !disabledTools.value.has(id)))
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면(또는 사라지면) 그다음(끝이면 처음부터) 누를 수 있는 단추 */
const rovingTool = computed<ToolId>(() => {
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
const start = TOOL_ORDER.indexOf(activeTool.value)
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
if (candidate && !disabledTools.value.has(candidate)) return candidate
const order = toolOrder.value
const usable = (id: ToolId | undefined): id is ToolId => id !== undefined && order.includes(id) && !disabledTools.value.has(id)
if (usable(activeTool.value)) return activeTool.value
const start = ALL_TOOLS.indexOf(activeTool.value)
for (let step = 1; step < ALL_TOOLS.length; step += 1) {
const candidate = ALL_TOOLS[(start + step) % ALL_TOOLS.length]
if (usable(candidate)) return candidate
}
return 'clear'
})
@@ -65,6 +72,7 @@ const rovingTool = computed<ToolId>(() => {
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
const leadingTools = computed<LeadingTool[]>(() => [
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
@@ -120,7 +128,7 @@ function onKeydown(event: KeyboardEvent) {
function onFocusIn(event: FocusEvent) {
const element = event.target instanceof HTMLElement ? event.target : null
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
const tool = ALL_TOOLS.find((id) => id === element?.dataset.tool)
if (!tool) return
activeTool.value = tool
lastFocusedTool = tool
@@ -188,6 +196,17 @@ watch(disabledTools, (disabled) => {
:tabindex="tabindexOf('move')"
@move="bulk.moveTo"
/>
<MailBulkButton
v-if="bulk.spamTarget.value"
:icon="bulk.inJunk.value ? 'inbox' : 'alert-octagon'"
:label="spamLabel"
:label-mode="labelMode"
:tone="bulk.inJunk.value ? 'accent' : 'default'"
:disabled="bulk.busy.value"
data-tool="spam"
:tabindex="tabindexOf('spam')"
@click="bulk.moveSpam()"
/>
<MailBulkButton
icon="trash"
:label="deleteLabel"
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { CLOCK } from '@/composables/useClock'
import type { MailVacation } from '@/shared/types/mail'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { createTestRouter } from '@/test-utils/router'
import MailVacationNotice from './MailVacationNotice.vue'
// 메일함 패널 아래 부재중 안내 — 시각에 따라 바뀌므로 1분마다 다시 계산한다 (손으로 돌리는 시계로 시간을 넘긴다)
// 날짜는 브라우저 시간대 기준이라 기대값도 로컬 Date 로 만든다
const ONE_MINUTE_MS = 60_000
function vacation(partial: Partial<MailVacation>): MailVacation {
return { isEnabled: true, fromDate: null, toDate: null, subject: '부재중입니다', textBody: '자리를 비웁니다.', ...partial }
}
describe('MailVacationNotice — 1분마다 다시 계산', () => {
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
})
async function mountNotice(value: MailVacation, clock: ManualClock) {
const router = createTestRouter()
await router.push('/mail')
await router.isReady()
wrapper = mount(MailVacationNotice, {
props: { vacation: value },
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
})
return wrapper
}
const detail = (view: VueWrapper) => view.find('[data-vacation-notice] .vacation-notice__detail')
it('끝나는 시각이 지나면 1분 안에 안내가 사라진다 — 새로고침을 기다리지 않는다', async () => {
const clock = createManualClock(new Date(2026, 10, 3, 23, 59, 30).getTime())
const view = await mountNotice(vacation({ toDate: endOfDayIso('2026-11-03') }), clock)
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
clock.advance(ONE_MINUTE_MS)
await nextTick()
expect(view.find('[data-vacation-notice]').exists()).toBe(false)
})
it('시작 전에는 기간을 보이다가, 시작하면 끝나는 날 문구로 바뀐다', async () => {
const clock = createManualClock(new Date(2026, 9, 29, 23, 59, 30).getTime())
const view = await mountNotice(
vacation({ fromDate: startOfDayIso('2026-10-30'), toDate: endOfDayIso('2026-11-03') }),
clock,
)
expect(detail(view).text()).toBe('10월 30일 ~ 11월 3일 · 설정')
clock.advance(ONE_MINUTE_MS)
await nextTick()
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
})
it('타이머는 하나만 걸어 두고 이어 걸며, 화면을 떠나면 거둔다', async () => {
const clock = createManualClock(new Date(2026, 9, 29, 9, 0).getTime())
const view = await mountNotice(vacation({}), clock)
expect(detail(view).text()).toBe('끌 때까지 · 설정')
expect(clock.pendingCount()).toBe(1)
clock.advance(ONE_MINUTE_MS * 3)
expect(clock.pendingCount()).toBe(1)
view.unmount()
wrapper = null
expect(clock.pendingCount()).toBe(0)
})
})
@@ -0,0 +1,101 @@
<script setup lang="ts">
// 부재중 자동 응답 안내 — 메일함 패널 아래(사용량 위) (디자인 원본 MailboxManage · README "4단계 › 부재중 자동 응답")
// 바탕 #FFF7E6 · #7A5200, 높이 44 이상: "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 설정 › 메일 › 부재중 자동 응답으로.
// 켜져 있는 동안만 보인다 — 꺼져 있거나 끝나는 날이 지났으면 그리지 않는다 (아직 시작하지 않았으면 시작일을 보인다, mail-vacation.ts).
// 시각에 따라 바뀌므로 1분마다 다시 계산한다 (useClock — 테스트는 손으로 돌리는 시계)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useClock, type ClockTimer } from '@/composables/useClock'
import { VACATION_NOTICE_REFRESH_MS, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { MailVacation } from '@/shared/types/mail'
import { vacationNoticeText } from '@/shared/utils/mail-vacation'
interface Props {
vacation: MailVacation | null
}
const props = defineProps<Props>()
const clock = useClock()
/** 안내를 계산한 시각 — 1분마다 바뀐다 */
const now = ref<number>(clock.now())
let timer: ClockTimer | null = null
const detail = computed(() => vacationNoticeText(props.vacation, new Date(now.value)))
function tick() {
now.value = clock.now()
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
}
onMounted(() => {
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
})
onBeforeUnmount(() => {
if (timer !== null) clock.clearTimeout(timer)
timer = null
})
</script>
<template>
<RouterLink
v-if="detail"
:to="{ name: SETTINGS_ROUTES.vacation }"
class="vacation-notice"
data-vacation-notice
>
<AppIcon
name="calendar"
:size="15"
class="vacation-notice__icon"
/>
<span class="vacation-notice__text">
<span class="vacation-notice__title">{{ VACATION_TEXT.noticeTitle }}</span>
<span class="vacation-notice__detail">{{ detail }}</span>
</span>
</RouterLink>
</template>
<style scoped>
.vacation-notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
min-height: var(--size-touch);
padding: var(--space-8) var(--space-10);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
text-decoration: none;
}
.vacation-notice:hover {
color: var(--color-warning-text);
}
.vacation-notice:hover .vacation-notice__detail {
text-decoration: underline;
}
.vacation-notice__icon {
margin-top: var(--space-2);
}
.vacation-notice__text {
display: flex;
flex-direction: column;
line-height: var(--line-height-heading);
}
.vacation-notice__title {
font-weight: 600;
}
.vacation-notice__detail {
font-size: var(--font-size-small);
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
import { computed } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
mailboxName: string
/** 안의 메일 수 */
messageCount: number
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
hasChildren: boolean
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
blocked?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
blocked: false,
})
const emit = defineEmits<Emits>()
const title = computed(() =>
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
)
const message = computed(() => {
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
})
</script>
<template>
<ModalDialog
id="mailbox-delete-dialog"
:open="open"
role="alertdialog"
:title="title"
:message="message"
title-id="mailbox-delete-title"
message-id="mailbox-delete-message"
@cancel="emit('cancel')"
>
<template #actions>
<button
v-if="hasChildren"
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
</button>
<template v-else>
<button
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 비우기: 확인하면 닫고 비우기를 시작한다
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { notifyApiError } from '@/composables/useApi'
import { useMailboxActions } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import { hasChildMailbox } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
request: MailboxDialogRequest | null
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const mailStore = useMailStore()
const actions = useMailboxActions()
const pending = ref<boolean>(false)
const nameError = ref<string | null>(null)
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
let unmounted = false
const nameMode = computed<'create' | 'rename' | null>(() => {
const kind = props.request?.kind
return kind === 'create' || kind === 'rename' ? kind : null
})
/** 대상 메일함 — 연 뒤에 바뀐 개수 · 이름을 쓰도록 지금 목록에서 다시 찾는다 */
const target = computed<Mailbox | null>(() => {
const request = props.request
if (!request || request.kind === 'create') return null
return mailStore.mailboxes.find((mailbox) => mailbox.id === request.mailbox.id) ?? request.mailbox
})
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
function close() {
pending.value = false
nameError.value = null
emit('close')
}
async function onSubmitName(name: string) {
const request = props.request
if (!request || pending.value) return
if (request.kind === 'rename' && name === target.value?.name) {
close()
return
}
pending.value = true
const result =
request.kind === 'rename'
? await actions.rename(target.value ?? request.mailbox, name)
: request.kind === 'create'
? await actions.create(name, request.parent?.id ?? null)
: null
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
nameError.value = result.error.message
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
}
}
function onConfirmDelete() {
const mailbox = target.value
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
if (!mailbox || deleteBlocked.value) return
close()
void actions.remove(mailbox)
}
function onConfirmEmpty() {
const mailbox = target.value
if (!mailbox || emptyBlocked.value) return
close()
void actions.empty(mailbox)
}
// 다른 대화상자로 바뀌면 지난 오류를 지운다
watch(
() => props.request,
() => {
nameError.value = null
},
)
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<MailboxNameDialog
:open="nameMode !== null"
:mode="nameMode ?? 'create'"
:parent-name="parentName"
:initial-name="nameMode === 'rename' ? (target?.name ?? '') : ''"
:pending="pending"
:error="nameError"
@submit="onSubmitName"
@cancel="close"
@clear-error="nameError = null"
/>
<MailboxDeleteDialog
:open="request?.kind === 'delete'"
:mailbox-name="target?.name ?? ''"
:message-count="target?.totalCount ?? 0"
:has-children="hasChildren"
:blocked="deleteBlocked"
@confirm="onConfirmDelete"
@cancel="close"
/>
<MailboxEmptyDialog
:open="request?.kind === 'empty'"
:mailbox-name="target?.name ?? ''"
:message-count="target?.totalCount ?? 0"
:blocked="emptyBlocked"
id-prefix="mailbox-empty"
@confirm="onConfirmEmpty"
@cancel="close"
/>
</template>
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
import MessageTable from './MessageTable.vue'
// 휴지통 · 스팸메일함 목록 머리의 「비우기」 (디자인 원본 MailboxManage · 계약 2절 "메일함 관리")
function box(id: string, role: Mailbox['role'], name: string, totalCount: number): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
}
function summary(id: string, mailboxId: string): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '호스팅 안내', email: 'ad@example.test' },
to: [],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-21T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
}
}
describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
let pinia: Pinia
let requests: MockRequest[]
let listed: MessageSummary[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountList(
mailbox: Mailbox,
view: 'split' | 'list' = 'list',
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
) {
listed = [summary('t1', mailbox.id), summary('t2', mailbox.id)]
requests = installMockApi((request) => {
const custom = handler?.(request)
if (custom) return custom
if (request.method === 'DELETE') {
listed = []
return ok({ processed: 2, remaining: 0 })
}
if (request.url === '/messages') return ok({ items: listed, pageInfo: { hasNext: false, nextCursor: null, total: listed.length } })
if (request.url === '/mailboxes') return ok({ items: useMailStore().mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = [box('inbox', 'inbox', '받은편지함', 3), mailbox]
store.currentMailboxId = mailbox.id
store.messages = listed
store.pageInfo = { hasNext: false, nextCursor: null, total: listed.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.isReady()
const wrapper = mount(view === 'split' ? MessageList : MessageTable, {
global: { plugins: [pinia, router] },
attachTo: document.body,
})
await flushPromises()
return { wrapper, store }
}
it('휴지통 — 빨간 글자 「휴지통 비우기」, 확인(처음 초점 「취소」) 뒤 비우고 목록을 다시 받는다 · 되돌리기 없는 알림', async () => {
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
const empty = wrapper.get('[data-mailbox-empty]')
expect(empty.text()).toBe('휴지통 비우기')
await empty.trigger('click')
await flushPromises()
expect(document.getElementById('list-empty-dialog')?.hasAttribute('open')).toBe(true)
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
expect(document.activeElement?.id).toBe('list-empty-cancel')
await wrapper.get('#list-empty-confirm').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/mailboxes/trash/messages',
])
expect(store.messages).toEqual([])
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
['휴지통을 비웠습니다 — 메일 2통.', null],
])
// 비었으니 단추도 사라진다
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
})
it('스팸메일함 · 분할 보기 — 「스팸메일함 비우기」는 아이콘만(이름은 aria-label · 툴팁)', async () => {
const { wrapper } = await mountList(box('junk', 'junk', '스팸메일함', 2), 'split')
const empty = wrapper.get('[data-mailbox-empty]')
expect(empty.classes()).toContain('empty-button--split')
expect(empty.attributes('aria-label')).toBe('스팸메일함 비우기')
expect(empty.attributes('title')).toBe('스팸메일함 비우기')
})
it('빈 휴지통 · 다른 메일함에는 없다', async () => {
const emptyTrash = await mountList(box('trash', 'trash', '휴지통', 0))
expect(emptyTrash.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
emptyTrash.wrapper.unmount()
const inbox = await mountList(box('archive', 'archive', '보관함', 2))
expect(inbox.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
})
const dialogOpen = () => document.getElementById('list-empty-dialog')?.hasAttribute('open') ?? false
const deletes = () => requests.filter((request) => request.method === 'DELETE').map((request) => request.url)
it('확인창은 연 때의 메일함을 붙잡는다 — 연 채 보는 메일함이 바뀌면(뒤로 가기 등) 닫고, 연 적 없는 메일함을 비우지 않는다', async () => {
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
store.mailboxes = [...store.mailboxes, box('junk', 'junk', '스팸메일함', 5)]
await wrapper.get('[data-mailbox-empty]').trigger('click')
await flushPromises()
expect(dialogOpen()).toBe(true)
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
// 확인창을 연 채 뒤로 가기로 스팸메일함을 보게 되었다
store.currentMailboxId = 'junk'
await flushPromises()
expect(dialogOpen()).toBe(false)
expect(deletes()).toEqual([])
// 다시 열면 지금 메일함의 창 — 확인하면 그 메일함만 비운다
await wrapper.get('[data-mailbox-empty]').trigger('click')
await flushPromises()
expect(wrapper.get('#list-empty-title').text()).toBe('스팸메일함의 메일 5통을 모두 영구 삭제할까요?')
await wrapper.get('#list-empty-confirm').trigger('click')
await flushPromises()
expect(deletes()).toEqual(['/mailboxes/junk/messages'])
})
it('메일함을 지우는 동안 「휴지통 비우기」는 누를 수 없다 — 꺼도 초점을 잃지 않게 aria-disabled', async () => {
let finish: (result: MockResult) => void = () => undefined
const deleting = new Promise<MockResult>((resolve) => {
finish = resolve
})
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2), 'list', (request) =>
request.url === '/mailboxes/c' ? deleting : undefined,
)
const custom = box('c', null, '거래처', 12)
store.mailboxes = [...store.mailboxes, custom]
const running = store.runMailboxOperation('delete', custom, () => undefined)
await flushPromises()
const empty = wrapper.get('[data-mailbox-empty]')
expect(empty.attributes('aria-disabled')).toBe('true')
expect(empty.attributes('disabled')).toBeUndefined()
await empty.trigger('click')
await flushPromises()
expect(dialogOpen()).toBe(false)
finish(ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
await running
await flushPromises()
expect(wrapper.get('[data-mailbox-empty]').attributes('aria-disabled')).toBeUndefined()
})
})
@@ -0,0 +1,138 @@
<script setup lang="ts">
// 휴지통 · 스팸메일함 목록 머리의 「휴지통 비우기」 · 「스팸메일함 비우기」 (디자인 원본 MailboxManage — 빨강 글자 · 44)
// 확인 대화상자(MailboxEmptyDialog — 처음 초점 「취소」)를 거쳐 비운다 (useMailboxActions — 진행은 알림으로).
// - 확인창은 연 때의 메일함을 붙잡는다: 열어 둔 채 뒤로 가기 등으로 보는 메일함이 바뀌면 창을 닫는다 — 연 적 없는 메일함을 비우지 않게
// - 비우는 중 · 메일함을 지우는 중(휴지통 — 함께 돌 수 없다)에는 누를 수 없다. 꺼도 초점은 잃지 않게 aria-disabled 로 둔다
// (끝나서 단추가 사라지면 useMailboxActions 가 메일함 패널의 지금 메일함으로 초점을 옮긴다)
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(40×44 — 이름은 툴팁 · aria-label), 목록 보기는 아이콘 + 이름
import { computed, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
import { useMailboxActions } from '@/composables/useMailboxActions'
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
mailbox: Mailbox
variant: 'split' | 'list'
}
const props = defineProps<Props>()
const actions = useMailboxActions()
const authStore = useAuthStore()
const mailStore = useMailStore()
/** 확인창을 연 메일함 — 열려 있는 동안만 */
const target = shallowRef<Mailbox | null>(null)
const label = computed(() => (props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash))
/** 지금은 누를 수 없다 — 비우는 중 · 함께 돌 수 없는 처리가 도는 중 */
const unavailable = computed(
() => actions.isRunning('empty', props.mailbox.id) || actions.isBlocked('empty', props.mailbox),
)
/** 확인창에 보일 메일함 — 연 뒤에 바뀐 개수를 쓰도록 지금 목록에서 다시 찾는다 */
const shown = computed<Mailbox | null>(() => {
const opened = target.value
if (!opened) return null
return mailStore.mailboxes.find((mailbox) => mailbox.id === opened.id) ?? opened
})
const blocked = computed(() => (shown.value ? actions.isBlocked('empty', shown.value) : false))
function open() {
if (unavailable.value) return
target.value = props.mailbox
}
function close() {
target.value = null
}
function onConfirm() {
const mailbox = shown.value
// 누르는 순간 다시 본다 — 그사이 메일함 지우기가 시작됐으면 창을 그대로 둔다
if (!mailbox || blocked.value) return
close()
void actions.empty(mailbox)
}
// 보는 메일함이 바뀌면(뒤로 가기 등) 확인창을 닫는다 — 잡아 둔 메일함과 다른 메일함의 단추가 되었다
watch(
() => props.mailbox.id,
(id) => {
if (target.value && target.value.id !== id) close()
},
)
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인을 닫는다 — 세션 만료 대화상자와 겹치지 않게
watch(() => authStore.generation, close)
</script>
<template>
<button
type="button"
:class="['empty-button', `empty-button--${variant}`]"
:aria-label="label"
:title="label"
:aria-disabled="unavailable ? 'true' : undefined"
data-mailbox-empty
@click="open"
>
<AppIcon
name="trash"
:size="15"
/>
<span class="empty-button__label">{{ label }}</span>
</button>
<MailboxEmptyDialog
:open="shown !== null"
:mailbox-name="shown?.name ?? ''"
:message-count="shown?.totalCount ?? 0"
:blocked="blocked"
id-prefix="list-empty"
@confirm="onConfirm"
@cancel="close"
/>
</template>
<style scoped>
.empty-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-width: var(--size-icon-button);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-danger);
font-weight: 600;
white-space: nowrap;
}
.empty-button:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
.empty-button[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
/* 분할 보기의 목록 칸 — 아이콘만 40×44 */
.empty-button--split {
width: var(--size-icon-button);
padding: 0;
}
.empty-button--split .empty-button__label {
display: none;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 휴지통 · 스팸메일함 비우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
// "휴지통의 메일 21통을 모두 영구 삭제할까요?" · "지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
// 쓰는 곳: 메일함 패널의 ⋯ 메뉴 · 휴지통 / 스팸메일함 목록 머리의 「비우기」 — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
// 메일함을 지우는 동안의 휴지통 비우기는 [영구 삭제]를 누를 수 없다 (blocked — 지우는 메일함의 메일이 휴지통에 들어가자마자 지워지지 않게)
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
mailboxName: string
/** 안의 메일 수 */
messageCount: number
/** 제목 · 글 · 단추의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
/** 지금은 시작할 수 없다 (메일함을 지우는 중의 휴지통) — [영구 삭제]를 끈다 */
blocked?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
withDefaults(defineProps<Props>(), {
blocked: false,
})
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
:id="`${idPrefix}-dialog`"
:open="open"
role="alertdialog"
:title="MAILBOX_DIALOG_TEXT.emptyTitle(mailboxName, formatCount(messageCount))"
:message="MAILBOX_DIALOG_TEXT.emptyMessage"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('cancel')"
>
<template #actions>
<button
:id="`${idPrefix}-confirm`"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.emptyConfirm }}
</button>
<button
:id="`${idPrefix}-cancel`"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
// 메일함 행의 ⋯ 단추와 메뉴 (디자인 원본 MailboxManage · README "4단계 › 메일함 관리")
// - 단추 32×40 — 목록 표의 별 칸과 같은 밀도 예외(--size-mailbox-more). 행에 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다
// (보임 · 숨김은 행 MailboxPanelRow 가 정한다 — 열림을 open-change 로 알린다)
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import { opensUpward } from '@/shared/utils/popup-placement'
interface Props {
/** 메일함 이름 — 단추 · 메뉴의 이름 */
mailboxName: string
items: readonly EditorMenuItem[]
}
interface Emits {
(e: 'select', id: string): void
(e: 'open-change', open: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
const buttonRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 위로 연다 — 아래가 모자랄 때 */
const upward = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function setOpen(next: boolean) {
if (open.value === next) return
open.value = next
emit('open-change', next)
}
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
function visibleArea(element: Element): { top: number; bottom: number } {
let top = 0
let bottom = window.innerHeight
for (let node = element.parentElement; node; node = node.parentElement) {
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
const rect = node.getBoundingClientRect()
top = Math.max(top, rect.top)
bottom = Math.min(bottom, rect.bottom)
}
return { top, bottom }
}
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
function place() {
const button = buttonRef.value
const menu = menuRef.value?.$el
if (!button || !(menu instanceof HTMLElement)) return
const buttonRect = button.getBoundingClientRect()
const menuRect = menu.getBoundingClientRect()
const area = visibleArea(button)
upward.value = opensUpward({
triggerTop: buttonRect.top,
triggerBottom: buttonRect.bottom,
popupHeight: menuRect.height,
areaTop: area.top,
areaBottom: area.bottom,
gap: Math.max(0, menuRect.top - buttonRect.bottom),
})
}
function onClick(event: MouseEvent) {
if (open.value) {
setOpen(false)
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
setOpen(true)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
setOpen(true)
}
function onClose(restoreFocus: boolean) {
setOpen(false)
if (restoreFocus) buttonRef.value?.focus()
}
function onSelect(id: string) {
setOpen(false)
buttonRef.value?.focus()
emit('select', id)
}
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
watch(
open,
(isOpen) => {
upward.value = false
if (isOpen) place()
},
{ flush: 'post' },
)
</script>
<template>
<div class="mailbox-more">
<button
ref="buttonRef"
type="button"
class="mailbox-more__button"
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
aria-haspopup="menu"
:aria-expanded="open"
data-mailbox-more
@click="onClick"
@keydown="onKeydown"
>
<AppIcon
name="more-horizontal"
:size="15"
/>
</button>
<EditorMenu
v-if="open"
ref="menuRef"
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
:items="items"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
.mailbox-more {
position: relative;
flex: 0 0 auto;
display: flex;
}
/* 32×40 — 밀도 예외 (tokens.css --size-mailbox-more · 행 높이 --size-row-min) */
.mailbox-more__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-mailbox-more);
height: var(--size-row-min);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.mailbox-more__button:hover,
.mailbox-more__button[aria-expanded='true'] {
background: var(--color-border);
}
/* 메뉴 폭 196 — 행 오른쪽 끝에 맞춘다 (디자인 원본 MailboxManage) */
.mailbox-more__menu {
min-width: 12.25rem;
}
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
.mailbox-more .mailbox-more__menu--up {
top: auto;
bottom: calc(100% + var(--space-4));
}
</style>
@@ -0,0 +1,182 @@
<script setup lang="ts">
// 메일함 이름 대화상자 — 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 (디자인 원본 MailboxManage, 공용 모양 ModalDialog)
// 이름 칸(44 · 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다." — 오류는 그 자리에 빨강으로
// (같은 이름 MAIL_009 · 형식 VAL_001 — 문구는 에러 코드 사전). 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
// Enter 는 [만들기 · 바꾸기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다). Esc 는 취소와 같다.
// 고정 id — #mailbox-name-dialog · #mailbox-name-title · #mailbox-name-input · #mailbox-name-help · #mailbox-name-confirm · #mailbox-name-cancel
import { computed, nextTick, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import AppIcon from '@/components/AppIcon.vue'
import { getErrorMessage } from '@/composables/useApi'
import { MAILBOX_DIALOG_TEXT, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { mailboxNameProblem } from '@/shared/utils/mailbox'
interface Props {
open: boolean
mode: 'create' | 'rename'
/** 하위 메일함을 만들 곳의 이름 — 맨 위면 null */
parentName?: string | null
/** 처음 이름 — 이름 바꾸기는 지금 이름 */
initialName?: string
/** 보내는 중 — 단추를 다시 누를 수 없다 */
pending?: boolean
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
error?: string | null
}
interface Emits {
(e: 'submit', name: string): void
(e: 'cancel'): void
(e: 'clear-error'): void
}
const props = withDefaults(defineProps<Props>(), {
parentName: null,
initialName: '',
pending: false,
error: null,
})
const emit = defineEmits<Emits>()
const inputRef = ref<HTMLInputElement | null>(null)
const name = ref<string>(props.initialName)
/** 보내기 전에 막은 형식 오류 ('/' · 길이) */
const formatError = ref<string | null>(null)
const title = computed(() => {
if (props.mode === 'rename') return MAILBOX_DIALOG_TEXT.renameTitle
return props.parentName ? MAILBOX_DIALOG_TEXT.createChildTitle(props.parentName) : MAILBOX_DIALOG_TEXT.createTitle
})
const confirmLabel = computed(() => (props.mode === 'rename' ? MAILBOX_DIALOG_TEXT.rename : MAILBOX_DIALOG_TEXT.create))
const shownError = computed(() => formatError.value ?? props.error)
/** 이름을 비웠으면 누를 수 없다 */
const canSubmit = computed(() => mailboxNameProblem(name.value) !== 'empty' && !props.pending)
function onInput(event: Event) {
name.value = (event.target as HTMLInputElement).value
formatError.value = null
if (props.error) emit('clear-error')
}
function submit() {
if (!canSubmit.value) return
if (mailboxNameProblem(name.value) === 'invalid') {
formatError.value = getErrorMessage('VAL_001')
inputRef.value?.focus()
return
}
emit('submit', name.value.trim())
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || isImeComposing(event)) return
event.preventDefault()
submit()
}
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
watch(
() => props.open,
(open) => {
if (!open) return
name.value = props.initialName
formatError.value = null
void nextTick(() => {
if (props.mode === 'rename') inputRef.value?.select()
})
},
)
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
watch(
() => props.error,
(error) => {
if (error) void nextTick(() => inputRef.value?.focus())
},
)
</script>
<template>
<ModalDialog
id="mailbox-name-dialog"
:open="open"
:title="title"
title-id="mailbox-name-title"
described-by="mailbox-name-help"
@cancel="emit('cancel')"
>
<div class="mailbox-name">
<label
for="mailbox-name-input"
class="mailbox-name__label"
>{{ MAILBOX_DIALOG_TEXT.nameLabel }}</label>
<input
id="mailbox-name-input"
ref="inputRef"
class="form-input mailbox-name__input"
type="text"
:value="name"
:maxlength="MAILBOX_NAME_MAX_LENGTH"
autocomplete="off"
:aria-invalid="shownError ? 'true' : 'false'"
aria-describedby="mailbox-name-help"
autofocus
@input="onInput"
@keydown="onKeydown"
>
<p
id="mailbox-name-help"
:class="['form-message', { 'form-message--error': shownError }]"
>
<AppIcon
v-if="shownError"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ shownError ?? MAILBOX_DIALOG_TEXT.nameHelp }}</span>
</p>
</div>
<template #actions>
<button
id="mailbox-name-confirm"
type="button"
class="modal-dialog__action"
:disabled="!canSubmit"
:aria-busy="pending"
@click="submit"
>
{{ confirmLabel }}
</button>
<button
id="mailbox-name-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.mailbox-name {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-top: var(--space-2);
}
.mailbox-name__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.mailbox-name__input {
width: 100%;
}
</style>
@@ -0,0 +1,777 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailVacation, Mailbox } from '@/shared/types/mail'
import { endOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
// 메일함 패널의 메일함 관리 (디자인 원본 MailboxManage · MailActions4 · 계약 2절 "메일함 관리" · "부재중 자동 응답")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
box('junk', 'junk', '스팸메일함', { unreadCount: 1, totalCount: 5 }),
box('trash', 'trash', '휴지통', { totalCount: 21 }),
box('p', null, '프로젝트', { unreadCount: 2, totalCount: 18 }),
box('pq', null, '견적', { parentId: 'p', totalCount: 6 }),
box('c', null, '거래처', { unreadCount: 1, totalCount: 12 }),
]
/** 응답을 직접 풀어 주는 요청 — 진행 알림을 그 사이에 본다 */
interface Deferred {
promise: Promise<MockResult>
resolve: (result: MockResult) => void
}
function deferred(): Deferred {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
type Reply = MockResult | Deferred
describe('MailboxPanel — 메일함 관리', () => {
let pinia: Pinia
let requests: MockRequest[]
/** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
let replies: Map<string, Reply[]>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
replies = new Map()
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function reply(key: string, ...results: Reply[]) {
replies.set(key, results)
}
async function mountPanel(path = '/mail/inbox') {
requests = installMockApi((request) => {
const queue = replies.get(`${request.method} ${request.url}`)
if (queue && queue.length > 0) {
const next = queue.length > 1 ? queue.shift() : queue[0]
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
}
if (request.url === '/mailboxes') {
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 8 } })
}
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
store.mailboxesStatus = 'ready'
const router = createTestRouter()
await router.push(path)
await router.isReady()
const match = /^\/mail\/([^/]+)/.exec(path)
if (match?.[1]) store.currentMailboxId = match[1]
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router, store }
}
const row = (wrapper: VueWrapper, name: string) => {
const found = wrapper.findAll('.mailbox-row').find((item) => item.get('.mailbox-row__name').text() === name)
if (!found) throw new Error(`${name} 줄이 없다`)
return found
}
async function openMenu(wrapper: VueWrapper, name: string) {
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
await flushPromises()
return wrapper.findAll('[role="menu"] [role^="menuitem"]')
}
async function choose(wrapper: VueWrapper, name: string, label: string) {
const items = await openMenu(wrapper, name)
const item = items.find((candidate) => candidate.text() === label)
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
await item.trigger('click')
await flushPromises()
}
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
it('기본 메일함 아래 "내 메일함" 묶음 — 하위 메일함은 들여 쓰고, 끝에 [+ 새 메일함]', async () => {
const { wrapper } = await mountPanel()
const lists = wrapper.findAll('.mailbox-tree__list')
expect(lists[0]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual([
'받은편지함',
'보낸편지함',
'임시보관함',
'스팸메일함',
'휴지통',
])
expect(wrapper.get('.mailbox-tree__group').text()).toBe('내 메일함')
expect(lists[1]?.attributes('aria-labelledby')).toBe(wrapper.get('.mailbox-tree__group').attributes('id'))
expect(lists[1]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual(['거래처', '프로젝트', '견적'])
expect(row(wrapper, '견적').get('a').classes()).toContain('mailbox-row__link--depth-1')
expect(wrapper.get('[data-mailbox-new]').text()).toBe('새 메일함')
// 안 읽은 수는 평범한 숫자 — ⋯ 앞에 그대로
expect(row(wrapper, '받은편지함').get('.mailbox-row__count').text()).toContain('3')
})
it('⋯ 메뉴 — 메일함마다 할 수 있는 일만 · 할 일이 없는 메일함에는 ⋯ 가 없다', async () => {
const { wrapper } = await mountPanel()
const labels = async (name: string) => {
const items = await openMenu(wrapper, name)
const result = items.map((item) => [item.text(), item.classes().includes('menu__item--danger')])
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
await flushPromises()
return result
}
expect(await labels('받은편지함')).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
])
expect(await labels('스팸메일함')).toEqual([
['모두 읽음으로 표시', false],
['스팸메일함 비우기', true],
])
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
expect(await labels('프로젝트')).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['지우기', true],
])
expect(await labels('견적')).toEqual([
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['지우기', true],
])
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
expect(row(wrapper, '임시보관함').find('[data-mailbox-more]').exists()).toBe(false)
})
it('⋯ 단추 — 이름 · aria-expanded, 메뉴가 열린 동안 줄이 is-open(⋯ 가 보인다), Esc 는 메뉴만 닫고 단추로', async () => {
const { wrapper } = await mountPanel()
const more = row(wrapper, '거래처').get('[data-mailbox-more]')
expect(more.attributes('aria-label')).toBe('거래처 메일함 메뉴')
expect(more.attributes('aria-haspopup')).toBe('menu')
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const menu = wrapper.get('[role="menu"]')
expect(menu.attributes('aria-label')).toBe('거래처 메일함')
expect(more.attributes('aria-expanded')).toBe('true')
expect(row(wrapper, '거래처').classes()).toContain('is-open')
expect(document.activeElement?.textContent?.trim()).toBe('모두 읽음으로 표시')
await menu.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(row(wrapper, '거래처').classes()).not.toContain('is-open')
expect(document.activeElement).toBe(more.element)
})
describe('새 메일함 · 하위 메일함 · 이름 바꾸기', () => {
it('[+ 새 메일함] — 이름 칸(64자)에 초점 · 안내, 만들면 POST /mailboxes { name } → 패널에 바로 나타나고 짧은 알림', async () => {
const { wrapper } = await mountPanel()
reply('POST /mailboxes', ok(box('n1', null, '협력사'), 201))
await wrapper.get('[data-mailbox-new]').trigger('click')
await flushPromises()
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
expect(wrapper.get('#mailbox-name-title').text()).toBe('새 메일함')
const input = wrapper.get('#mailbox-name-input')
expect(input.attributes('maxlength')).toBe('64')
expect(document.activeElement).toBe(input.element)
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
// 이름을 비웠으면 누를 수 없다
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
await input.setValue(' 협력사 ')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '협력사' }])
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
expect(wrapper.findAll('.mailbox-row__name').map((name) => name.text())).toContain('협력사')
expect(toastTexts()).toEqual(['‘협력사’ 메일함을 만들었습니다.'])
})
it('하위 메일함 만들기 — "‘프로젝트’ 안에 새 메일함", parentId 를 함께 보낸다', async () => {
const { wrapper } = await mountPanel()
reply('POST /mailboxes', ok(box('n2', null, '2027', { parentId: 'p' }), 201))
await choose(wrapper, '프로젝트', '하위 메일함 만들기')
expect(wrapper.get('#mailbox-name-title').text()).toBe('‘프로젝트’ 안에 새 메일함')
await wrapper.get('#mailbox-name-input').setValue('2027')
await wrapper.get('#mailbox-name-confirm').trigger('click')
await flushPromises()
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '2027', parentId: 'p' }])
})
it('같은 이름(409 MAIL_009) — 알림 없이 칸 아래에 빨간 사전 문구 · 대화상자는 그대로, 고치면 안내로 돌아간다', async () => {
const { wrapper } = await mountPanel()
reply('POST /mailboxes', fail(409, 'MAIL_009'))
await wrapper.get('[data-mailbox-new]').trigger('click')
await flushPromises()
await wrapper.get('#mailbox-name-input').setValue('거래처')
await wrapper.get('#mailbox-name-confirm').trigger('click')
await flushPromises()
const help = wrapper.get('#mailbox-name-help')
expect(help.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
expect(ERROR_CODE_MESSAGES.MAIL_009).toBe('같은 이름의 메일함이 이미 있습니다.')
expect(help.classes()).toContain('form-message--error')
expect(wrapper.get('#mailbox-name-input').attributes('aria-invalid')).toBe('true')
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
expect(useToastStore().toasts).toEqual([])
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
})
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
const { wrapper } = await mountPanel()
await wrapper.get('[data-mailbox-new]').trigger('click')
await flushPromises()
await wrapper.get('#mailbox-name-input').setValue('2026/견적')
await wrapper.get('#mailbox-name-confirm').trigger('click')
await flushPromises()
expect(sent('POST', '/mailboxes')).toEqual([])
expect(wrapper.get('#mailbox-name-help').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
})
it('이름 바꾸기 — 지금 이름으로 채운다. 그대로면 보내지 않고 닫고, 바꾸면 PATCH { name } + "메일함 이름을 바꿨습니다."', async () => {
const { wrapper } = await mountPanel()
reply('PATCH /mailboxes/c', ok(box('c', null, '거래처 (2026)', { totalCount: 12 })))
await choose(wrapper, '거래처', '이름 바꾸기')
expect(wrapper.get('#mailbox-name-title').text()).toBe('메일함 이름 바꾸기')
const input = wrapper.get<HTMLInputElement>('#mailbox-name-input')
expect(input.element.value).toBe('거래처')
await wrapper.get('#mailbox-name-confirm').trigger('click')
await flushPromises()
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
expect(sent('PATCH', '/mailboxes/c')).toEqual([])
await choose(wrapper, '거래처', '이름 바꾸기')
await wrapper.get('#mailbox-name-input').setValue('거래처 (2026)')
await wrapper.get('#mailbox-name-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c').map((request) => request.data)).toEqual([{ name: '거래처 (2026)' }])
expect(row(wrapper, '거래처 (2026)').exists()).toBe(true)
expect(toastTexts()).toEqual(['메일함 이름을 바꿨습니다.'])
})
})
describe('지우기 — 안의 메일은 휴지통으로', () => {
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '프로젝트', '지우기')
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
await wrapper.get('#mailbox-delete-cancel').trigger('click')
await flushPromises()
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
})
it('확인(처음 초점 「취소」) 뒤 DELETE 를 deleted 가 될 때까지 다시 보낸다 — 진행 알림이 결과로 바뀌고, 보던 메일함이면 받은편지함으로', async () => {
const second = deferred()
reply(
'DELETE /mailboxes/c',
ok({ deleted: false, processed: 500, movedToTrash: 500, remaining: 734 }),
second,
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
)
const { wrapper, router, store } = await mountPanel('/mail/c')
await choose(wrapper, '거래처', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함을 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
])
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
const items = await openMenu(wrapper, '거래처')
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
await flushPromises()
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(3)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['success', '‘거래처’ 메일함을 지웠습니다. 안의 메일 1,234통은 휴지통에 있습니다.'],
])
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('빈 메일함은 "빈 메일함입니다." — 지운 뒤 "‘…’ 메일함을 지웠습니다." · 다른 메일함을 보고 있었으면 그대로', async () => {
const { wrapper, router, store } = await mountPanel('/mail/inbox')
store.mailboxes = store.mailboxes.map((mailbox) => (mailbox.id === 'c' ? { ...mailbox, totalCount: 0 } : mailbox))
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
await flushPromises()
await choose(wrapper, '거래처', '지우기')
expect(wrapper.get('#mailbox-delete-message').text()).toBe('빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
})
describe('오래 걸리는 처리 — 모두 읽음 · 비우기', () => {
it('모두 읽음 — remaining 이 0 이 될 때까지 PATCH /mailboxes/{id}/messages { isRead: true } · 안 읽은 수가 바로 줄고 결과 알림', async () => {
const second = deferred()
reply(
'PATCH /mailboxes/inbox/messages',
ok({ processed: 500, remaining: 734 }),
second,
)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
expect(sent('PATCH', '/mailboxes/inbox/messages')[0]?.data).toEqual({ isRead: true })
expect(toastTexts()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 500 / 1,234통'])
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
const items = await openMenu(wrapper, '받은편지함')
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
['모두 읽음으로 표시', true],
['하위 메일함 만들기', false],
])
await row(wrapper, '받은편지함').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ processed: 734, remaining: 0 }))
await flushPromises()
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['success', '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.'],
])
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
})
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '휴지통', '휴지통 비우기')
expect(dialogOpen('mailbox-empty-dialog')).toBe(true)
expect(wrapper.get('#mailbox-empty-title').text()).toBe('휴지통의 메일 21통을 모두 영구 삭제할까요?')
expect(wrapper.get('#mailbox-empty-message').text()).toBe('지운 메일은 되돌릴 수 없습니다.')
expect(document.activeElement?.id).toBe('mailbox-empty-cancel')
await wrapper.get('#mailbox-empty-cancel').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/trash/messages')).toEqual([])
await choose(wrapper, '휴지통', '휴지통 비우기')
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
// 되돌릴 수 없어 [되돌리기]가 없다
expect(useToastStore().toasts[0]?.action).toBeNull()
})
it('도중에 실패하면 거기까지 처리한 수를 알린다 (까닭은 인터셉터 알림) · 처음부터 실패하면 진행 알림만 닫는다', async () => {
reply('PATCH /mailboxes/inbox/messages', ok({ processed: 500, remaining: 734 }), fail(503, 'MAIL_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
useToastStore().clear()
reply('DELETE /mailboxes/junk/messages', fail(503, 'SYS_001'))
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
})
it('서버가 나아가지 못하면(남은 수만 돌려줌) 되풀이를 멈추고 사전 문구로 알린다', async () => {
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 1 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '거래처', '모두 읽음으로 표시')
expect(sent('PATCH', '/mailboxes/c/messages')).toHaveLength(3)
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.BIZ_001])
})
})
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
const vacation: MailVacation = {
isEnabled: true,
fromDate: null,
toDate: endOfDayIso('2099-11-03'),
subject: '부재중 안내',
textBody: '휴가입니다.',
}
const { wrapper } = await mountPanel()
useMailVacationStore().vacation = vacation
useMailStore().quota = { usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }
await flushPromises()
const notice = wrapper.get('[data-vacation-notice]')
expect(notice.text()).toContain('부재중 자동 응답 켜짐')
expect(notice.text()).toContain('2099년 11월 3일까지 · 설정')
expect(notice.attributes('href')).toBe('/settings/mail/vacation')
const footer = wrapper.get('.mailbox-panel__footer').element
expect(footer.firstElementChild).toBe(notice.element)
expect(footer.lastElementChild?.classList.contains('quota')).toBe(true)
useMailVacationStore().vacation = { ...vacation, isEnabled: false }
await flushPromises()
expect(wrapper.find('[data-vacation-notice]').exists()).toBe(false)
})
describe('지우기 · 비우기 — 리뷰 보완', () => {
it('다른 메일함에도 든 메일은 이 메일함에서만 빠진다 — 진행 · 정체는 processed 로, 끝 알림의 휴지통 수는 movedToTrash 합계로', async () => {
reply(
'DELETE /mailboxes/c',
ok({ deleted: false, processed: 500, movedToTrash: 300, remaining: 300 }),
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 200 }),
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 100 }),
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 0 }),
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
)
const { wrapper } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
// 휴지통으로 옮긴 것이 없는 차례가 이어져도 나아가고 있다 — 멈추지 않는다
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(5)
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 300통은 휴지통에 있습니다.'])
})
it('이미 지워진 메일함(404 RES_001)은 끝난 것으로 — 오류 알림 없이 "‘거래처’ 메일함을 지웠습니다." · 메일함 목록을 다시 맞춘다', async () => {
reply('DELETE /mailboxes/c', fail(404, 'RES_001'))
const { wrapper, store } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['success', '‘거래처’ 메일함을 지웠습니다.'],
])
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(0)
})
it('응답이 끊긴 뒤 다시 보내 404 를 받아도 지운 것으로 — 처음 끊김은 인터셉터 알림만', async () => {
reply('DELETE /mailboxes/c', 'network', fail(404, 'RES_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
useToastStore().clear()
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
})
it('다른 탭이 먼저 했으면 — "거래처에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다."', async () => {
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 0 }))
reply('DELETE /mailboxes/trash/messages', ok({ processed: 0, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '거래처', '모두 읽음으로 표시')
expect(toastTexts()).toEqual(['거래처에 안 읽은 메일이 없습니다.'])
useToastStore().clear()
await choose(wrapper, '휴지통', '휴지통 비우기')
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual(['휴지통이 이미 비어 있습니다.'])
})
it('다른 탭이 같은 메일함을 처리하는 중이면(429 REQ_001) 인터셉터 알림만 — 진행 알림은 닫는다', async () => {
reply('PATCH /mailboxes/inbox/messages', fail(429, 'REQ_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', ERROR_CODE_MESSAGES.REQ_001],
])
})
it('비운 뒤 누른 ⋯ 가 사라지면 초점을 메일함 패널의 지금 메일함으로', async () => {
reply('DELETE /mailboxes/trash/messages', ok({ processed: 21, remaining: 0 }))
const { wrapper } = await mountPanel('/mail/inbox')
await choose(wrapper, '휴지통', '휴지통 비우기')
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(row(wrapper, '휴지통').find('[data-mailbox-more]').exists()).toBe(false)
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
})
it('지운 메일함의 ⋯ 가 사라져도 초점을 메일함 패널의 지금 메일함으로', async () => {
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
const { wrapper } = await mountPanel('/mail/inbox')
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
})
})
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
const itemState = (items: DOMWrapper<Element>[]) =>
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
const second = deferred()
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), second)
const { wrapper } = await mountPanel()
await choose(wrapper, '휴지통', '휴지통 비우기')
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(itemState(await openMenu(wrapper, '거래처'))).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['지우기', true],
])
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ processed: 1, remaining: 0 }))
await flushPromises()
expect(itemState(await openMenu(wrapper, '거래처')).at(-1)).toEqual(['지우기', false])
})
it('지우기 대화상자를 연 뒤 휴지통 비우기가 시작되면 [지우기]를 누를 수 없다 — 누르는 순간에도 다시 본다', async () => {
const emptying = deferred()
reply('DELETE /mailboxes/trash/messages', emptying)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
const trash = store.mailboxes.find((mailbox) => mailbox.id === 'trash')
const target = store.mailboxes.find((mailbox) => mailbox.id === 'c')
if (!trash || !target) throw new Error('메일함이 없다')
const running = store.runMailboxOperation('empty', trash, () => undefined)
await flushPromises()
const confirm = wrapper.get('#mailbox-delete-confirm')
expect(confirm.attributes('disabled')).toBeDefined()
await confirm.trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/c')).toEqual([])
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
// 스토어도 시작하지 않는다
expect((await store.runMailboxOperation('delete', target, () => undefined)).status).toBe('canceled')
emptying.resolve(ok({ processed: 21, remaining: 0 }))
await running
await flushPromises()
expect(wrapper.get('#mailbox-delete-confirm').attributes('disabled')).toBeUndefined()
})
it('메일함을 지우는 동안은 「휴지통 비우기」가 꺼진다 — 「스팸메일함 비우기」는 그대로', async () => {
const second = deferred()
reply('DELETE /mailboxes/c', ok({ deleted: false, processed: 6, movedToTrash: 6, remaining: 6 }), second)
const { wrapper } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', true]])
await row(wrapper, '휴지통').get('[data-mailbox-more]').trigger('click')
expect(itemState(await openMenu(wrapper, '스팸메일함'))).toEqual([
['모두 읽음으로 표시', false],
['스팸메일함 비우기', false],
])
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ deleted: true, processed: 6, movedToTrash: 6, remaining: 0 }))
await flushPromises()
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', false]])
})
})
describe('세션이 바뀌면 (stores/mail/mailbox-admin.ts)', () => {
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const inbox = () => MAILBOXES[0] as Mailbox
it('처리 중 로그아웃하면 다음 요청을 보내지 않고 진행 알림이 닫힌다', async () => {
const first = deferred()
reply('PATCH /mailboxes/inbox/messages', first, ok({ processed: 734, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['progress'])
useAuthStore().clearAuth()
await nextTick()
first.resolve(ok({ processed: 500, remaining: 734 }))
await flushPromises()
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
})
it('「모두 읽음」을 두 번 눌러도 요청은 한 줄 — 도는 동안 항목이 꺼지고 스토어도 다시 시작하지 않는다', async () => {
const first = deferred()
reply('PATCH /mailboxes/inbox/messages', first)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
const items = await openMenu(wrapper, '받은편지함')
await items[0]?.trigger('click')
await flushPromises()
expect((await store.runMailboxOperation('read', inbox(), () => undefined)).status).toBe('canceled')
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
first.resolve(ok({ processed: 3, remaining: 0 }))
await flushPromises()
expect(toastTexts()).toEqual(['받은편지함의 메일 3통을 읽음으로 표시했습니다.'])
})
it('새 세션에서 같은 처리를 다시 시작할 수 있다 — 옛 처리가 끝나도 새 처리의 표시를 지우지 않는다', async () => {
const first = deferred()
const second = deferred()
reply('PATCH /mailboxes/inbox/messages', first, second)
const { store } = await mountPanel()
const old = store.runMailboxOperation('read', inbox(), () => undefined)
await flushPromises()
// 로그아웃 → 다시 로그인 (사이에 로그인 화면이 있다)
useAuthStore().clearAuth()
await nextTick()
useAuthStore().startSession(USER)
await nextTick()
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
const fresh = store.runMailboxOperation('read', inbox(), () => undefined)
await flushPromises()
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
first.resolve(ok({ processed: 1, remaining: 2 }))
expect((await old).status).toBe('canceled')
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
second.resolve(ok({ processed: 3, remaining: 0 }))
expect((await fresh).status).toBe('done')
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(false)
})
it('만들기 · 이름 바꾸기의 늦은 응답은 쓰지 않는다 — 다음 사용자의 메일함을 덮지 않고 알리지도 않는다', async () => {
const created = deferred()
const renamed = deferred()
reply('POST /mailboxes', created)
reply('PATCH /mailboxes/c', renamed)
const { store } = await mountPanel()
const creating = store.createMailbox('협력사', null)
const renaming = store.renameMailbox('c', '거래처 (옛 사용자)')
await flushPromises()
useAuthStore().clearAuth()
await nextTick()
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
await nextTick()
// 다음 사용자의 메일함 — id 가 겹칠 수 있다
store.mailboxes = [box('c', null, '도윤의 거래처')]
created.resolve(ok(box('n1', null, '협력사'), 201))
renamed.resolve(ok(box('c', null, '거래처 (옛 사용자)')))
expect(await creating).toBeNull()
expect(await renaming).toBeNull()
expect(store.mailboxes.map((mailbox) => mailbox.name)).toEqual(['도윤의 거래처'])
expect(useToastStore().toasts).toEqual([])
})
})
it('⋯ 메뉴는 메일함 패널의 보이는 영역 아래가 모자라면 위로 연다', async () => {
const { wrapper } = await mountPanel()
const panel = wrapper.get('[data-mailbox-panel]').element as HTMLElement
panel.style.overflowY = 'auto'
let buttonTop = 540
const rectOf = (top: number, bottom: number) =>
({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }) as DOMRect
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this === panel) return rectOf(0, 600)
if (this.matches('[data-mailbox-more]')) return rectOf(buttonTop, buttonTop + 40)
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 44, buttonTop + 44 + 254)
return rectOf(0, 0)
})
await openMenu(wrapper, '거래처')
expect(wrapper.get('[role="menu"]').classes()).toContain('mailbox-more__menu--up')
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
buttonTop = 100
await openMenu(wrapper, '거래처')
expect(wrapper.get('[role="menu"]').classes()).not.toContain('mailbox-more__menu--up')
})
})
+68 -98
View File
@@ -1,33 +1,63 @@
<script setup lang="ts">
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
import { computed } from 'vue'
import { computed, ref, watch } 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 MailVacationNotice from '@/components/mail/MailVacationNotice.vue'
import MailboxDialogs from '@/components/mail/MailboxDialogs.vue'
import MailboxTree from '@/components/mail/MailboxTree.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { useMailboxActions } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import type { MailboxAction } from '@/shared/utils/mailbox'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
const MAX_INDENT_DEPTH = 3
import { useMailVacationStore } from '@/stores/mail-vacation.store'
const route = useRoute()
const authStore = useAuthStore()
const mailStore = useMailStore()
const vacationStore = useMailVacationStore()
const actions = useMailboxActions()
const dialog = ref<MailboxDialogRequest | null>(null)
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)}`
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
function onAction(action: MailboxAction, mailbox: Mailbox) {
if (action === 'markRead') {
void actions.markAllRead(mailbox)
return
}
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
else dialog.value = { kind: 'empty', mailbox }
}
// 세션이 바뀌면(로그아웃 · 세션 만료) 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않게
watch(
() => authStore.generation,
() => {
dialog.value = null
},
)
</script>
<template>
<!-- data-mailbox-panel — 비우기 · 지우기 뒤 초점을 잃으면 여기의 지금 메일함으로 (useMailboxActions) -->
<nav
class="mailbox-panel"
aria-label="메일함"
data-mailbox-panel
>
<span
v-if="isComposing"
@@ -54,39 +84,17 @@ function depthClass(depth: number): string {
<span>메일 쓰기</span>
</RouterLink>
<ul
<MailboxTree
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>
:nodes="mailStore.orderedMailboxes"
:active-id="activeMailboxId"
@action="onAction"
@create="dialog = { kind: 'create', parent: null }"
/>
<ul
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
class="mailbox-panel__list"
class="mailbox-panel__placeholders"
aria-label="메일함 불러오는 중"
aria-busy="true"
>
@@ -114,12 +122,20 @@ function depthClass(depth: number): string {
</button>
</div>
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
<MailQuotaMeter
v-if="mailStore.quota"
:quota="mailStore.quota"
/>
<!-- 아래 묶음 — 부재중 안내(켜져 있을 때) → 사용량(받지 못했으면 그리지 않는다 — 보조 정보) -->
<div class="mailbox-panel__footer">
<MailVacationNotice :vacation="vacationStore.vacation" />
<MailQuotaMeter
v-if="mailStore.quota"
:quota="mailStore.quota"
/>
</div>
</nav>
<!-- 대화상자는 메일함 목록(nav) 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
<MailboxDialogs
:request="dialog"
@close="dialog = null"
/>
</template>
<style scoped>
@@ -156,7 +172,7 @@ a.mailbox-panel__primary:hover {
background: var(--color-brand-strong);
}
.mailbox-panel__list {
.mailbox-panel__placeholders {
display: flex;
flex-direction: column;
gap: var(--space-1);
@@ -165,60 +181,6 @@ a.mailbox-panel__primary:hover {
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;
@@ -249,6 +211,14 @@ a.mailbox-panel__primary:hover {
font-weight: 600;
}
/* 아래에 붙는다 — 안내와 사용량 사이 10 */
.mailbox-panel__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--space-10);
}
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.mailbox-panel {
@@ -0,0 +1,186 @@
<script setup lang="ts">
// 메일함 패널의 한 줄 — 아이콘 · 이름 · 안 읽은 수(평범한 숫자, 뱃지 아님) · ⋯ 메뉴 (디자인 원본 Main · MailboxManage)
// - 하위 메일함은 16px 씩 들여 쓴다 (3단계까지 — 그 아래는 같은 깊이로)
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { mailboxActions, mailboxIcon, type MailboxAction } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
mailbox: Mailbox
depth: number
active: boolean
}
interface Emits {
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const mailStore = useMailStore()
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
const MAX_INDENT_DEPTH = 3
const menuOpen = ref<boolean>(false)
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
function isDisabled(action: MailboxAction): boolean {
const mailbox = props.mailbox
if (mailStore.isMailboxOperationRunning('delete', mailbox.id)) return true
if (action === 'markRead') return mailStore.isMailboxOperationRunning('read', mailbox.id)
if (action === 'empty') {
return mailStore.isMailboxOperationRunning('empty', mailbox.id) || mailStore.isMailboxOperationBlocked('empty', mailbox)
}
if (action === 'delete') return mailStore.isMailboxOperationBlocked('delete', mailbox)
return false
}
function labelOf(action: MailboxAction): string {
if (action === 'empty') return props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash
return MAILBOX_MENU_TEXT[action]
}
const items = computed<EditorMenuItem[]>(() =>
mailboxActions(props.mailbox).map((action) => ({
id: action,
label: labelOf(action),
disabled: isDisabled(action),
tone: action === 'delete' || action === 'empty' ? 'danger' : undefined,
})),
)
const depthClass = computed(() => `mailbox-row__link--depth-${Math.min(props.depth, MAX_INDENT_DEPTH)}`)
function onSelect(id: string) {
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
if (action) emit('action', action, props.mailbox)
}
</script>
<template>
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
<RouterLink
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
:class="['mailbox-row__link', depthClass]"
:aria-current="active ? 'page' : undefined"
>
<AppIcon :name="mailboxIcon(mailbox)" />
<span class="mailbox-row__name">{{ mailbox.name }}</span>
<span
v-if="mailbox.unreadCount > 0"
class="mailbox-row__count"
>
<span class="visually-hidden">안 읽음</span>
{{ formatCount(mailbox.unreadCount) }}
</span>
</RouterLink>
<MailboxMoreButton
v-if="items.length > 0"
class="mailbox-row__more"
:mailbox-name="mailbox.name"
:items="items"
@select="onSelect"
@open-change="menuOpen = $event"
/>
</li>
</template>
<style scoped>
.mailbox-row {
display: flex;
align-items: center;
border-radius: var(--radius-item);
}
.mailbox-row:hover {
background: var(--color-surface-muted);
}
.mailbox-row.is-active {
background: var(--color-brand-subtle);
}
.mailbox-row__link {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-4) 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.mailbox-row:hover .mailbox-row__link {
color: var(--color-text);
}
.mailbox-row.is-active .mailbox-row__link {
color: var(--color-brand-strong);
font-weight: 600;
}
.mailbox-row__link--depth-1 {
padding-left: var(--space-28);
}
.mailbox-row__link--depth-2 {
padding-left: var(--space-44);
}
.mailbox-row__link--depth-3 {
padding-left: var(--space-56);
}
.mailbox-row__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-row__count {
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.mailbox-row.is-active .mailbox-row__count {
color: var(--color-brand-strong);
}
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
.mailbox-row__more {
opacity: 0;
}
.mailbox-row:hover .mailbox-row__more,
.mailbox-row:focus-within .mailbox-row__more,
.mailbox-row.is-open .mailbox-row__more {
opacity: 1;
}
/* 마우스를 올릴 수 없는 화면(터치)에서는 늘 보인다 */
@media (hover: none) {
.mailbox-row__more {
opacity: 1;
}
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
interface Props {
nodes: readonly MailboxNode[]
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
activeId: string | null
}
interface Emits {
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
(e: 'create'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const MY_GROUP_ID = 'mailbox-panel-mine'
const groups = computed(() => splitMailboxGroups(props.nodes))
</script>
<template>
<div class="mailbox-tree">
<ul class="mailbox-tree__list">
<MailboxPanelRow
v-for="node in groups.system"
:key="node.mailbox.id"
:mailbox="node.mailbox"
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
/>
</ul>
<p
:id="MY_GROUP_ID"
class="mailbox-tree__group"
>
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
</p>
<ul
v-if="groups.mine.length > 0"
class="mailbox-tree__list"
:aria-labelledby="MY_GROUP_ID"
>
<MailboxPanelRow
v-for="node in groups.mine"
:key="node.mailbox.id"
:mailbox="node.mailbox"
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
/>
</ul>
<button
type="button"
class="mailbox-tree__new"
data-mailbox-new
@click="emit('create')"
>
<AppIcon name="plus" />
<span>{{ MAILBOX_PANEL_TEXT.newMailbox }}</span>
</button>
</div>
</template>
<style scoped>
.mailbox-tree {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.mailbox-tree__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
/* "내 메일함" — 12px 굵게 (디자인 원본 MailboxManage) */
.mailbox-tree__group {
margin: var(--space-12) var(--space-10) var(--space-4);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
/* [+ 새 메일함] — 44 */
.mailbox-tree__new {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
margin-top: var(--space-2);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
text-align: left;
}
.mailbox-tree__new:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -3,19 +3,16 @@
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -26,10 +23,8 @@ interface Props {
const props = defineProps<Props>()
const router = useRouter()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const { backToSourceList } = useMailNavigation()
const working = ref<boolean>(false)
const confirmOpen = ref<boolean>(false)
@@ -43,13 +38,7 @@ async function run(kind: 'trash' | 'purge') {
working.value = true
try {
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
if (!outcome.removed || !source) return
if (props.layout === 'list') {
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToSourceList(source)
return
}
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
await removal.leaveRemoved(outcome, source, props.layout)
} finally {
working.value = false
}
@@ -0,0 +1,120 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
interface Props {
messageId: string
}
const props = defineProps<Props>()
const { downloadMessageSource } = useMail()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const ITEMS: readonly EditorMenuItem[] = [
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
]
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
open.value = true
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
open.value = true
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) buttonRef.value?.focus()
}
function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
}
</script>
<template>
<div class="reader-more">
<button
ref="buttonRef"
type="button"
class="reader-more__button"
:aria-label="MAIL_READER_MORE_TEXT.button"
:title="MAIL_READER_MORE_TEXT.button"
aria-haspopup="menu"
:aria-expanded="open"
data-reader-more
@click="onClick"
@keydown="onKeydown"
>
<AppIcon name="more-horizontal" />
</button>
<EditorMenu
v-if="open"
class="reader-more__menu"
:label="MAIL_READER_MORE_TEXT.button"
:items="ITEMS"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
.reader-more {
position: relative;
}
/* 40×44 — 새로고침 · 이전/다음과 같은 크기 */
.reader-more__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-icon-button);
height: var(--size-touch);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
}
.reader-more__button:hover,
.reader-more__button[aria-expanded='true'] {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
.reader-more__menu {
min-width: 12.5rem;
}
</style>
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
@@ -457,3 +458,149 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
})
})
describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자인 원본 MailActions4)', () => {
let pinia: Pinia
let requests: MockRequest[]
/** 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
let downloads: (string | null)[]
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href'))
})
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
) {
const box = options.box ?? 'inbox'
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
requests = installMockApi((request) => {
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = mailboxes
store.listSource = { kind: 'mailbox', mailboxId: box }
const open = { ...DETAIL, mailboxIds: [box] }
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${box}/m1`)
await router.isReady()
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
return { wrapper, router, store }
}
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
const { wrapper } = await mountWith()
const toolbar = wrapper.get('.reader__toolbar')
expect(toolbar.findAll('.tool-button').map((button) => button.text())).toEqual([
'답장',
'전체 답장',
'전달',
'스팸 신고',
'삭제',
])
const more = toolbar.get('[data-reader-more]')
expect(more.attributes('aria-label')).toBe('더 보기')
expect(more.attributes('aria-haspopup')).toBe('menu')
expect(toolbar.element.lastElementChild?.contains(more.element)).toBe(true)
})
it('스팸 신고 — 스팸메일함으로 옮기며 isJunk: true, 다음 메일을 연다 · [되돌리기]는 원래 메일함 · isJunk: false', async () => {
const { wrapper, router, store } = await mountWith()
await wrapper.get('[data-reader-spam]').trigger('click')
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
const { wrapper } = await mountWith({ box: 'junk' })
const notSpam = wrapper.get('[data-reader-spam]')
expect(notSpam.text()).toBe('스팸 아님')
expect(notSpam.classes()).toContain('tool-button--accent')
await notSpam.trigger('click')
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
})
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
const { wrapper } = await mountWith({ mailboxes: [INBOX, TRASH] })
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
})
it('⋯ → "원문 내려받기 (.eml)" — 이동하기 전에 세션을 확인하고(confirmSession) 원문 주소로 내려받는다', async () => {
const { wrapper } = await mountWith()
const more = wrapper.get('[data-reader-more]')
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/source'])
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(more.element)
})
it('세션을 확인하지 못하면(갱신도 401) 내려받지 않고 세션 만료로 다룬다', async () => {
const { wrapper } = await mountWith({ me: fail(401, 'AUTH_001'), refresh: fail(401, 'AUTH_001') })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.get('[role="menuitem"]').trigger('click')
await flushPromises()
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
})
@@ -3,7 +3,8 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
@@ -15,6 +16,8 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
@@ -99,10 +102,15 @@ function leaveToList() {
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
:message="message"
:layout="layout"
/>
<MessageDeleteButton
:message="message"
:layout="layout"
/>
<MessageMoreMenu :message-id="message.id" />
</template>
</div>
</div>
@@ -0,0 +1,54 @@
<script setup lang="ts">
// 읽기 화면의 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통 (디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리" · useMessageRemoval)
// - 스팸메일함에 든 메일이면 「스팸 아님」(받은편지함 아이콘 · 강조 글자 #0A5F67 굵게) — 받은편지함으로 옮기고 "스팸 아님" 표시
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
import { computed, ref } from 'vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_SPAM_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageSummary
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
layout: 'split' | 'list'
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const working = ref<boolean>(false)
const inJunk = computed(() => removal.isInJunk(props.message))
const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk') !== null)
async function onClick() {
if (working.value) return
const source = mailStore.listSource
working.value = true
try {
const outcome = await removal.moveSpam(props.message)
await removal.leaveRemoved(outcome, source, props.layout)
} finally {
working.value = false
}
}
</script>
<template>
<MailToolButton
v-if="available"
:icon="inJunk ? 'inbox' : 'alert-octagon'"
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
:tone="inJunk ? 'accent' : 'default'"
:disabled="working"
data-reader-spam
@click="onClick"
/>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
@@ -52,7 +53,7 @@ function isToggle(role: string | undefined): boolean {
<button
v-else
type="button"
class="menu__item"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
:role="item.role ?? 'menuitem'"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
@@ -125,6 +126,19 @@ function isToggle(role: string | undefined): boolean {
cursor: default;
}
/* 되돌릴 수 없는 일 (디자인 원본 MailboxManage "지우기 · 비우기") */
.menu__item--danger {
color: var(--color-danger);
}
.menu__item--danger:hover:not(:disabled) {
background: var(--color-danger-surface);
}
.menu__item--danger .menu__icon {
color: var(--color-danger);
}
.menu__icon {
color: var(--color-text-secondary);
}
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
hint?: string
checked?: boolean
disabled?: boolean
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
opens?: 'menu' | 'dialog'
/** 미리보기 모양 (글꼴 · 크기) — EditorMenu 의 class 이름 */
@@ -0,0 +1,9 @@
// 메일함 패널의 대화상자 요청 — 패널(MailboxPanel)이 열고 대화상자 묶음(MailboxDialogs)이 그린다
import type { Mailbox } from '@/shared/types/mail'
export type MailboxDialogRequest =
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
| { kind: 'create'; parent: Mailbox | null }
| { kind: 'rename'; mailbox: Mailbox }
| { kind: 'delete'; mailbox: Mailbox }
| { kind: 'empty'; mailbox: Mailbox }
@@ -0,0 +1,258 @@
<script setup lang="ts">
// 비밀번호 바꾸기 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
// 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인 — 앞의 두 칸 끝에 보기/숨기기 (확인 칸은 새 비밀번호를 따른다)
// - 보내기 전에 서버와 같은 규칙을 본다: 8자 이상 · 지금과 다름(어기면 새 비밀번호 안내 글이 빨간 오류가 된다) · 확인 칸 일치("새 비밀번호가 서로 다릅니다.")
// · 지금 비밀번호를 비움 · 128자 초과(사전 문구 VAL_001). 처음 걸린 칸으로 초점을 옮긴다
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status
import { computed, onBeforeUnmount, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { PASSWORD_TEXT } from '@/shared/constants/account'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import {
hasPasswordChangeProblem,
passwordChangeProblems,
type PasswordChangeProblems,
} from '@/shared/utils/password-change'
import { useAuthStore } from '@/stores/auth.store'
/** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001']
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
const { changePassword } = useAuth()
const authStore = useAuthStore()
const currentRef = ref<InstanceType<typeof PasswordField> | null>(null)
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
const current = ref<string>('')
const next = ref<string>('')
const confirm = ref<string>('')
const showCurrent = ref<boolean>(false)
const showNext = ref<boolean>(false)
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
const submitting = ref<boolean>(false)
const done = ref<boolean>(false)
/** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */
let unmounted = false
const currentError = computed(() => {
if (problems.value.current === 'missing') return getErrorMessage('VAL_001')
return serverError.value?.field === 'current' ? serverError.value.message : null
})
const nextError = computed(() => {
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
return serverError.value?.field === 'next' ? serverError.value.message : null
})
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? PASSWORD_TEXT.mismatch : null))
// 칸을 고치면 그 칸에 걸린 오류와 "바꿨습니다"를 내린다 (지금 비밀번호를 고치면 "지금과 같음"도 다시 본다)
function setCurrent(value: string) {
current.value = value
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
if (serverError.value?.field === 'current') serverError.value = null
done.value = false
}
function setNext(value: string) {
next.value = value
problems.value = { ...problems.value, next: null, confirm: null }
if (serverError.value?.field === 'next') serverError.value = null
done.value = false
}
function setConfirm(value: string) {
confirm.value = value
problems.value = { ...problems.value, confirm: null }
done.value = false
}
function focusFirstProblem(found: PasswordChangeProblems) {
if (found.current) currentRef.value?.focus()
else if (found.next) nextRef.value?.focus()
else if (found.confirm) confirmRef.value?.focus()
}
function clearFields() {
current.value = ''
next.value = ''
confirm.value = ''
showCurrent.value = false
showNext.value = false
}
async function submit() {
if (submitting.value) return
done.value = false
serverError.value = null
const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value })
problems.value = found
if (hasPasswordChangeProblem(found)) {
focusFirstProblem(found)
return
}
submitting.value = true
try {
await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES })
clearFields()
problems.value = NO_PROBLEMS
done.value = true
} catch (error) {
const apiError = toApiError(error)
if (unmounted) {
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
if (apiError.code === 'AUTH_005') {
serverError.value = { field: 'current', message: apiError.message }
currentRef.value?.focus()
} else if (apiError.code === 'VAL_001') {
serverError.value = { field: 'next', message: apiError.message }
nextRef.value?.focus()
}
} finally {
submitting.value = false
}
}
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<form
class="password-form"
novalidate
@submit.prevent="submit"
>
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
<input
type="text"
name="username"
autocomplete="username"
:value="authStore.user?.email ?? ''"
hidden
readonly
>
<PasswordField
id="password-current"
ref="currentRef"
:label="PASSWORD_TEXT.current"
:model-value="current"
autocomplete="current-password"
:shown="showCurrent"
:toggle-label="PASSWORD_TEXT.showCurrent"
:error="currentError"
@update:model-value="setCurrent"
@toggle="showCurrent = !showCurrent"
/>
<PasswordField
id="password-next"
ref="nextRef"
:label="PASSWORD_TEXT.next"
:model-value="next"
autocomplete="new-password"
:shown="showNext"
:toggle-label="PASSWORD_TEXT.showNext"
:hint="PASSWORD_TEXT.hint"
:hint-is-error="problems.next === 'rule'"
:error="nextError"
@update:model-value="setNext"
@toggle="showNext = !showNext"
/>
<PasswordField
id="password-confirm"
ref="confirmRef"
:label="PASSWORD_TEXT.confirm"
:model-value="confirm"
autocomplete="new-password"
:shown="showNext"
:error="confirmError"
@update:model-value="setConfirm"
/>
<div class="password-form__actions">
<button
id="password-submit"
type="submit"
class="password-form__submit"
:disabled="submitting"
:aria-busy="submitting"
>
{{ PASSWORD_TEXT.submit }}
</button>
<span
id="password-status"
class="password-form__status"
role="status"
>
<template v-if="done">
<AppIcon
name="check"
:size="15"
:stroke-width="2.2"
/>
<span>{{ PASSWORD_TEXT.done }}</span>
</template>
</span>
</div>
</form>
</template>
<style scoped>
.password-form {
display: flex;
flex-direction: column;
gap: var(--space-18);
max-width: var(--form-max-width);
margin: 0;
}
.password-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.password-form__submit {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-22);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.password-form__submit:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.password-form__submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-form__status {
display: inline-flex;
align-items: center;
gap: var(--space-6);
color: var(--color-success-text);
font-weight: 500;
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
// 비밀번호 칸 하나 — 이름 · 칸(44) · [보기/숨기기](44 · 눈 아이콘 · aria-pressed) · 안내 · 오류 (디자인 원본 SettingsAccount)
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
interface Props {
id: string
label: string
modelValue: string
autocomplete: 'current-password' | 'new-password'
/** 글자를 보이는 중 */
shown: boolean
/** 보기 단추의 이름 — 없으면 단추를 두지 않는다 (확인 칸은 새 비밀번호의 단추를 따른다) */
toggleLabel?: string
hint?: string
/** 안내 글이 그대로 오류다 */
hintIsError?: boolean
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
error?: string | null
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'toggle'): void
}
const props = withDefaults(defineProps<Props>(), {
toggleLabel: '',
hint: '',
hintIsError: false,
error: null,
})
const emit = defineEmits<Emits>()
const inputRef = ref<HTMLInputElement | null>(null)
const hintId = computed(() => `${props.id}-hint`)
const errorId = computed(() => `${props.id}-error`)
const invalid = computed(() => Boolean(props.error) || props.hintIsError)
const describedBy = computed(() => {
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
return ids.length > 0 ? ids.join(' ') : undefined
})
function onInput(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
defineExpose({
focus: () => inputRef.value?.focus(),
})
</script>
<template>
<div class="password-field">
<label
:for="id"
class="password-field__label"
>{{ label }}</label>
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
<input
:id="id"
ref="inputRef"
class="form-input__control"
:type="shown ? 'text' : 'password'"
:autocomplete="autocomplete"
:value="modelValue"
spellcheck="false"
autocapitalize="off"
:aria-invalid="invalid ? 'true' : 'false'"
:aria-describedby="describedBy"
@input="onInput"
>
<button
v-if="toggleLabel"
type="button"
class="password-field__toggle"
:aria-label="toggleLabel"
:title="toggleLabel"
:aria-pressed="shown"
:aria-controls="id"
@click="emit('toggle')"
>
<AppIcon :name="shown ? 'eye-off' : 'eye'" />
</button>
</div>
<p
v-if="hint"
:id="hintId"
:class="['form-message', { 'form-message--error': hintIsError }]"
>
<AppIcon
v-if="hintIsError"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ hint }}</span>
</p>
<p
v-if="error"
:id="errorId"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</div>
</template>
<style scoped>
.password-field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.password-field__label {
font-weight: 600;
}
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
.password-field__box {
display: flex;
align-items: stretch;
padding: 0;
}
/* 44 */
.password-field__toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
border: 0;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: transparent;
color: var(--color-text-secondary);
}
.password-field__toggle:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.password-field__toggle[aria-pressed='true'] {
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
titleId: string
title: string
description: string
}
defineProps<Props>()
</script>
<template>
<main
class="settings-page"
:aria-labelledby="titleId"
>
<div class="settings-page__column">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
</div>
<slot />
</div>
</main>
</template>
<style scoped>
.settings-page {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
}
.settings-page__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
.settings-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.settings-page__title {
margin: 0;
font-size: var(--font-size-title-lg);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.settings-page__description {
margin: 0;
color: var(--color-text-secondary);
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
@media (min-width: 70rem) {
.settings-page {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,296 @@
<script setup lang="ts">
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields) → [저장] + 상태 글 → 미리 보기(VacationPreview).
// - 끄면 아래 칸을 흐리게(쓴 글은 남긴다). 켤 때만 제목 · 내용이 필요하다 — 꺼 두면 빈 칸도 저장된다
// - 저장하기 전에 막는 것(켜 둘 때): 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 — 그 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점.
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError)
// - 저장하면 "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." 등 — 무엇이든 다시 바꾸면 내린다
// - 나가기 확인: 불러온(저장한 뒤에는 저장한) 값과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창 (서명 설정과 같다).
// 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
// 고정 id — #vacation-enabled · #vacation-save · #vacation-save-status ·
// 나가기 확인 #vacation-leave-dialog · #vacation-leave-stay · #vacation-leave-confirm (칸의 id 는 각 칸 컴포넌트에)
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import VacationMessageFields from '@/components/settings/VacationMessageFields.vue'
import VacationPeriodFields from '@/components/settings/VacationPeriodFields.vue'
import VacationPreview from '@/components/settings/VacationPreview.vue'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import {
sameVacationForm,
vacationFormOf,
vacationPayloadOf,
vacationProblems,
vacationSavedText,
type VacationForm,
type VacationPeriod,
type VacationProblem,
} from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
interface Props {
/** 서버의 설정 — 이 값으로 칸을 채운다 */
vacation: MailVacation
}
const props = defineProps<Props>()
const authStore = useAuthStore()
const vacationStore = useMailVacationStore()
const periodRef = ref<InstanceType<typeof VacationPeriodFields> | null>(null)
const messageRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
const form = reactive<VacationForm>(vacationFormOf(props.vacation))
/** 불러온(저장한 뒤에는 저장한) 값 — 이것과 다르면 바꾼 것이다 */
const saved = shallowRef<VacationForm>(vacationFormOf(props.vacation))
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
const savedText = ref<string>('')
const problems = ref<VacationProblem[]>([])
/** 서버가 거절한 까닭 (사전 문구) */
const saveError = ref<string | null>(null)
/** 화면이 사라졌다 — 저장 단추 옆 글을 볼 곳이 없다 */
let unmounted = false
const changed = computed(() => !sameVacationForm(form, saved.value))
const problemText = getErrorMessage('VAL_001')
/** 그 칸에 걸린 문제의 문구 — 없으면 null */
function problemOf(problem: VacationProblem): string | null {
return problems.value.includes(problem) ? problemText : null
}
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => changed.value && authStore.isLoggedIn,
})
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblem[]) {
Object.assign(form, patch)
problems.value = problems.value.filter((problem) => !clears.includes(problem))
saveError.value = null
if (saveState.value === 'saved') saveState.value = 'idle'
}
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 내린다 */
function setEnabled(event: Event) {
edit({ isEnabled: (event.target as HTMLInputElement).checked }, ['period', 'subject', 'textBody'])
}
/** 기간 정하기로 바꾸는데 날짜가 비었으면 시작을 오늘로 채운다 (끝은 사용자가 고른다) */
function setPeriod(period: VacationPeriod) {
const prefill = period === 'range' && !form.fromDay && !form.toDay ? { fromDay: formatDate(new Date()) } : {}
edit({ period, ...prefill }, ['period'])
}
function focusFirst(found: readonly VacationProblem[]) {
if (found.includes('period')) periodRef.value?.focus()
else if (found.includes('subject')) messageRef.value?.focusSubject()
else if (found.includes('textBody')) messageRef.value?.focusBody()
}
async function save() {
if (saveState.value === 'saving') return
saveError.value = null
const found = vacationProblems(form)
problems.value = found
if (found.length > 0) {
focusFirst(found)
return
}
const sent: VacationForm = { ...form }
saveState.value = 'saving'
try {
const result = await vacationStore.save(vacationPayloadOf(sent), { silent: ['VAL_001'] })
const savedForm = vacationFormOf(result)
// 저장하는 동안 더 고치지 않았으면 서버가 저장한 값으로 맞춘다 (흐린 칸의 맞지 않는 기간은 비워 저장했다)
if (sameVacationForm(form, sent)) Object.assign(form, savedForm)
saved.value = savedForm
savedText.value = vacationSavedText(result)
saveState.value = 'saved'
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (apiError.code !== 'VAL_001') return
// 알림을 끈 거절 — 화면을 떠났으면 알림으로 알린다 (보내는 사이 떠나도 묻지 않는다)
if (unmounted) notifyApiError(apiError)
else saveError.value = apiError.message
}
}
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<div class="vacation-form">
<label class="vacation-form__toggle">
<input
id="vacation-enabled"
type="checkbox"
class="vacation-form__checkbox"
:checked="form.isEnabled"
@change="setEnabled"
>
<span>{{ VACATION_TEXT.enable }}</span>
</label>
<VacationPeriodFields
ref="periodRef"
:period="form.period"
:from-day="form.fromDay"
:to-day="form.toDay"
:disabled="!form.isEnabled"
:error="problemOf('period')"
@update:period="setPeriod"
@update:from-day="edit({ fromDay: $event }, ['period'])"
@update:to-day="edit({ toDay: $event }, ['period'])"
/>
<VacationMessageFields
ref="messageRef"
:subject="form.subject"
:text-body="form.textBody"
:disabled="!form.isEnabled"
:subject-error="problemOf('subject')"
:body-error="problemOf('textBody')"
@update:subject="edit({ subject: $event }, ['subject'])"
@update:text-body="edit({ textBody: $event }, ['textBody'])"
/>
<div class="vacation-form__actions">
<button
id="vacation-save"
type="button"
class="vacation-form__save"
:disabled="saveState === 'saving'"
:aria-busy="saveState === 'saving'"
@click="save"
>
{{ saveState === 'saving' ? VACATION_TEXT.saving : VACATION_TEXT.save }}
</button>
<span
id="vacation-save-status"
:class="['vacation-form__status', { 'is-error': saveError }]"
role="status"
>
<template v-if="saveError">
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ saveError }}</span>
</template>
<template v-else-if="saveState === 'saved'">
<AppIcon
name="check"
:size="15"
:stroke-width="2.2"
/>
<span>{{ savedText }}</span>
</template>
</span>
</div>
<hr class="vacation-form__rule">
<VacationPreview
:enabled="form.isEnabled"
:sender-name="authStore.user?.name ?? ''"
:sender-email="authStore.user?.email ?? ''"
:subject="form.subject"
:text-body="form.textBody"
/>
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="vacation-leave"
:copy="VACATION_LEAVE_DIALOG"
@stay="stayOnForm"
@leave="leaveForm"
/>
</div>
</template>
<style scoped>
.vacation-form {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.vacation-form__toggle {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
font-weight: 600;
cursor: pointer;
}
.vacation-form__checkbox {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.vacation-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.vacation-form__save {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-22);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.vacation-form__save:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.vacation-form__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.vacation-form__status {
display: inline-flex;
align-items: center;
gap: var(--space-6);
color: var(--color-success-text);
font-weight: 500;
}
.vacation-form__status.is-error {
color: var(--color-danger);
}
.vacation-form__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
interface Props {
subject: string
textBody: string
disabled: boolean
/** 칸마다의 문제 문구 — 없으면 null */
subjectError?: string | null
bodyError?: string | null
}
interface Emits {
(e: 'update:subject', value: string): void
(e: 'update:textBody', value: string): void
}
withDefaults(defineProps<Props>(), {
subjectError: null,
bodyError: null,
})
const emit = defineEmits<Emits>()
const subjectRef = ref<HTMLInputElement | null>(null)
const bodyRef = ref<HTMLTextAreaElement | null>(null)
function valueOf(event: Event): string {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
}
defineExpose({
focusSubject: () => subjectRef.value?.focus(),
focusBody: () => bodyRef.value?.focus(),
})
</script>
<template>
<div class="vacation-message">
<div class="vacation-message__field">
<label
for="vacation-subject"
class="vacation-message__label"
>{{ VACATION_TEXT.subject }}</label>
<input
id="vacation-subject"
ref="subjectRef"
class="form-input vacation-message__subject"
type="text"
:value="subject"
:maxlength="VACATION_SUBJECT_MAX_LENGTH"
:disabled="disabled"
:aria-invalid="subjectError ? 'true' : 'false'"
:aria-describedby="subjectError ? 'vacation-subject-error' : undefined"
@input="emit('update:subject', valueOf($event))"
>
<p
v-if="subjectError"
id="vacation-subject-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ subjectError }}</span>
</p>
</div>
<div class="vacation-message__field">
<label
for="vacation-body"
class="vacation-message__label"
>{{ VACATION_TEXT.body }}</label>
<textarea
id="vacation-body"
ref="bodyRef"
class="form-input vacation-message__body"
rows="6"
:value="textBody"
:maxlength="VACATION_BODY_MAX_LENGTH"
:disabled="disabled"
:aria-invalid="bodyError ? 'true' : 'false'"
:aria-describedby="bodyError ? 'vacation-body-hint vacation-body-error' : 'vacation-body-hint'"
@input="emit('update:textBody', valueOf($event))"
/>
<p
v-if="bodyError"
id="vacation-body-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ bodyError }}</span>
</p>
<p
id="vacation-body-hint"
class="form-message"
>
{{ VACATION_TEXT.bodyHint }}
</p>
</div>
</div>
</template>
<style scoped>
.vacation-message {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.vacation-message__field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.vacation-message__label {
font-weight: 600;
}
.vacation-message__subject {
width: 100%;
max-width: var(--form-wide-max-width);
}
.vacation-message__body {
width: 100%;
padding: var(--space-10) var(--space-12);
line-height: var(--line-height-body);
resize: vertical;
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { VacationPeriod } from '@/shared/utils/mail-vacation'
interface Props {
period: VacationPeriod
fromDay: string
toDay: string
disabled: boolean
/** 기간이 맞지 않는다 — 사전 문구 */
error?: string | null
}
interface Emits {
(e: 'update:period', period: VacationPeriod): void
(e: 'update:fromDay', day: string): void
(e: 'update:toDay', day: string): void
}
withDefaults(defineProps<Props>(), {
error: null,
})
const emit = defineEmits<Emits>()
const fromRef = ref<HTMLInputElement | null>(null)
function valueOf(event: Event): string {
return (event.target as HTMLInputElement).value
}
defineExpose({
/** 기간이 맞지 않을 때 — 시작 날짜 칸으로 */
focus: () => fromRef.value?.focus(),
})
</script>
<template>
<fieldset
class="vacation-period"
:disabled="disabled"
>
<legend class="vacation-period__legend">
{{ VACATION_TEXT.periodLegend }}
</legend>
<label class="vacation-period__option">
<input
id="vacation-period-until-off"
type="radio"
name="vacation-period"
class="vacation-period__radio"
:checked="period === 'untilOff'"
@change="emit('update:period', 'untilOff')"
>
<span>{{ VACATION_TEXT.untilOff }}</span>
</label>
<label class="vacation-period__option">
<input
id="vacation-period-range"
type="radio"
name="vacation-period"
class="vacation-period__radio"
:checked="period === 'range'"
@change="emit('update:period', 'range')"
>
<span>{{ VACATION_TEXT.range }}</span>
</label>
<template v-if="period === 'range'">
<div class="vacation-period__dates">
<label
for="vacation-from"
class="vacation-period__date-label"
>{{ VACATION_TEXT.from }}</label>
<input
id="vacation-from"
ref="fromRef"
class="form-input vacation-period__date"
type="date"
:value="fromDay"
:max="toDay || undefined"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
@input="emit('update:fromDay', valueOf($event))"
>
<span
class="vacation-period__tilde"
aria-hidden="true"
>~</span>
<label
for="vacation-to"
class="vacation-period__date-label"
>{{ VACATION_TEXT.to }}</label>
<input
id="vacation-to"
class="form-input vacation-period__date"
type="date"
:value="toDay"
:min="fromDay || undefined"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
@input="emit('update:toDay', valueOf($event))"
>
</div>
<p
id="vacation-period-hint"
class="form-message vacation-period__note"
>
{{ VACATION_TEXT.rangeHint }}
</p>
<p
v-if="error"
id="vacation-period-error"
class="form-message form-message--error vacation-period__note"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</template>
</fieldset>
</template>
<style scoped>
.vacation-period {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.vacation-period:disabled {
color: var(--color-icon-muted);
}
.vacation-period__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.vacation-period__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.vacation-period:disabled .vacation-period__option {
cursor: default;
}
.vacation-period__radio {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
/* 날짜 줄 — 라디오 글자 선(28)에 맞춰 들여 쓴다 */
.vacation-period__dates {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-10);
padding: var(--space-4) 0 0 var(--space-28);
}
.vacation-period__date-label {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.vacation-period__date {
padding: 0 var(--space-10);
}
.vacation-period__tilde {
color: var(--color-text-muted);
}
.vacation-period__note {
margin: var(--space-6) 0 0 var(--space-28);
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation)
// 보낸 사람(나) · 제목 · 내용(글자 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄
// (켜짐: "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." · 꺼짐: "꺼져 있습니다 — 자동으로 답하지 않습니다.")
import { computed } from 'vue'
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
interface Props {
enabled: boolean
senderName: string
senderEmail: string
subject: string
textBody: string
}
const props = defineProps<Props>()
const sender = computed(() =>
props.senderName ? `${props.senderName} <${props.senderEmail}>` : props.senderEmail,
)
const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
</script>
<template>
<section
class="vacation-preview"
aria-labelledby="vacation-preview-title"
data-vacation-preview
>
<h2
id="vacation-preview-title"
class="vacation-preview__title"
>
{{ VACATION_TEXT.previewTitle }}
</h2>
<div class="vacation-preview__card">
<dl class="vacation-preview__meta">
<dt class="vacation-preview__label">
{{ VACATION_TEXT.previewFrom }}
</dt>
<dd class="vacation-preview__value">
{{ sender }}
</dd>
<dt class="vacation-preview__label">
{{ VACATION_TEXT.previewSubject }}
</dt>
<dd
class="vacation-preview__value"
data-vacation-preview-subject
>
{{ shownSubject }}
</dd>
</dl>
<!-- 글자 그대로 — 템플릿의 들여쓰기 공백이 끼지 않게 v-text 로 넣는다 -->
<div
class="vacation-preview__body"
data-vacation-preview-body
v-text="textBody"
/>
</div>
<p
class="vacation-preview__note"
data-vacation-preview-note
>
{{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
</p>
</section>
</template>
<style scoped>
.vacation-preview {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.vacation-preview__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.vacation-preview__card {
display: flex;
flex-direction: column;
gap: var(--space-10);
padding: var(--space-16) var(--space-20) var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 이름 칸 72 · 값 (12.5px) */
.vacation-preview__meta {
display: grid;
grid-template-columns: var(--space-72) minmax(0, 1fr);
row-gap: var(--space-2);
margin: 0;
font-size: var(--font-size-label);
}
.vacation-preview__label {
color: var(--color-text-muted);
}
.vacation-preview__value {
margin: 0;
overflow-wrap: anywhere;
}
/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
.vacation-preview__body {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
line-height: var(--line-height-mail-body);
color: var(--color-text-body);
}
.vacation-preview__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -27,7 +27,7 @@ describe('AppRail', () => {
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
expect(links).toEqual([
['메일', '/mail'],
['설정', '/settings'],