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'],
+3
View File
@@ -66,6 +66,9 @@ describe('에러 코드 사전', () => {
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
// 웹메일 4단계
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
]
for (const code of usedByClient) {
+17 -2
View File
@@ -1,8 +1,10 @@
import type { AxiosRequestConfig } from 'axios'
import { ref } from 'vue'
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
import { announceLogout } from './useLogoutBroadcast'
import type { AuthUser, LoginPayload } from '@/shared/types/auth'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import type { AuthUser, ChangePasswordPayload, LoginPayload } from '@/shared/types/auth'
import { useAuthStore } from '@/stores/auth.store'
export type { LoginPayload } from '@/shared/types/auth'
@@ -83,5 +85,18 @@ export function useAuth() {
return authStore.restore(fetchSessionUser)
}
return { loading, login, logout, me, refresh: refreshSession, restoreSession }
/**
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
*/
async function changePassword(
payload: ChangePasswordPayload,
options?: Pick<AxiosRequestConfig, 'silent'>,
): Promise<void> {
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
}
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
}
+34 -4
View File
@@ -5,11 +5,12 @@ import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import type { Mailbox } from '@/shared/types/mail'
import { listLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
@@ -27,6 +28,10 @@ export function useBulkMailActions() {
)
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
@@ -55,11 +60,11 @@ export function useBulkMailActions() {
await router.replace(listLocationOf(source))
}
async function moveTo(mailbox: Mailbox): Promise<void> {
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id)
const result = await mailStore.moveChecked(mailbox.id, options)
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox.name)
} finally {
@@ -78,6 +83,18 @@ export function useBulkMailActions() {
await moveTo(trash.mailbox)
}
/** 스팸 신고 · 스팸 아님 — 옮기며 스팸 표시를 함께 바꾼다 (되돌리기는 원래 메일함 · 원래 표시로) */
async function moveSpam(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
const reportsSpam = !inJunk.value
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
if ('error' in target) {
notifyApiError(target.error)
return
}
await moveTo(target.mailbox, { isJunk: reportsSpam })
}
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
async function purge(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
@@ -91,5 +108,18 @@ export function useBulkMailActions() {
}
}
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
return {
busy,
allFlagged,
inTrash,
inJunk,
spamTarget,
moveTargets,
markRead,
toggleFlagged,
moveTo,
moveToTrash,
moveSpam,
purge,
}
}
+83
View File
@@ -3,6 +3,7 @@ import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import {
MAIL_IDEMPOTENCY_HEADER,
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
} from '@/shared/constants/mail'
@@ -14,7 +15,12 @@ import type {
MailQuota,
MailSignature,
MailState,
MailVacation,
Mailbox,
MailboxBulkResult,
MailboxCreatePayload,
MailboxDeletion,
MailboxRenamePayload,
MessageDeletionRequest,
MessageDeletionResult,
MessageDetail,
@@ -83,6 +89,59 @@ export function useMail() {
return api.get<MailQuota, MailQuota>('/mail-quota', options)
}
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ─────────────
/** 새 메일함 — 같은 자리에 같은 이름이면 409 MAIL_009, 없는 parentId 는 404 RES_001. parentId 가 없으면 맨 위 */
function createMailbox(payload: MailboxCreatePayload, options?: MailRequestOptions): Promise<Mailbox> {
const body: MailboxCreatePayload = payload.parentId ? payload : { name: payload.name }
return api.post<Mailbox, Mailbox>('/mailboxes', body, options)
}
/** 이름 바꾸기 — 기본 메일함은 400 VAL_001 (화면은 메뉴에 두지 않는다) */
function renameMailbox(id: string, payload: MailboxRenamePayload, options?: MailRequestOptions): Promise<Mailbox> {
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, payload, options)
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
*/
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
...options,
})
}
/** 그 메일함의 안 읽은 메일을 읽음으로 — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
function markMailboxRead(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
return api.patch<MailboxBulkResult, MailboxBulkResult>(
`/mailboxes/${segment(id)}/messages`,
{ isRead: true },
{ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS, ...options },
)
}
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
function emptyMailbox(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
return api.delete<MailboxBulkResult, MailboxBulkResult>(`/mailboxes/${segment(id)}/messages`, {
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
...options,
})
}
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
}
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -248,9 +307,33 @@ export function useMail() {
return true
}
/** 원문(.eml) 주소 — 받은 그대로의 메일 message/rfc822 (표준 래퍼 없음, 4단계 "스팸 · 원문") */
function messageSourceUrl(messageId: string): string {
return buildApiUrl(`/messages/${segment(messageId)}/source`)
}
/**
* 원문을 .eml 로 내려받는다 — 첨부 내려받기와 같이 이동하기 전에 세션을 확인한다 (confirmSession).
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자·토스트)는 이미 끝났다.
*/
async function downloadMessageSource(messageId: string): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(messageSourceUrl(messageId))
return true
}
return {
listMailboxes,
getQuota,
createMailbox,
renameMailbox,
deleteMailbox,
markMailboxRead,
emptyMailbox,
getVacation,
saveVacation,
messageSourceUrl,
downloadMessageSource,
getMailState,
listMessages,
getMessage,
@@ -1,11 +1,19 @@
import { useToast } from './useToast'
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
import {
movedMessageText,
notSpamMovedText,
purgedMessageText,
restoredMessageText,
type BulkOrigin,
} from '@/shared/utils/mail-bulk'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
/**
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
* - "메일 N통을 영구 삭제했습니다."
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
@@ -15,8 +23,8 @@ export function useMailMoveFeedback() {
const mailStore = useMailStore()
const toast = useToast()
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
const result = await mailStore.restoreMoved(origins)
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
const result = await mailStore.restoreMoved(origins, options)
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
@@ -27,9 +35,12 @@ export function useMailMoveFeedback() {
const skipped = result.skippedIds.length
if (done === 0 && skipped === 0) return
const origins = result.origins
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
const text =
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
toast.success(
movedMessageText(done, mailboxName, skipped),
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
text,
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
)
}
@@ -0,0 +1,155 @@
import { nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { notifyApiError, toApiError, type ApiError } from './useApi'
import { useMailNavigation } from './useMailNavigation'
import { useToast } from './useToast'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { isSearchRouteName, listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { initialOperationTotal, mailboxOperationCopy, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
import { useMailStore } from '@/stores/mail.store'
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
// 비우기 · 지우기는 되돌릴 수 없어 [되돌리기]가 없다. 확인 대화상자는 부르는 쪽이 먼저 띄운다.
// 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다 (isBlocked — 단추 · 메뉴 항목을 끄고, 확인하는 순간에도 다시 본다)
// - 끝난 뒤: 보고 있던 메일함을 지웠으면 받은편지함으로, 비운 메일함의 메일을 열어 두었으면 그 목록으로 (기록을 쌓지 않는다).
// 비우기 · 지우기를 누른 단추(목록 머리 「비우기」 · 지운 메일함의 ⋯)가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 옮긴다
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
export type MailboxNameResult =
| { status: 'saved'; mailbox: Mailbox }
/** inline: 대화상자가 칸 아래에 보일 실패 (알림을 끈 코드) */
| { status: 'failed'; error: ApiError; inline: boolean }
/** 기다리는 사이 세션이 바뀌었다 */
| { status: 'canceled' }
/** 초점이 갈 곳을 잃었는가 — 누른 단추가 사라졌거나 꺼졌다 */
function focusIsLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
export function useMailboxActions() {
const mailStore = useMailStore()
const router = useRouter()
const toast = useToast()
const { replaceWithMailbox } = useMailNavigation()
function isRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
return mailStore.isMailboxOperationRunning(kind, mailboxId)
}
/** 지금은 시작할 수 없는가 — 휴지통 비우기 ↔ 메일함 지우기 (스토어의 isMailboxOperationBlocked) */
function isBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
return mailStore.isMailboxOperationBlocked(kind, mailbox)
}
async function saveName(
run: () => Promise<Mailbox | null>,
done: (mailbox: Mailbox) => string,
): Promise<MailboxNameResult> {
try {
const mailbox = await run()
if (!mailbox) return { status: 'canceled' }
toast.success(done(mailbox))
return { status: 'saved', mailbox }
} catch (error) {
const apiError = toApiError(error)
return { status: 'failed', error: apiError, inline: NAME_INLINE_CODES.includes(apiError.code) }
}
}
/** 새 메일함 — parentId 가 없으면 맨 위 */
function create(name: string, parentId: string | null): Promise<MailboxNameResult> {
return saveName(
() => mailStore.createMailbox(name, parentId, { silent: NAME_INLINE_CODES }),
(mailbox) => MAILBOX_RESULT_TEXT.created(mailbox.name),
)
}
function rename(mailbox: Mailbox, name: string): Promise<MailboxNameResult> {
return saveName(
() => mailStore.renameMailbox(mailbox.id, name, { silent: NAME_INLINE_CODES }),
() => MAILBOX_RESULT_TEXT.renamed,
)
}
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
function isViewing(mailboxId: string): boolean {
const route = router.currentRoute.value
if (route.name === 'mail-mailbox' || route.name === 'mail-message') return route.params.mailboxId === mailboxId
return isSearchRouteName(route.name) && scopeMailboxIdOf(mailStore.listSource) === mailboxId
}
/** 끝난 뒤의 이동 (위 규칙) */
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
if (!isViewing(mailboxId)) return
if (kind === 'delete') {
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
return
}
const source = mailStore.listSource
if (kind === 'empty' && source && router.currentRoute.value.params.messageId) {
await router.replace(listLocationOf(source))
}
}
/** 누른 단추가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
async function recoverFocus(): Promise<void> {
await nextTick()
if (!focusIsLost()) return
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
}
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
const copy = mailboxOperationCopy(kind, mailbox.name)
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
progress.update(copy.progress(step.processed, step.total)),
)
if (outcome.status === 'canceled') {
progress.dismiss()
return
}
if (outcome.status === 'failed') {
if (!outcome.notified) notifyApiError(outcome.error)
if (outcome.processed > 0) progress.fail(copy.failed(outcome.processed, outcome.total))
else progress.dismiss()
return
}
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
await moveAway(kind, mailbox.id)
if (kind !== 'read') await recoverFocus()
}
/** 모두 읽음으로 표시 */
function markAllRead(mailbox: Mailbox): Promise<void> {
return runOperation('read', mailbox)
}
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 확인 대화상자를 거친 뒤에만 부른다 */
function empty(mailbox: Mailbox): Promise<void> {
return runOperation('empty', mailbox)
}
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
function remove(mailbox: Mailbox): Promise<void> {
return runOperation('delete', mailbox)
}
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
}
+48 -2
View File
@@ -1,18 +1,27 @@
import { useRouter } from 'vue-router'
import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import { useMailNavigation } from './useMailNavigation'
import type { MessageSummary } from '@/shared/types/mail'
import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
// (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문" · 계약 2절 "여러 통 한꺼번에" · "스팸 · 원문")
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
export interface MessageRemovalOutcome {
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
@@ -25,7 +34,9 @@ const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
export function useMessageRemoval() {
const mailStore = useMailStore()
const router = useRouter()
const feedback = useMailMoveFeedback()
const { backToSourceList } = useMailNavigation()
/**
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
@@ -37,6 +48,12 @@ export function useMessageRemoval() {
return message.mailboxIds.every((id) => id === trashId)
}
/** 스팸메일함에 든 메일 — 「스팸 신고」 대신 「스팸 아님」 */
function isInJunk(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
const junkId = mailStore.mailboxByRole('junk')?.id
return Boolean(junkId && message.mailboxIds.includes(junkId))
}
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
return { removed, nextId: removed ? nextId : null }
@@ -63,5 +80,34 @@ export function useMessageRemoval() {
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
return { deletesPermanently, moveToTrash, purge }
/** 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다 */
async function moveSpam(message: MessageSummary): Promise<MessageRemovalOutcome> {
const reportsSpam = !isInJunk(message)
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
if ('error' in target) {
notifyApiError(target.error)
return NOTHING
}
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
feedback.announceMove(result, target.mailbox.name)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
async function leaveRemoved(
outcome: MessageRemovalOutcome,
source: MailListSource | null,
layout: 'split' | 'list',
): Promise<void> {
if (!outcome.removed || !source) return
if (layout === 'list') {
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToSourceList(source)
return
}
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
}
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
}
+36 -3
View File
@@ -1,11 +1,23 @@
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore, type ToastAction } from '@/stores/toast.store'
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
export interface ProgressToast {
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
update: (message: string) => void
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
succeed: (message: string) => void
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
fail: (message: string) => void
/** 알리지 않고 닫는다 */
dismiss: () => void
}
/**
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store). 진행 알림도 띄운 세션에서만 바뀐다
*/
export function useToast() {
const store = useToastStore()
@@ -27,5 +39,26 @@ export function useToast() {
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
}
return { success, dismiss: store.dismiss }
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
function progress(message: string): ProgressToast {
const session = authStore.generation
let id = store.show({ kind: 'progress', message })
const sameSession = () => authStore.generation === session
function settle(kind: TimedToastKind, text: string) {
if (!sameSession()) return
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
}
return {
update: (text) => {
if (sameSession()) store.update(id, { message: text })
},
succeed: (text) => settle('success', text),
fail: (text) => settle('error', text),
dismiss: () => store.dismiss(id),
}
}
return { success, progress, dismiss: store.dismiss }
}
+4
View File
@@ -2,6 +2,7 @@
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
import AppShell from '@/components/shell/AppShell.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
@@ -19,6 +21,8 @@ void mailStore.ensureMailboxes()
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
useMailStatePolling({
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountPasswordPage from './AccountPasswordPage.vue'
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CURRENT = 'old-pass-1234'
const NEXT = 'Ara!2026-mail'
describe('AccountPasswordPage', () => {
let pinia: Pinia
let requests: MockRequest[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(reply: (request: MockRequest) => MockResult | Promise<MockResult> = () => ok(null)) {
requests = installMockApi((request) => (request.url === '/auth/password' ? reply(request) : ok(null)))
const router = createTestRouter()
await router.push('/settings/account/password')
await router.isReady()
const wrapper = mount(AccountPasswordPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return wrapper
}
async function fill(wrapper: VueWrapper, values: { current?: string; next?: string; confirm?: string }) {
if (values.current !== undefined) await wrapper.get('#password-current').setValue(values.current)
if (values.next !== undefined) await wrapper.get('#password-next').setValue(values.next)
if (values.confirm !== undefined) await wrapper.get('#password-confirm').setValue(values.confirm)
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const passwordRequests = () => requests.filter((request) => request.url === '/auth/password')
const errorOf = (wrapper: VueWrapper, id: string) => wrapper.find(`#${id}-error`)
it('칸 셋 · 안내 · 안내 상자 — 앞의 두 칸에 보기 단추(aria-pressed), 확인 칸은 새 비밀번호를 따른다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('비밀번호')
expect(wrapper.findAll('label').map((label) => label.text())).toEqual(['지금 비밀번호', '새 비밀번호', '새 비밀번호 확인'])
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
expect(wrapper.get('#password-next-hint').text()).toBe(
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
)
expect(wrapper.get('[data-password-note]').text()).toContain('메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.')
const toggles = wrapper.findAll('.password-field__toggle')
expect(toggles.map((toggle) => toggle.attributes('aria-label'))).toEqual(['지금 비밀번호 보기', '새 비밀번호 보기'])
await toggles[1]?.trigger('click')
expect(toggles[1]?.attributes('aria-pressed')).toBe('true')
// 이름은 그대로다 — 켜짐은 aria-pressed 로만
expect(toggles[1]?.attributes('aria-label')).toBe('새 비밀번호 보기')
expect(wrapper.get('#password-next').attributes('type')).toBe('text')
expect(wrapper.get('#password-confirm').attributes('type')).toBe('text')
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
})
it('보내기 전에 규칙을 본다 — 8자 미만이면 안내 글이 빨간 오류, 확인 칸이 다르면 "새 비밀번호가 서로 다릅니다." · 보내지 않는다', async () => {
const wrapper = await mountPage()
await fill(wrapper, { current: CURRENT, next: 'short', confirm: 'shorter' })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
expect(errorOf(wrapper, 'password-confirm').text()).toBe('새 비밀번호가 서로 다릅니다.')
expect(document.activeElement?.id).toBe('password-next')
// 고치면 그 칸의 오류를 내린다
await fill(wrapper, { next: NEXT })
expect(wrapper.get('#password-next').attributes('aria-invalid')).toBe('false')
expect(errorOf(wrapper, 'password-confirm').exists()).toBe(false)
})
it('지금 비밀번호와 같으면 보내지 않는다 · 지금 비밀번호를 비우면 그 칸에 사전 문구', async () => {
const wrapper = await mountPage()
await fill(wrapper, { current: NEXT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(wrapper.get('#password-next-hint').classes()).toContain('form-message--error')
await fill(wrapper, { current: '' })
await submit(wrapper)
expect(passwordRequests()).toEqual([])
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(document.activeElement?.id).toBe('password-current')
})
it('바꾸면 PUT /auth/password { currentPassword, newPassword } — "비밀번호를 바꿨습니다…" 를 보이고 세 칸을 비운다', async () => {
const wrapper = await mountPage()
await wrapper.findAll('.password-field__toggle')[0]?.trigger('click')
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests().map((request) => [request.method, request.data])).toEqual([
['PUT', { currentPassword: CURRENT, newPassword: NEXT }],
])
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
for (const id of ['password-current', 'password-next', 'password-confirm']) {
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
}
// 보기도 끈다
expect(wrapper.get('#password-current').attributes('type')).toBe('password')
expect(useAuthStore().isLoggedIn).toBe(true)
// 다시 치면 내린다
await fill(wrapper, { current: 'x' })
expect(wrapper.get('#password-status').text()).toBe('')
})
it('지금 비밀번호가 틀리면(400 AUTH_005) 알림 없이 지금 비밀번호 칸 아래에 사전 문구 — 세션은 그대로', async () => {
const wrapper = await mountPage(() => fail(400, 'AUTH_005'))
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(errorOf(wrapper, 'password-current').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(ERROR_CODE_MESSAGES.AUTH_005).toBe('지금 비밀번호가 올바르지 않습니다.')
expect(wrapper.get('#password-current').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('password-current')
expect(useToastStore().toasts).toEqual([])
expect(useAuthStore().isLoggedIn).toBe(true)
// 쓴 것은 남긴다 — 지금 비밀번호만 고쳐 다시 보낼 수 있게
expect((wrapper.get('#password-next').element as HTMLInputElement).value).toBe(NEXT)
await fill(wrapper, { current: CURRENT })
expect(errorOf(wrapper, 'password-current').exists()).toBe(false)
})
it('엔진 규칙에 어긋나면(400 VAL_001) 새 비밀번호 칸 아래에 사전 문구 · 그 밖의 실패(MAIL_001)는 인터셉터 알림', async () => {
let reply: MockResult = fail(400, 'VAL_001')
const wrapper = await mountPage(() => reply)
await fill(wrapper, { current: CURRENT, next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(errorOf(wrapper, 'password-next').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
reply = fail(503, 'MAIL_001')
await submit(wrapper)
expect(errorOf(wrapper, 'password-next').exists()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('기다리는 사이 화면을 떠났는데 지금 비밀번호가 틀렸으면(AUTH_005) 볼 칸이 없으니 알림으로', async () => {
let finish: (result: MockResult) => void = () => undefined
const wrapper = await mountPage(
() =>
new Promise<MockResult>((resolve) => {
finish = resolve
}),
)
await fill(wrapper, { current: 'wrong-pass', next: NEXT, confirm: NEXT })
await submit(wrapper)
expect(passwordRequests()).toHaveLength(1)
wrapper.unmount()
finish(fail(400, 'AUTH_005'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', ERROR_CODE_MESSAGES.AUTH_005],
])
expect(useAuthStore().isLoggedIn).toBe(true)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호") — /settings/account/password · 설정의 첫 화면
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 메일 프로그램(IMAP)에 새 비밀번호를 다시 넣는 것
import AppIcon from '@/components/AppIcon.vue'
import PasswordChangeForm from '@/components/settings/PasswordChangeForm.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { PASSWORD_TEXT } from '@/shared/constants/account'
</script>
<template>
<SettingsPageFrame
title-id="password-page-title"
:title="PASSWORD_TEXT.title"
:description="PASSWORD_TEXT.description"
>
<PasswordChangeForm />
<div
class="password-note"
data-password-note
>
<AppIcon
name="info"
class="password-note__icon"
/>
<div class="password-note__text">
<span>{{ PASSWORD_TEXT.noteSessions }}</span>
<span>{{ PASSWORD_TEXT.noteImap }}</span>
</div>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.password-note {
display: flex;
align-items: flex-start;
gap: var(--space-10);
max-width: var(--note-max-width);
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.password-note__icon {
margin-top: var(--space-2);
color: var(--color-brand-strong);
}
.password-note__text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailVacation } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.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, routeViewProvide } from '@/test-utils/router'
import MailVacationPage from './MailVacationPage.vue'
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
const SAVED: MailVacation = {
isEnabled: true,
fromDate: startOfDayIso('2026-10-30'),
toDate: endOfDayIso('2026-11-03'),
subject: '부재중 안내 — 11월 4일 복귀',
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
}
interface Server {
vacation?: MailVacation | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MailVacationPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
requests = installMockApi((request) => {
if (request.method === 'GET' && request.url === '/mail-vacation') {
const vacation = server.vacation ?? NEVER
return typeof vacation === 'string' || 'status' in vacation ? vacation : ok(vacation)
}
if (request.method === 'PUT' && request.url === '/mail-vacation') {
return server.save ? server.save(request) : ok(request.data)
}
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/mail/vacation')
await router.isReady()
const wrapper = mount(MailVacationPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
async function save(wrapper: VueWrapper) {
await wrapper.get('#vacation-save').trigger('click')
await flushPromises()
}
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('부재중 자동 응답')
expect((wrapper.get('#vacation-enabled').element as HTMLInputElement).checked).toBe(false)
expect((wrapper.get('#vacation-period-until-off').element as HTMLInputElement).checked).toBe(true)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
})
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
expect(wrapper.get('#vacation-period-hint').text()).toBe(
'시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
)
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get vacation() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
reply = SAVED
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
})
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
await wrapper.get('#vacation-period-range').setValue(true)
// 날짜가 비었으면 시작을 오늘로 채운다
expect(value(wrapper, '#vacation-from')).toBe(formatDate(new Date()))
await wrapper.get('#vacation-from').setValue('2026-10-30')
await wrapper.get('#vacation-to').setValue('2026-11-03')
await wrapper.get('#vacation-subject').setValue('부재중 안내')
await wrapper.get('#vacation-body').setValue('휴가입니다.')
await save(wrapper)
expect(puts()).toEqual([
{
isEnabled: true,
fromDate: new Date(2026, 9, 30).toISOString(),
toDate: new Date(2026, 10, 4).toISOString(),
subject: '부재중 안내',
textBody: '휴가입니다.',
},
])
expect(wrapper.get('#vacation-save-status').text()).toBe(
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
)
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
// 다시 바꾸면 내린다
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
expect(wrapper.get('#vacation-save-status').text()).toBe('')
})
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
await wrapper.get('#vacation-period-range').setValue(true)
await wrapper.get('#vacation-from').setValue('2026-11-05')
await wrapper.get('#vacation-to').setValue('2026-11-03')
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-period-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-subject').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('vacation-from')
// 고친 칸의 문구만 내린다
await wrapper.get('#vacation-subject').setValue('부재중 안내')
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
})
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-enabled').setValue(false)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
const { wrapper: fresh } = await mountPage()
await save(fresh)
expect(puts().at(-1)).toEqual(NEVER)
})
it('끌 때까지 계속 — 두 날짜 모두 null · "끌 때까지 자동으로 답합니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-period-until-off').setValue(true)
expect(wrapper.find('#vacation-from').exists()).toBe(false)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, fromDate: null, toDate: null }])
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
})
it('서버가 거절하면(VAL_001) 알림 없이 저장 단추 옆에 사전 문구', async () => {
const { wrapper } = await mountPage({ vacation: SAVED, save: () => fail(400, 'VAL_001') })
await save(wrapper)
expect(wrapper.get('#vacation-save-status').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
})
it('저장하는 사이 화면을 떠났는데 서버가 거절하면(VAL_001) 볼 곳이 없으니 알림으로 — 떠날 때는 묻지 않는다', async () => {
let finish: (result: MockResult) => void = () => undefined
const { wrapper, router } = await mountPage({
vacation: SAVED,
save: () =>
new Promise<MockResult>((resolve) => {
finish = resolve
}),
})
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await save(wrapper)
expect(wrapper.get('#vacation-save').attributes('aria-busy')).toBe('true')
await router.push('/mail/inbox')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
wrapper.unmount()
finish(fail(400, 'VAL_001'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', ERROR_CODE_MESSAGES.VAL_001],
])
})
describe('나가기 확인 (서명 설정과 같다)', () => {
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
const stay = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(true)
expect(wrapper.get('#vacation-leave-title').text()).toBe('저장하지 않은 자동 응답이 있습니다')
expect(wrapper.get('#vacation-leave-message').text()).toBe('이 화면을 나가면 바꾼 내용이 사라집니다.')
expect(document.activeElement?.id).toBe('vacation-leave-stay')
await wrapper.get('#vacation-leave-stay').trigger('click')
await stay
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/vacation')
const leave = router.push('/mail/inbox')
await flushPromises()
await wrapper.get('#vacation-leave-confirm').trigger('click')
await leave
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await wrapper.get('#vacation-subject').setValue(SAVED.subject)
await wrapper.get('#vacation-body').setValue('바꾼 내용')
await save(wrapper)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
})
})
@@ -0,0 +1,51 @@
<script setup lang="ts">
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import VacationForm from '@/components/settings/VacationForm.vue'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
const vacationStore = useMailVacationStore()
function reload() {
void vacationStore.load()
}
// 처음 그리기 전에 — 지난번에 받아 둔 값으로 칸을 잠깐 그렸다 지우지 않게
reload()
</script>
<template>
<SettingsPageFrame
title-id="vacation-page-title"
:title="VACATION_TEXT.title"
:description="VACATION_TEXT.description"
>
<MailLoadError
v-if="vacationStore.status === 'error' && vacationStore.error"
:error="vacationStore.error"
leave-label=""
@retry="reload"
/>
<VacationForm
v-else-if="vacationStore.status === 'ready' && vacationStore.vacation"
:vacation="vacationStore.vacation"
/>
<div
v-else
class="vacation-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* 받는 동안의 자리 — 사용 · 기간 · 제목 · 내용 칸만큼 */
.vacation-page__placeholder {
min-height: 22rem;
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
</style>
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
describe('SettingsLayout', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
document.body.innerHTML = ''
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(SettingsLayout, {
global: { plugins: [pinia, router], stubs: { RouterView: { render: () => h('div') } } },
})
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
expect(menu.findAll('.settings-menu__group').map((group) => group.text())).toEqual(['계정', '메일'])
const lists = menu.findAll('ul')
expect(lists.map((list) => list.attributes('aria-labelledby'))).toEqual(['settings-menu-account', 'settings-menu-mail'])
expect(lists.map((list) => list.findAll('a').map((link) => [link.text(), link.attributes('href')]))).toEqual([
[['비밀번호', '/settings/account/password']],
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
],
])
})
it('지금 화면의 항목만 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/vacation')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').findAll('a[aria-current="page"]')
expect(current.map((link) => link.text())).toEqual(['부재중 자동 응답'])
expect(current[0]?.classes()).toContain('is-active')
})
it('/settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 — 앱의 라우트 표 그대로', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings')
expect(router.currentRoute.value.name).toBe('settings-account-password')
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
})
})
+43 -24
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { SETTINGS_MENU } from '@/shared/constants/settings-menu'
function groupLabelId(groupId: string): string {
return `settings-menu-${groupId}`
}
</script>
<template>
@@ -11,30 +17,38 @@ import AppShell from '@/components/shell/AppShell.vue'
class="settings-menu"
aria-label="설정 메뉴"
>
<p
id="settings-menu-mail"
class="settings-menu__group"
<template
v-for="group in SETTINGS_MENU"
:key="group.id"
>
메일
</p>
<ul
class="settings-menu__list"
aria-labelledby="settings-menu-mail"
>
<li>
<RouterLink
:to="{ name: 'settings-mail-signature' }"
class="settings-menu__link"
active-class="is-active"
<p
:id="groupLabelId(group.id)"
class="settings-menu__group"
>
{{ group.label }}
</p>
<ul
class="settings-menu__list"
:aria-labelledby="groupLabelId(group.id)"
>
<li
v-for="item in group.items"
:key="item.routeName"
>
<AppIcon
name="edit"
:size="16"
/>
<span>서명</span>
</RouterLink>
</li>
</ul>
<RouterLink
:to="{ name: item.routeName }"
class="settings-menu__link"
active-class="is-active"
>
<AppIcon
:name="item.icon"
:size="16"
/>
<span>{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</template>
</nav>
<RouterView />
</AppShell>
@@ -59,6 +73,11 @@ import AppShell from '@/components/shell/AppShell.vue'
color: var(--color-text-muted);
}
/* 둘째 묶음부터 위를 띄운다 (디자인 원본 SettingsAccount — 묶음 사이 14) */
.settings-menu__list + .settings-menu__group {
margin-top: var(--space-14);
}
.settings-menu__list {
display: flex;
flex-direction: column;
+2 -66
View File
@@ -1,10 +1,10 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { documentTitle } from '@/shared/utils/document-title'
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
import { toSafeRedirect } from '@/shared/utils/redirect'
import { useAuthStore } from '@/stores/auth.store'
import { routes } from './routes'
declare module 'vue-router' {
interface RouteMeta {
@@ -17,70 +17,6 @@ declare module 'vue-router' {
}
}
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: { name: 'mail' },
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
meta: { guestOnly: true, title: '로그인' },
},
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
meta: { requiresAuth: true, title: '메일' },
children: [
// /mail → 메일함을 불러온 뒤 받은편지함으로 (MailboxPage 가 이동시킨다)
{ path: '', name: 'mail', component: MailboxPage },
{
path: 'compose',
name: 'mail-compose',
component: () => import('@/pages/mail/ComposePage.vue'),
meta: { title: '메일 쓰기' },
},
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
{
path: 'search/:messageId',
name: MAIL_SEARCH_MESSAGE_ROUTE,
component: MailboxPage,
meta: { title: '메일 검색' },
},
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
},
{
// 설정 — 왼쪽 메뉴(메일 › 서명) + 하위 화면. /settings 는 첫 메뉴로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
meta: { requiresAuth: true, title: '설정' },
children: [
{ path: '', name: 'settings', redirect: { name: 'settings-mail-signature' } },
{
path: 'mail/signature',
name: 'settings-mail-signature',
component: () => import('@/pages/settings/MailSignaturePage.vue'),
meta: { title: '메일 서명' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
meta: { title: '페이지를 찾을 수 없음' },
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
+83
View File
@@ -0,0 +1,83 @@
import type { RouteRecordRaw } from 'vue-router'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
// 앱의 라우트 표 — 라우터(index.ts)와 라우트 테스트가 함께 쓴다 (이 파일은 라우터를 만들지 않는다)
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
export const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: { name: 'mail' },
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
meta: { guestOnly: true, title: '로그인' },
},
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
meta: { requiresAuth: true, title: '메일' },
children: [
// /mail → 메일함을 불러온 뒤 받은편지함으로 (MailboxPage 가 이동시킨다)
{ path: '', name: 'mail', component: MailboxPage },
{
path: 'compose',
name: 'mail-compose',
component: () => import('@/pages/mail/ComposePage.vue'),
meta: { title: '메일 쓰기' },
},
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
{
path: 'search/:messageId',
name: MAIL_SEARCH_MESSAGE_ROUTE,
component: MailboxPage,
meta: { title: '메일 검색' },
},
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
meta: { requiresAuth: true, title: '설정' },
children: [
{ path: '', name: 'settings', redirect: { name: SETTINGS_ROUTES.password } },
{
path: 'account/password',
name: SETTINGS_ROUTES.password,
component: () => import('@/pages/settings/AccountPasswordPage.vue'),
meta: { title: '비밀번호' },
},
{
path: 'mail/signature',
name: SETTINGS_ROUTES.signature,
component: () => import('@/pages/settings/MailSignaturePage.vue'),
meta: { title: '메일 서명' },
},
{
path: 'mail/vacation',
name: SETTINGS_ROUTES.vacation,
component: () => import('@/pages/settings/MailVacationPage.vue'),
meta: { title: '부재중 자동 응답' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
meta: { title: '페이지를 찾을 수 없음' },
},
]
+31
View File
@@ -0,0 +1,31 @@
// 설정 › 계정 › 비밀번호 상수 — 계약 1절 "PUT /api/auth/password" · 디자인 README "4단계 › 비밀번호" (디자인 원본 SettingsAccount)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다 (넘으면 400 VAL_001).
/** 새 비밀번호 최소 길이 — 메일 엔진도 8자 미만을 거절한다 */
export const PASSWORD_MIN_LENGTH = 8
/** 새 비밀번호 최대 길이 (JS 문자열 길이) */
export const PASSWORD_MAX_LENGTH = 128
/**
* 비밀번호 바꾸기 요청 하나의 시간 한도 — 서버는 메일 엔진을 두 번(각 10초까지) 부른 뒤 다른 세션을 끊어 최악 약 21초가 걸린다.
* 기본 10초로 끊으면 화면은 실패로 보는데 서버는 이미 바꿨을 수 있고, 다시 누르면 옛 비밀번호라 AUTH_005 가
* 로그인 실패 한도에 쌓인다 (shared/constants/mail.ts 의 긴 요청 한도와 같은 방식)
*/
export const PASSWORD_CHANGE_TIMEOUT_MS = 30 * 1000
export const PASSWORD_TEXT = {
title: '비밀번호',
description: '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
current: '지금 비밀번호',
next: '새 비밀번호',
confirm: '새 비밀번호 확인',
/** 보기/숨기기 단추의 이름 — 눌림 상태는 aria-pressed 로 알리므로 이름은 바꾸지 않는다 */
showCurrent: '지금 비밀번호 보기',
showNext: '새 비밀번호 보기',
/** 새 비밀번호 아래 안내 — 규칙에 맞지 않으면 이 글이 빨간 오류로 바뀐다 */
hint: '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
mismatch: '새 비밀번호가 서로 다릅니다.',
submit: '비밀번호 바꾸기',
done: '비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.',
noteSessions: '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
noteImap: '휴대폰이나 PC 메일 프로그램(IMAP)에는 새 비밀번호를 다시 넣어 주세요.',
} as const
+24
View File
@@ -224,6 +224,30 @@ export const ICONS = {
],
/** 배경 색 "없음" 견본의 빗금 */
slash: [line(5, 19, 19, 5)],
// ── 4단계: 계정 · 메일 설정 (디자인 원본 SettingsAccount · SettingsVacation · MailboxManage) ──
/** 설정 메뉴의 비밀번호 */
lock: [rect(3, 11, 18, 11, 2), path('M7 11V7a5 5 0 0 1 10 0v4')],
/** 설정 메뉴의 부재중 자동 응답 (달력 + ✕) */
'calendar-x': [
rect(3, 4, 18, 18, 2),
line(16, 2, 16, 6),
line(8, 2, 8, 6),
line(3, 10, 21, 10),
line(10, 14, 14, 18),
line(14, 14, 10, 18),
],
/** 비밀번호 보기 — 숨긴 상태 */
eye: [path('M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'), circle(12, 12, 3)],
/** 비밀번호 보기 — 보이는 상태 */
'eye-off': [
path(
'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24',
),
line(1, 1, 23, 23),
],
/** 메일함 패널의 "새 메일함" */
plus: [line(12, 5, 12, 19), line(5, 12, 19, 12)],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -0,0 +1,55 @@
// 설정 › 메일 › 부재중 자동 응답 상수 — 계약 2절 "부재중 자동 응답" · 디자인 README "4단계 › 부재중 자동 응답" (디자인 원본 SettingsVacation)
// 한도는 서버와 같은 값이다 — 켜 둘 때만 제목 · 내용이 필요하다 (서버가 최종 검증한다, 넘으면 400 VAL_001).
/** 제목 — 켤 때 1~200자 */
export const VACATION_SUBJECT_MAX_LENGTH = 200
/** 내용 — 켤 때 1~4,000자 (글자만) */
export const VACATION_BODY_MAX_LENGTH = 4_000
/**
* 메일함 패널 안내를 다시 계산하는 간격 — 끝나는 시각이 지나면 사라지고, 시작 전 문구("10월 30일 ~ …")가 시작한 뒤의 문구로 바뀐다
* (안내의 날짜는 하루 단위라 1분이면 충분하다)
*/
export const VACATION_NOTICE_REFRESH_MS = 60_000
export const VACATION_TEXT = {
title: '부재중 자동 응답',
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 두면 메일 화면의 메일함 패널 아래에 알립니다.',
enable: '부재중 자동 응답 사용',
periodLegend: '기간',
untilOff: '끌 때까지 계속',
range: '기간 정하기',
from: '시작',
to: '끝',
rangeHint: '시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
subject: '제목',
body: '내용',
bodyHint: '글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.',
save: '저장',
saving: '저장하는 중…',
previewTitle: '보낸 사람이 받는 답장',
previewFrom: '보낸 사람',
previewSubject: '제목',
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
previewNoteOff: '꺼져 있습니다 — 자동으로 답하지 않습니다.',
// 저장한 뒤의 상태 글 — 날짜는 "10월 30일(금)" (올해가 아니면 앞에 연도)
savedOff: '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.',
savedUntilOff: '저장했습니다. 끌 때까지 자동으로 답합니다.',
savedRange: (from: string, to: string) => `저장했습니다. ${from}부터 ${to}까지 자동으로 답합니다.`,
savedFrom: (from: string) => `저장했습니다. ${from}부터 자동으로 답합니다.`,
savedTo: (to: string) => `저장했습니다. ${to}까지 자동으로 답합니다.`,
// 메일 화면의 메일함 패널 아래(사용량 위) 안내 — 날짜는 "11월 3일"
noticeTitle: '부재중 자동 응답 켜짐',
noticeUntil: (to: string) => `${to}까지 · 설정`,
noticeUntilOff: '끌 때까지 · 설정',
/** 아직 시작하지 않았다 */
noticeRange: (from: string, to: string) => `${from} ~ ${to} · 설정`,
noticeFrom: (from: string) => `${from}부터 · 설정`,
} as const
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
export const VACATION_LEAVE_DIALOG = {
title: '저장하지 않은 자동 응답이 있습니다',
message: '이 화면을 나가면 바꾼 내용이 사라집니다.',
stay: '계속 고치기',
leave: '나가기',
} as const
+21
View File
@@ -105,6 +105,11 @@ export const MAIL_QUOTA_WARNING_RATIO = 0.9
export const MAIL_UPLOAD_TIMEOUT_MS = 5 * 60 * 1000
/** 발송 — 첨부를 붙여 메일 엔진에 넘기는 시간까지 기다린다 */
export const MAIL_SEND_TIMEOUT_MS = 60 * 1000
/**
* 오래 걸리는 메일함 처리(전체 읽음 · 비우기 · 메일함 지우기)의 요청 하나 — 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다
* (계약 2절 "메일함 관리"). 기본 10초로는 끊기므로 발송과 같은 값을 쓴다
*/
export const MAIL_MAILBOX_OPERATION_TIMEOUT_MS = 60 * 1000
// ── 중복 발송 방지 (계약 2절 "발송 본문") ─────────────────────
/** 쓰기 화면마다 만든 키(UUID)를 이 헤더로 보낸다 — 서버는 같은 키의 두 번째 발송을 내보내지 않는다 */
@@ -269,6 +274,22 @@ export const MAIL_BULK_TEXT = {
purgeCancel: '취소',
} as const
// ── 스팸 · 원문 (계약 2절 "스팸 · 원문" · 디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리") ─────────────
export const MAIL_SPAM_TEXT = {
/** 스팸메일함 밖 — 스팸메일함으로 옮기고 스팸 표시를 붙인다 */
report: '스팸 신고',
/** 스팸메일함 — 받은편지함으로 옮기고 표시를 "스팸 아님"으로 바꾼다 */
notSpam: '스팸 아님',
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다 (스팸 아님).`,
} as const
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
export const MAIL_READER_MORE_TEXT = {
button: '더 보기',
downloadSource: '원문 내려받기 (.eml)',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
@@ -0,0 +1,85 @@
// 메일함 관리 · 오래 걸리는 처리 상수 — 계약 2절 "메일함 관리" · 디자인 README "4단계 › 메일함 관리" (디자인 원본 MailboxManage · MailActions4)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다.
/** 메일함 이름 — 앞뒤 공백을 뺀 1~64자 (넘으면 400 VAL_001) */
export const MAILBOX_NAME_MAX_LENGTH = 64
/** 메일함 이름에 쓸 수 없는 글자 — 경로 구분자로 읽는 메일 프로그램이 있다 */
export const MAILBOX_NAME_FORBIDDEN_CHAR = '/'
/**
* 오래 걸리는 처리가 한 번의 요청에서 하나도 처리하지 못한 채 남은 수만 돌려준 횟수 — 이만큼 이어지면 멈추고 실패로 알린다.
* 서버는 한 요청에 500통씩 처리하므로 정상이라면 일어나지 않는다 (같은 요청을 끝없이 다시 보내지 않게 하는 안전장치)
*/
export const MAILBOX_OPERATION_MAX_STALLS = 3
export const MAILBOX_PANEL_TEXT = {
/** 기본 메일함 아래 묶음 이름 (12px 굵게) */
myMailboxes: '내 메일함',
newMailbox: '새 메일함',
/** ⋯ 단추의 이름 */
more: (name: string) => `${name} 메일함 메뉴`,
/** ⋯ 메뉴의 이름 */
menu: (name: string) => `${name} 메일함`,
} as const
/** ⋯ 메뉴 항목 (항목 높이 40 — 메뉴 항목 밀도 예외) */
export const MAILBOX_MENU_TEXT = {
markRead: '모두 읽음으로 표시',
createChild: '하위 메일함 만들기',
rename: '이름 바꾸기',
delete: '지우기',
emptyTrash: '휴지통 비우기',
emptyJunk: '스팸메일함 비우기',
} as const
/** 대화상자 (공용 모양 — ModalDialog) */
export const MAILBOX_DIALOG_TEXT = {
createTitle: '새 메일함',
createChildTitle: (parent: string) => `‘${parent}’ 안에 새 메일함`,
renameTitle: '메일함 이름 바꾸기',
nameLabel: '이름',
nameHelp: '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
create: '만들기',
rename: '바꾸기',
cancel: '취소',
deleteTitle: (name: string) => `‘${name}’ 메일함을 지울까요?`,
deleteMessage: (count: string) => `안의 메일 ${count}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.`,
deleteEmptyMessage: '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
deleteConfirm: '지우기',
blockedTitle: (name: string) => `‘${name}’ 메일함을 지울 수 없습니다`,
blockedMessage: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.',
blockedConfirm: '확인',
/** "휴지통의 메일 21통을 모두 영구 삭제할까요?" */
emptyTitle: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 모두 영구 삭제할까요?`,
emptyMessage: '지운 메일은 되돌릴 수 없습니다.',
emptyConfirm: '영구 삭제',
} as const
/** 만들기 · 이름 바꾸기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구") */
export const MAILBOX_RESULT_TEXT = {
created: (name: string) => `‘${name}’ 메일함을 만들었습니다.`,
renamed: '메일함 이름을 바꿨습니다.',
} as const
/**
* 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)의 알림 — 하는 중에는 남아 있다가 끝나면 결과로 바뀐다.
* 하는 중의 글은 "…하는 중" + 진행 수(" · 600 / 1,234통" — 셀 메일이 없으면 뺀다).
* done · total · count 는 formatCount 로 만든 글자. 비우기의 object 는 메일함 이름에 '을/를'까지 붙여 넘긴다 ("휴지통을")
*/
export const MAILBOX_OPERATION_TEXT = {
readProgress: (mailbox: string) => `${mailbox}의 메일을 읽음으로 표시하는 중`,
readDone: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 읽음으로 표시했습니다.`,
/** 처리할 것이 없었다 — 다른 탭 · 메일 프로그램이 먼저 했다 */
readNone: (mailbox: string) => `${mailbox}에 안 읽은 메일이 없습니다.`,
emptyProgress: (object: string) => `${object} 비우는 중`,
emptyDone: (object: string, count: string) => `${object} 비웠습니다 — 메일 ${count}통.`,
/** subject 에는 메일함 이름에 '이/가'까지 붙여 넘긴다 ("휴지통이") */
emptyNone: (subject: string) => `${subject} 이미 비어 있습니다.`,
deleteProgress: (mailbox: string) => `‘${mailbox}’ 메일함을 지우는 중`,
progressCount: (done: string, total: string) => ` · ${done} / ${total}통`,
deleteDone: (mailbox: string) => `‘${mailbox}’ 메일함을 지웠습니다.`,
deleteDoneMoved: (mailbox: string, count: string) =>
`‘${mailbox}’ 메일함을 지웠습니다. 안의 메일 ${count}통은 휴지통에 있습니다.`,
/** 도중에 실패 — 거기까지 처리한 수. 다시 하면 남은 것만 처리한다 */
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 처리했습니다 — 다시 시도해 주세요.`,
} as const
@@ -0,0 +1,40 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
export const SETTINGS_ROUTES = {
/** 설정의 첫 화면 — /settings 는 이 화면으로 바꿔 끼운다 (앱 레일의 "설정") */
password: 'settings-account-password',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
} as const
export interface SettingsMenuItem {
routeName: string
label: string
icon: IconName
}
export interface SettingsMenuGroup {
id: string
/** 묶음 이름 — 12px 굵게 */
label: string
items: readonly SettingsMenuItem[]
}
export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{
id: 'account',
label: '계정',
items: [{ routeName: SETTINGS_ROUTES.password, label: '비밀번호', icon: 'lock' }],
},
{
id: 'mail',
label: '메일',
items: [
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
],
},
]
+10
View File
@@ -12,3 +12,13 @@ export interface LoginPayload {
email: string
password: string
}
/**
* PUT /auth/password 요청 본문 (4단계) — 성공은 200 data: null, 지금 비밀번호가 틀리면 400 AUTH_005.
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다
*/
export interface ChangePasswordPayload {
currentPassword: string
/** 8~128자 · 지금 비밀번호와 달라야 한다 (넘으면 400 VAL_001) */
newPassword: string
}
+57
View File
@@ -110,6 +110,11 @@ export interface UpdateMessagePayload {
isRead?: boolean
isFlagged?: boolean
mailboxId?: string
/**
* 스팸 표시 (4단계 "스팸 · 원문") — true 면 $junk 를 붙이고 $notjunk 를 뗀다, false 면 반대.
* 화면은 이동과 함께 보낸다 — 스팸 신고 { mailboxId: 스팸메일함, isJunk: true } · 스팸 아님 { mailboxId: 받은편지함, isJunk: false }
*/
isJunk?: boolean
}
// ── 새 메일 확인 (계약 2절 "새 메일 확인" — 3단계) ──────────────────
@@ -203,6 +208,58 @@ export interface DraftSaved {
updatedAt: string
}
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ──────────────────
/** POST /mailboxes 본문 — parentId 가 없으면 맨 위 (받은편지함 · 보관함 · 내 메일함 아래만 만들 수 있다) */
export interface MailboxCreatePayload {
/** 앞뒤 공백을 뺀 1~64자, '/' 없음 — 같은 자리에 같은 이름이면 409 MAIL_009 */
name: string
parentId?: string | null
}
/** PATCH /mailboxes/{id} 본문 — 기본 메일함(역할이 있는 메일함)은 바꿀 수 없다 */
export interface MailboxRenamePayload {
name: string
}
/** DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다) */
export interface MailboxDeletion {
deleted: boolean
/**
* 이번 요청에서 이 메일함에서 빠진 메일 수 — 휴지통으로 옮긴 것 + 다른 메일함에도 있어 이 메일함에서만 뺀 것.
* 진행 · 정체 판정은 이 값으로 한다 (다른 메일함에도 든 메일만 남아도 나아가고 있다). 이 값을 아직 주지 않는 서버면 movedToTrash 로 본다
*/
processed: number
/** 이번 요청에서 휴지통으로 옮긴 메일 수 — 끝 알림의 "안의 메일 N통은 휴지통에 있습니다" */
movedToTrash: number
/** 아직 남은 메일 수 */
remaining: number
}
/** PATCH · DELETE /mailboxes/{id}/messages 응답 — 전체 읽음 · 비우기. remaining 이 0 이 될 때까지 같은 요청을 다시 보낸다 */
export interface MailboxBulkResult {
/** 이번 요청에서 읽음으로 바꾼 · 지운 메일 수 */
processed: number
remaining: number
}
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────────
/**
* GET · PUT /mail-vacation — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
* 제목(1~200자) · 내용(1~4,000자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
*/
export interface MailVacation {
isEnabled: boolean
/** ISO 8601 UTC — null 이면 켠 때부터. 화면은 시작일 0시(브라우저 시간대)를 보낸다 */
fromDate: string | null
/** ISO 8601 UTC — null 이면 끌 때까지. 화면은 끝나는 날 다음 날 0시(브라우저 시간대)를 보낸다 */
toDate: string | null
subject: string
/** 글자만(서식 없음) — 서명은 붙지 않는다 */
textBody: string
}
// ── 서명 (계약 2절 "서명") ─────────────────────────────
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { directionParticle, subjectParticle } from './korean'
import { directionParticle, objectParticle, subjectParticle } from './korean'
describe('subjectParticle (주격 조사 이/가)', () => {
it.each([
@@ -35,3 +35,17 @@ describe('directionParticle (방향 조사 으로/로)', () => {
expect(directionParticle(word)).toBe(particle)
})
})
describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습니다")', () => {
it.each([
['휴지통', '을'],
['스팸메일함', '을'],
['보관함', '을'],
['프로젝트', '를'],
['메일 서버', '를'],
['Work', '을(를)'],
['', '을(를)'],
])('%s → %s', (word, particle) => {
expect(objectParticle(word)).toBe(particle)
})
})
+10
View File
@@ -28,6 +28,16 @@ export function subjectParticle(word: string): '이' | '가' | '이(가)' {
return jongseong === NO_JONGSEONG ? '가' : '이'
}
/**
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다.
*/
export function objectParticle(word: string): '을' | '를' | '을(를)' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '을(를)'
return jongseong === NO_JONGSEONG ? '를' : '을'
}
/**
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
@@ -4,6 +4,7 @@ import {
chunk,
groupByOrigin,
movedMessageText,
notSpamMovedText,
purgedMessageText,
restoredMessageText,
sendInChunks,
@@ -64,4 +65,11 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
expect(purgedMessageText(0, 2)).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
})
it('스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
expect(notSpamMovedText(2, '받은편지함', 1)).toBe(
'메일 2통을 받은편지함으로 옮겼습니다 (스팸 아님). 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
})
})
+7 -1
View File
@@ -1,6 +1,6 @@
// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 · 알림 문구 (계약 2절 "여러 통 한꺼번에" — 순수 함수만 둔다)
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 { directionParticle } from '@/shared/utils/korean'
@@ -81,6 +81,12 @@ export function movedMessageText(done: number, mailboxName: string, skipped = 0)
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
}
/** 스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0): string {
const place = `${mailboxName}${directionParticle(mailboxName)}`
return withSkipped(MAIL_SPAM_TEXT.notSpamMoved(formatCount(done), place), done, skipped)
}
export function purgedMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
}
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import type { MailVacation } from '@/shared/types/mail'
import {
endOfDayIso,
fromDayOf,
sameVacationForm,
startOfDayIso,
toDayOf,
vacationFormOf,
vacationNoticeText,
vacationPayloadOf,
vacationProblems,
vacationSavedText,
type VacationForm,
} from './mail-vacation'
// 부재중 자동 응답 — 날짜 ↔ 시각 (계약 2절: 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로)
// 시간대마다 결과가 달라지므로 기대값도 같은 브라우저 시간대(로컬 Date)로 만든다
const OFF: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
function form(partial: Partial<VacationForm> = {}): VacationForm {
return {
isEnabled: true,
period: 'untilOff',
fromDay: '',
toDay: '',
subject: '부재중 안내',
textBody: '복귀 후 답장 드리겠습니다.',
...partial,
}
}
describe('날짜 ↔ 시각', () => {
it('시작일은 그날 0시, 끝나는 날은 다음 날 0시 — 브라우저 시간대 기준을 UTC ISO 로', () => {
expect(startOfDayIso('2026-10-30')).toBe(new Date(2026, 9, 30).toISOString())
expect(endOfDayIso('2026-11-03')).toBe(new Date(2026, 10, 4).toISOString())
// 달 · 해가 넘어가도 다음 날 0시
expect(endOfDayIso('2026-12-31')).toBe(new Date(2027, 0, 1).toISOString())
})
it('틀린 날짜 · 빈 값은 null', () => {
expect(startOfDayIso('')).toBeNull()
expect(startOfDayIso('2026-02-30')).toBeNull()
expect(endOfDayIso('2026/11/03')).toBeNull()
})
it('서버 값 → 화면 날짜 — 끝 시각은 그 직전 날짜가 끝나는 날', () => {
expect(fromDayOf(startOfDayIso('2026-10-30'))).toBe('2026-10-30')
expect(toDayOf(endOfDayIso('2026-11-03'))).toBe('2026-11-03')
expect(fromDayOf(null)).toBe('')
expect(toDayOf('잘못된 값')).toBe('')
})
})
describe('칸 ↔ 서버 값', () => {
it('설정한 적 없는 계정(꺼짐 · 빈 값)은 끌 때까지 · 빈 칸', () => {
expect(vacationFormOf(OFF)).toEqual({
isEnabled: false,
period: 'untilOff',
fromDay: '',
toDay: '',
subject: '',
textBody: '',
})
})
it('날짜가 하나라도 있으면 기간 정하기 — 저장하면 같은 시각이 돌아간다', () => {
const vacation: MailVacation = {
isEnabled: true,
fromDate: startOfDayIso('2026-10-30'),
toDate: endOfDayIso('2026-11-03'),
subject: '부재중 안내',
textBody: '휴가입니다.',
}
const filled = vacationFormOf(vacation)
expect(filled).toMatchObject({ period: 'range', fromDay: '2026-10-30', toDay: '2026-11-03' })
expect(vacationPayloadOf(filled)).toEqual(vacation)
})
it('끌 때까지 계속이면 숨은 날짜 칸이 남아 있어도 두 날짜 모두 null', () => {
expect(vacationPayloadOf(form({ fromDay: '2026-10-30', toDay: '2026-11-03' }))).toMatchObject({
fromDate: null,
toDate: null,
})
})
it('꺼 둔 채 저장하면 맞지 않는 기간은 비운다 — 끄기는 늘 저장된다 (빈 제목 · 내용도)', () => {
const payload = vacationPayloadOf(
form({ isEnabled: false, period: 'range', fromDay: '2026-11-05', toDay: '2026-11-03', subject: '', textBody: '' }),
)
expect(payload).toEqual({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' })
})
it('저장하지 않은 것이 있는지 — 끌 때까지 계속이면 숨은 날짜는 보지 않는다', () => {
expect(sameVacationForm(form(), form({ fromDay: '2026-10-30' }))).toBe(true)
expect(sameVacationForm(form({ period: 'range' }), form({ period: 'range', fromDay: '2026-10-30' }))).toBe(false)
expect(sameVacationForm(form(), form({ subject: '다른 제목' }))).toBe(false)
})
})
describe('저장하기 전 검사 — 켜 둘 때만', () => {
it('꺼 두면 아무것도 막지 않는다', () => {
expect(vacationProblems(form({ isEnabled: false, subject: '', textBody: '', period: 'range' }))).toEqual([])
})
it('켜 두면 제목 · 내용이 필요하다 (공백뿐이면 빈 것)', () => {
expect(vacationProblems(form({ subject: ' ', textBody: '\n' }))).toEqual(['subject', 'textBody'])
expect(vacationProblems(form())).toEqual([])
})
it('기간 정하기 — 날짜가 하나도 없거나 시작이 끝보다 늦으면 막는다 (같은 날은 하루)', () => {
expect(vacationProblems(form({ period: 'range' }))).toEqual(['period'])
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-04', toDay: '2026-11-03' }))).toEqual(['period'])
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-03', toDay: '2026-11-03' }))).toEqual([])
expect(vacationProblems(form({ period: 'range', toDay: '2026-11-03' }))).toEqual([])
})
})
describe('알리는 글', () => {
const now = new Date(2026, 9, 28, 9, 0)
const ranged = (from: string | null, to: string | null): MailVacation => ({
isEnabled: true,
fromDate: from ? startOfDayIso(from) : null,
toDate: to ? endOfDayIso(to) : null,
subject: '부재중 안내',
textBody: '휴가입니다.',
})
it('저장한 뒤 — 기간 · 끌 때까지 · 껐음 (디자인 원본 SettingsVacation)', () => {
expect(vacationSavedText(ranged('2026-10-30', '2026-11-03'), now)).toBe(
'저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다.',
)
expect(vacationSavedText(ranged(null, null), now)).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
expect(vacationSavedText(ranged(null, '2026-11-03'), now)).toBe('저장했습니다. 11월 3일(화)까지 자동으로 답합니다.')
expect(vacationSavedText(ranged('2026-10-30', null), now)).toBe('저장했습니다. 10월 30일(금)부터 자동으로 답합니다.')
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
})
it('올해가 아니면 연도를 붙인다', () => {
expect(vacationSavedText(ranged('2026-12-28', '2027-01-03'), now)).toBe(
'저장했습니다. 12월 28일(월)부터 2027년 1월 3일(일)까지 자동으로 답합니다.',
)
})
it('메일함 패널 안내 — 켜져 있는 동안만 · 끝나는 날 · 아직 시작 전이면 시작일', () => {
expect(vacationNoticeText(ranged('2026-10-27', '2026-11-03'), now)).toBe('11월 3일까지 · 설정')
expect(vacationNoticeText(ranged(null, null), now)).toBe('끌 때까지 · 설정')
expect(vacationNoticeText(ranged('2026-10-30', '2026-11-03'), now)).toBe('10월 30일 ~ 11월 3일 · 설정')
expect(vacationNoticeText(ranged('2026-10-30', null), now)).toBe('10월 30일부터 · 설정')
// 끝났거나 꺼져 있으면 보이지 않는다
expect(vacationNoticeText(ranged('2026-10-20', '2026-10-27'), now)).toBeNull()
expect(vacationNoticeText({ ...ranged(null, null), isEnabled: false }, now)).toBeNull()
expect(vacationNoticeText(null, now)).toBeNull()
})
})
+181
View File
@@ -0,0 +1,181 @@
// 부재중 자동 응답 — 화면의 날짜 ↔ 서버의 시각, 폼 검사, 안내 문구 (계약 2절 "부재중 자동 응답" — 순수 함수만 둔다)
// 화면은 날짜만 고른다. 서버에는 브라우저 시간대의 "시작일 0시"와 "끝나는 날 다음 날 0시"를 UTC(ISO 8601)로 보낸다 —
// 끝 시각은 그 시각 직전까지 답한다는 뜻이라, 읽을 때는 1ms 앞의 날짜가 끝나는 날이다.
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
/** 화면의 기간 고르기 — 끌 때까지 계속 · 기간 정하기 */
export type VacationPeriod = 'untilOff' | 'range'
/** 화면의 칸 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
export interface VacationForm {
isEnabled: boolean
period: VacationPeriod
fromDay: string
toDay: string
subject: string
textBody: string
}
/** 저장하기 전에 막는 문제 — 칸마다 하나 (켜 둘 때만 본다) */
export type VacationProblem = 'period' | 'subject' | 'textBody'
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
/** 끝 시각 바로 앞 — 끝나는 날을 구한다 */
const END_OFFSET_MS = 1
/** YYYY-MM-DD → 브라우저 시간대의 그날 0시 (없는 날짜 · 형식 오류면 null) */
function localMidnight(day: string, addDays = 0): Date | null {
const match = DAY_PATTERN.exec(day)
if (!match) return null
const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]
const check = new Date(year, month - 1, date)
// 2월 30일처럼 넘어간 날짜는 받지 않는다
if (check.getFullYear() !== year || check.getMonth() !== month - 1 || check.getDate() !== date) return null
return new Date(year, month - 1, date + addDays)
}
function parseInstant(value: string | null): Date | null {
if (!value) return null
const time = Date.parse(value)
return Number.isNaN(time) ? null : new Date(time)
}
/** 시작일 → 그날 0시(브라우저 시간대)의 UTC ISO — 비었거나 틀린 날짜면 null */
export function startOfDayIso(day: string): string | null {
return localMidnight(day)?.toISOString() ?? null
}
/** 끝나는 날 → 다음 날 0시(브라우저 시간대)의 UTC ISO — 그 직전(끝나는 날 자정)까지 답한다 */
export function endOfDayIso(day: string): string | null {
return localMidnight(day, 1)?.toISOString() ?? null
}
/** 서버의 시작 시각 → 화면의 시작일 (브라우저 시간대) */
export function fromDayOf(fromDate: string | null): string {
const instant = parseInstant(fromDate)
return instant ? formatDate(instant) : ''
}
/** 서버의 끝 시각(그 직전까지 답한다) → 화면의 끝나는 날 — 1ms 앞의 날짜 */
export function toDayOf(toDate: string | null): string {
const instant = parseInstant(toDate)
return instant ? formatDate(new Date(instant.getTime() - END_OFFSET_MS)) : ''
}
/** 서버 값 → 화면의 칸. 날짜가 하나라도 있으면 "기간 정하기" */
export function vacationFormOf(vacation: MailVacation): VacationForm {
const fromDay = fromDayOf(vacation.fromDate)
const toDay = toDayOf(vacation.toDate)
return {
isEnabled: vacation.isEnabled,
period: fromDay || toDay ? 'range' : 'untilOff',
fromDay,
toDay,
subject: vacation.subject ?? '',
textBody: vacation.textBody ?? '',
}
}
/** 고른 기간 — 날짜가 없거나 틀렸거나 시작이 끝보다 늦으면 null */
function rangeOf(form: Pick<VacationForm, 'fromDay' | 'toDay'>): { fromDate: string | null; toDate: string | null } | null {
const fromDate = form.fromDay ? startOfDayIso(form.fromDay) : null
const toDate = form.toDay ? endOfDayIso(form.toDay) : null
if ((form.fromDay && !fromDate) || (form.toDay && !toDate)) return null
if (!fromDate && !toDate) return null
if (fromDate && toDate && Date.parse(fromDate) >= Date.parse(toDate)) return null
return { fromDate, toDate }
}
/**
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도 저장된다 — 계약 2절).
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~200자 · 내용 1~4,000자 (공백뿐이면 빈 것)
*/
export function vacationProblems(form: VacationForm): VacationProblem[] {
if (!form.isEnabled) return []
const problems: VacationProblem[] = []
if (form.period === 'range' && !rangeOf(form)) problems.push('period')
if (form.subject.trim() === '' || form.subject.length > VACATION_SUBJECT_MAX_LENGTH) problems.push('subject')
if (form.textBody.trim() === '' || form.textBody.length > VACATION_BODY_MAX_LENGTH) problems.push('textBody')
return problems
}
/**
* 화면의 칸 → PUT /mail-vacation 본문 (전체 교체). 끌 때까지 계속이면 두 날짜 모두 null.
* 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비운다 — 끄기는 늘 저장되게
*/
export function vacationPayloadOf(form: VacationForm): MailVacation {
const range = form.period === 'range' ? rangeOf(form) : null
return {
isEnabled: form.isEnabled,
fromDate: range?.fromDate ?? null,
toDate: range?.toDate ?? null,
subject: form.subject,
textBody: form.textBody,
}
}
/** 두 폼이 같은 내용인가 — 저장하지 않은 것이 있는지 본다. 끌 때까지 계속이면 숨은 날짜 칸은 보지 않는다 */
export function sameVacationForm(a: VacationForm, b: VacationForm): boolean {
const sameRange = a.period === 'untilOff' || (a.fromDay === b.fromDay && a.toDay === b.toDay)
return (
a.isEnabled === b.isEnabled &&
a.period === b.period &&
sameRange &&
a.subject === b.subject &&
a.textBody === b.textBody
)
}
const WEEKDAY = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' })
/** "11월 3일" — 올해가 아니면 앞에 연도 ("2027년 1월 3일") */
function dayText(date: Date, now: Date): string {
const monthDay = `${date.getMonth() + 1}월 ${date.getDate()}일`
return date.getFullYear() === now.getFullYear() ? monthDay : `${date.getFullYear()}년 ${monthDay}`
}
/** "10월 30일(금)" — 저장한 뒤의 상태 글 */
function dayWithWeekday(date: Date, now: Date): string {
return `${dayText(date, now)}(${WEEKDAY.format(date)})`
}
/** 서버의 끝 시각 → 끝나는 날(그 직전 날짜) */
function endDayDate(toDate: Date): Date {
return new Date(toDate.getTime() - END_OFFSET_MS)
}
/**
* 저장한 뒤의 상태 글 (디자인 원본 SettingsVacation) —
* "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." · "저장했습니다. 끌 때까지 자동으로 답합니다." · 끄면 "…자동 응답을 껐습니다 — …"
*/
export function vacationSavedText(vacation: MailVacation, now: Date = new Date()): string {
if (!vacation.isEnabled) return VACATION_TEXT.savedOff
const from = parseInstant(vacation.fromDate)
const to = parseInstant(vacation.toDate)
const fromText = from ? dayWithWeekday(from, now) : null
const toText = to ? dayWithWeekday(endDayDate(to), now) : null
if (fromText && toText) return VACATION_TEXT.savedRange(fromText, toText)
if (toText) return VACATION_TEXT.savedTo(toText)
if (fromText) return VACATION_TEXT.savedFrom(fromText)
return VACATION_TEXT.savedUntilOff
}
/**
* 메일함 패널 아래 안내의 둘째 줄 (디자인 원본 MailboxManage "11월 3일까지 · 설정") — 보이지 않을 때는 null:
* 꺼져 있거나, 끝나는 시각이 지났다. 아직 시작하지 않았으면 시작일을 보인다
*/
export function vacationNoticeText(vacation: MailVacation | null, now: Date = new Date()): string | null {
if (!vacation?.isEnabled) return null
const from = parseInstant(vacation.fromDate)
const to = parseInstant(vacation.toDate)
if (to && to.getTime() <= now.getTime()) return null
const toText = to ? dayText(endDayDate(to), now) : null
if (from && from.getTime() > now.getTime()) {
const fromText = dayText(from, now)
return toText ? VACATION_TEXT.noticeRange(fromText, toText) : VACATION_TEXT.noticeFrom(fromText)
}
return toText ? VACATION_TEXT.noticeUntil(toText) : VACATION_TEXT.noticeUntilOff
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
describe('mailboxOperationCopy', () => {
it('모두 읽음 — "…하는 중 · 600 / 1,234통" → "받은편지함의 메일 1,234통을 읽음으로 표시했습니다."', () => {
const copy = mailboxOperationCopy('read', '받은편지함')
expect(copy.progress(600, 1234)).toBe('받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통')
expect(copy.done(1234)).toBe('받은편지함의 메일 1,234통을 읽음으로 표시했습니다.')
expect(copy.failed(600, 1234)).toBe('1,234통 가운데 600통까지 처리했습니다 — 다시 시도해 주세요.')
// 다른 탭이 먼저 했다
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
})
it('비우기 — 메일함 이름의 받침에 맞춰 을/를', () => {
expect(mailboxOperationCopy('empty', '휴지통').progress(20, 21)).toBe('휴지통을 비우는 중 · 20 / 21통')
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('휴지통을 비웠습니다 — 메일 21통.')
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('스팸메일함을 비웠습니다 — 메일 5통.')
// 다른 탭이 먼저 비웠다 — 받침에 맞춰 이/가
expect(mailboxOperationCopy('empty', '휴지통').done(0)).toBe('휴지통이 이미 비어 있습니다.')
expect(mailboxOperationCopy('empty', 'Spam').done(0)).toBe('Spam이(가) 이미 비어 있습니다.')
})
it('메일함 지우기 — 옮긴 메일이 있으면 휴지통에 있다고 알리고, 빈 메일함은 진행 수 없이', () => {
const copy = mailboxOperationCopy('delete', '거래처')
expect(copy.progress(500, 1234)).toBe('‘거래처’ 메일함을 지우는 중 · 500 / 1,234통')
expect(copy.progress(0, 0)).toBe('‘거래처’ 메일함을 지우는 중')
expect(copy.done(12)).toBe('‘거래처’ 메일함을 지웠습니다. 안의 메일 12통은 휴지통에 있습니다.')
expect(copy.done(0)).toBe('‘거래처’ 메일함을 지웠습니다.')
})
it('시작할 때의 전체 수 — 모두 읽음은 안 읽은 수, 비우기 · 지우기는 전체 수', () => {
const mailbox = { unreadCount: 3, totalCount: 148 }
expect(initialOperationTotal('read', mailbox)).toBe(3)
expect(initialOperationTotal('empty', mailbox)).toBe(148)
expect(initialOperationTotal('delete', mailbox)).toBe(148)
})
})
@@ -0,0 +1,63 @@
// 오래 걸리는 메일함 처리 — 모두 읽음 · 비우기 · 메일함 지우기의 알림 문구 (계약 2절 "메일함 관리" · 디자인 원본 MailActions4 — 순수 함수만 둔다)
// 서버는 한 요청에 500통씩(25초 안) 처리하고 남은 수를 돌려준다 — 화면은 0 이 될 때까지 다시 보내며 "…하는 중 · 600 / 1,234통"을 보인다.
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { objectParticle, subjectParticle } from '@/shared/utils/korean'
/** read: 모두 읽음으로 표시 · empty: 휴지통 / 스팸메일함 비우기 · delete: 메일함 지우기(안의 메일은 휴지통으로) */
export type MailboxOperationKind = 'read' | 'empty' | 'delete'
export interface MailboxOperationCopy {
/** 하는 중 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
progress: (done: number, total: number) => string
/**
* 끝남 — 처리한 수 (지우기는 휴지통으로 옮긴 수). 0 이면 이미 끝나 있던 것이다(다른 탭이 먼저 했다):
* "…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다." · "‘…’ 메일함을 지웠습니다."
*/
done: (count: number) => string
/** 도중에 실패 — 거기까지 처리한 수 */
failed: (done: number, total: number) => string
}
/** 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다) */
export function initialOperationTotal(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>): number {
return Math.max(0, kind === 'read' ? mailbox.unreadCount : mailbox.totalCount)
}
export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: string): MailboxOperationCopy {
const failed = (done: number, total: number) => MAILBOX_OPERATION_TEXT.failed(formatCount(done), formatCount(total))
/** "…하는 중 · 600 / 1,234통" — 셀 메일이 없으면(빈 메일함 지우기) 진행 수를 뺀다 */
const withCount = (label: string) => (done: number, total: number) =>
total > 0 ? `${label}${MAILBOX_OPERATION_TEXT.progressCount(formatCount(done), formatCount(total))}` : label
if (kind === 'read') {
return {
progress: withCount(MAILBOX_OPERATION_TEXT.readProgress(mailboxName)),
done: (count) =>
count > 0
? MAILBOX_OPERATION_TEXT.readDone(mailboxName, formatCount(count))
: MAILBOX_OPERATION_TEXT.readNone(mailboxName),
failed,
}
}
if (kind === 'empty') {
const object = `${mailboxName}${objectParticle(mailboxName)}`
return {
progress: withCount(MAILBOX_OPERATION_TEXT.emptyProgress(object)),
done: (count) =>
count > 0
? MAILBOX_OPERATION_TEXT.emptyDone(object, formatCount(count))
: MAILBOX_OPERATION_TEXT.emptyNone(`${mailboxName}${subjectParticle(mailboxName)}`),
failed,
}
}
return {
progress: withCount(MAILBOX_OPERATION_TEXT.deleteProgress(mailboxName)),
done: (count) =>
count > 0
? MAILBOX_OPERATION_TEXT.deleteDoneMoved(mailboxName, formatCount(count))
: MAILBOX_OPERATION_TEXT.deleteDone(mailboxName),
failed,
}
}
+62 -1
View File
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox } from '@/shared/types/mail'
import { emptyMailboxText, mailboxIcon, orderMailboxes } from './mailbox'
import {
emptyMailboxText,
hasChildMailbox,
mailboxActions,
mailboxIcon,
mailboxNameProblem,
orderMailboxes,
splitMailboxGroups,
} from './mailbox'
function mailbox(partial: Partial<Mailbox> & Pick<Mailbox, 'id' | 'name'>): Mailbox {
return {
@@ -88,3 +96,56 @@ describe('mailboxIcon', () => {
expect(mailboxIcon({ role: null })).toBe('folder')
})
})
describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', () => {
const inbox = mailbox({ id: 'i', name: '받은편지함', role: 'inbox' })
const sent = mailbox({ id: 's', name: '보낸편지함', role: 'sent' })
const trash = mailbox({ id: 't', name: '휴지통', role: 'trash' })
const project = mailbox({ id: 'p', name: '프로젝트' })
const quote = mailbox({ id: 'q', name: '견적', parentId: 'p' })
const inboxChild = mailbox({ id: 'ic', name: '협력사', parentId: 'i' })
it('두 묶음 — 역할이 있는 뿌리(와 그 아래)는 기본 메일함, 역할 없는 뿌리(와 그 아래)는 내 메일함', () => {
const groups = splitMailboxGroups(orderMailboxes([project, quote, sent, inboxChild, trash, inbox]))
expect(groups.system.map((node) => [node.mailbox.id, node.depth])).toEqual([
['i', 0],
['ic', 1],
['s', 0],
['t', 0],
])
expect(groups.mine.map((node) => [node.mailbox.id, node.depth])).toEqual([
['p', 0],
['q', 1],
])
})
it('⋯ 메뉴 — 안 읽은 메일이 있으면 모두 읽음 · 받은편지함과 내 메일함은 하위 메일함 · 내 메일함만 이름 바꾸기 · 지우기 · 휴지통 / 스팸메일함은 비우기', () => {
expect(mailboxActions({ ...inbox, unreadCount: 3 })).toEqual(['markRead', 'createChild'])
expect(mailboxActions(inbox)).toEqual(['createChild'])
expect(mailboxActions(sent)).toEqual([])
expect(mailboxActions({ ...project, unreadCount: 1 })).toEqual(['markRead', 'createChild', 'rename', 'delete'])
expect(mailboxActions({ ...trash, totalCount: 21 })).toEqual(['empty'])
expect(mailboxActions(mailbox({ id: 'j', name: '스팸메일함', role: 'junk', totalCount: 5, unreadCount: 1 }))).toEqual([
'markRead',
'empty',
])
// 빈 휴지통은 비울 것이 없다
expect(mailboxActions(trash)).toEqual([])
})
it('하위 메일함이 있는가 — 지우기를 막는다', () => {
expect(hasChildMailbox([project, quote], 'p')).toBe(true)
expect(hasChildMailbox([project, quote], 'q')).toBe(false)
})
it('이름 — 앞뒤 공백을 뺀 1~64자, / · 제어 문자(탭 등) 없음', () => {
expect(mailboxNameProblem(' ')).toBe('empty')
expect(mailboxNameProblem(' 협력사 ')).toBeNull()
expect(mailboxNameProblem('a'.repeat(64))).toBeNull()
expect(mailboxNameProblem('a'.repeat(65))).toBe('invalid')
expect(mailboxNameProblem('2026/견적')).toBe('invalid')
expect(mailboxNameProblem('견적\t2026')).toBe('invalid')
expect(mailboxNameProblem('견적\u00852026')).toBe('invalid')
})
})
+71
View File
@@ -7,6 +7,7 @@ import {
MAILBOX_ROLE_ORDER,
type MailboxKind,
} from '@/shared/constants/mail'
import { MAILBOX_NAME_FORBIDDEN_CHAR, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
import type { IconName } from '@/shared/constants/icons'
import type { Mailbox, MailboxRole } from '@/shared/types/mail'
import { subjectParticle } from '@/shared/utils/korean'
@@ -73,6 +74,76 @@ export function findMailboxByRole(list: readonly Mailbox[], role: MailboxRole):
return list.find((mailbox) => mailbox.role === role) ?? null
}
/**
* 메일함 패널의 두 묶음 (디자인 README "4단계 › 메일함 관리") — 기본 메일함(역할이 있는 뿌리)과 "내 메일함"(역할 없는 뿌리).
* 하위 메일함은 뿌리의 묶음을 따라간다 — 받은편지함 아래에 만든 메일함은 받은편지함 밑에 들여 쓴다 (나무 모양을 지킨다)
*/
export function splitMailboxGroups(nodes: readonly MailboxNode[]): { system: MailboxNode[]; mine: MailboxNode[] } {
const system: MailboxNode[] = []
const mine: MailboxNode[] = []
let rootHasRole = false
for (const node of nodes) {
if (node.depth === 0) rootHasRole = node.mailbox.role !== null
;(rootHasRole ? system : mine).push(node)
}
return { system, mine }
}
/** 하위 메일함이 있는가 — 있으면 지울 수 없다 (서버도 400 VAL_001, 화면이 먼저 막는다) */
export function hasChildMailbox(list: readonly Mailbox[], id: string): boolean {
return list.some((mailbox) => mailbox.parentId === id)
}
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함 (DELETE /mailboxes/{id}/messages) */
export function isEmptiableMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
return mailbox.role === 'trash' || mailbox.role === 'junk'
}
/** ⋯ 메뉴가 할 수 있는 일 */
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'delete' | 'empty'
/**
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리"):
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(내 메일함만) ·
* 휴지통 / 스팸메일함 비우기(그 메일함에서만 — 빈 메일함이면 뺀다). 할 일이 없으면 빈 목록 — ⋯ 단추를 그리지 않는다
*/
export function mailboxActions(mailbox: Mailbox): MailboxAction[] {
const actions: MailboxAction[] = []
if (mailbox.unreadCount > 0) actions.push('markRead')
if (mailbox.role === 'inbox' || mailbox.role === null) actions.push('createChild')
if (mailbox.role === null) actions.push('rename', 'delete')
if (isEmptiableMailbox(mailbox) && mailbox.totalCount > 0) actions.push('empty')
return actions
}
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름에 쓸 수 없다 (서버와 같은 범위) */
const CONTROL_CHAR_RANGES: readonly (readonly [number, number])[] = [
[0x00, 0x1f],
[0x7f, 0x9f],
]
function hasControlChar(text: string): boolean {
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index)
if (CONTROL_CHAR_RANGES.some(([start, end]) => code >= start && code <= end)) return true
}
return false
}
/** 메일함 이름의 문제 — 앞뒤 공백을 뺀 1~64자, '/' · 제어 문자 없음 (계약 2절 "메일함 관리") */
export function mailboxNameProblem(name: string): 'empty' | 'invalid' | null {
const trimmed = name.trim()
if (trimmed === '') return 'empty'
if (
trimmed.length > MAILBOX_NAME_MAX_LENGTH ||
trimmed.includes(MAILBOX_NAME_FORBIDDEN_CHAR) ||
hasControlChar(trimmed)
) {
return 'invalid'
}
return null
}
function kindOf(mailbox: Pick<Mailbox, 'role'>): MailboxKind {
return mailbox.role ?? CUSTOM_MAILBOX_KEY
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { hasPasswordChangeProblem, passwordChangeProblems } from './password-change'
// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙 (계약 1절: 8자 이상 · 지금과 다름 · 확인 칸 일치, 128자 이하)
describe('passwordChangeProblems', () => {
it('규칙에 맞으면 문제가 없다', () => {
const problems = passwordChangeProblems({ current: 'old-pass-1234', next: 'Ara!2026-mail', confirm: 'Ara!2026-mail' })
expect(problems).toEqual({ current: null, next: null, confirm: null })
expect(hasPasswordChangeProblem(problems)).toBe(false)
})
it('지금 비밀번호를 비웠다', () => {
expect(passwordChangeProblems({ current: '', next: 'Ara!2026-mail', confirm: 'Ara!2026-mail' }).current).toBe('missing')
})
it('새 비밀번호 — 8자 미만이거나 지금과 같으면 규칙 위반, 128자를 넘으면 너무 김', () => {
expect(passwordChangeProblems({ current: 'old-pass', next: 'short1!', confirm: 'short1!' }).next).toBe('rule')
expect(passwordChangeProblems({ current: 'same-pass-1', next: 'same-pass-1', confirm: 'same-pass-1' }).next).toBe('rule')
const long = 'a'.repeat(129)
expect(passwordChangeProblems({ current: 'old-pass', next: long, confirm: long }).next).toBe('tooLong')
// 공백도 글자다 — 다듬지 않는다
expect(passwordChangeProblems({ current: 'old-pass', next: ' ', confirm: ' ' }).next).toBeNull()
})
it('확인 칸이 다르다', () => {
const problems = passwordChangeProblems({ current: 'old-pass-1234', next: 'Ara!2026-mail', confirm: 'Ara!2026-mai' })
expect(problems.confirm).toBe('mismatch')
expect(hasPasswordChangeProblem(problems)).toBe(true)
})
})
@@ -0,0 +1,37 @@
// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙을 먼저 본다 (계약 1절 "PUT /api/auth/password" — 순수 함수만 둔다)
// 8자 이상 · 지금 비밀번호와 다름 · 확인 칸 일치. 128자를 넘으면 서버가 VAL_001 로 거절하므로 그것도 먼저 막는다.
// 공백도 비밀번호의 글자다 — 다듬지 않는다.
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '@/shared/constants/account'
export interface PasswordChangeInput {
current: string
next: string
confirm: string
}
/** 칸마다 하나 — 없으면 null */
export interface PasswordChangeProblems {
/** 지금 비밀번호를 비웠다 */
current: 'missing' | null
/** rule: 8자 미만이거나 지금 비밀번호와 같다(안내 글이 그대로 오류가 된다) · tooLong: 128자 초과 */
next: 'rule' | 'tooLong' | null
/** 확인 칸이 새 비밀번호와 다르다 */
confirm: 'mismatch' | null
}
export function passwordChangeProblems(input: PasswordChangeInput): PasswordChangeProblems {
const { current, next, confirm } = input
let nextProblem: PasswordChangeProblems['next'] = null
if (next.length > PASSWORD_MAX_LENGTH) nextProblem = 'tooLong'
else if (next.length < PASSWORD_MIN_LENGTH || next === current) nextProblem = 'rule'
return {
current: current === '' ? 'missing' : null,
next: nextProblem,
confirm: confirm !== next ? 'mismatch' : null,
}
}
export function hasPasswordChangeProblem(problems: PasswordChangeProblems): boolean {
return problems.current !== null || problems.next !== null || problems.confirm !== null
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { opensUpward } from './popup-placement'
// 메일함 패널의 ⋯ 메뉴 — 아래로 열면 잘릴 때 위로 연다
describe('opensUpward', () => {
const base = { triggerTop: 400, triggerBottom: 440, popupHeight: 254, gap: 4 }
it('아래에 다 들어가면 아래로', () => {
expect(opensUpward({ ...base, areaTop: 0, areaBottom: 900 })).toBe(false)
})
it('아래가 모자라고 위가 넉넉하면 위로', () => {
expect(opensUpward({ ...base, areaTop: 0, areaBottom: 600 })).toBe(true)
})
it('둘 다 모자라면 넓은 쪽으로 — 아래가 더 넓으면 아래로', () => {
expect(opensUpward({ ...base, triggerTop: 100, triggerBottom: 140, areaTop: 0, areaBottom: 320 })).toBe(false)
expect(opensUpward({ ...base, triggerTop: 220, triggerBottom: 260, areaTop: 0, areaBottom: 320 })).toBe(true)
})
})
@@ -0,0 +1,22 @@
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴, 순수 함수만 둔다)
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다.
export interface PopupFitInput {
/** 연 단추의 위 · 아래 (화면 좌표) */
triggerTop: number
triggerBottom: number
/** 메뉴 높이 */
popupHeight: number
/** 보이는 영역의 위 · 아래 (화면 좌표) */
areaTop: number
areaBottom: number
/** 단추와 메뉴 사이 */
gap: number
}
/** 위로 열어야 하는가 — 아래로는 다 보이지 않고 위가 더 넓을 때 (둘 다 모자라면 넓은 쪽으로 — 메뉴는 안에서 스크롤된다) */
export function opensUpward(input: PopupFitInput): boolean {
const below = input.areaBottom - input.triggerBottom - input.gap
const above = input.triggerTop - input.areaTop - input.gap
return input.popupHeight > below && above > below
}
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import type { MailVacation } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { useMailVacationStore } from './mail-vacation.store'
// 부재중 자동 응답 스토어 — 세션이 바뀐 뒤 늦게 온 응답은 쓰지 않는다 (계약 2절 "부재중 자동 응답")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const OTHER = { email: 'minsu.park@arasystem.it.kr', name: '박민수' }
const ON: MailVacation = {
isEnabled: true,
fromDate: null,
toDate: '2026-11-03T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 3일까지 자리를 비웁니다.',
}
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꿔 본다 */
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('mail-vacation.store — 늦게 온 응답', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('불러오는 사이 로그아웃하고 다른 계정으로 들어오면, 늦게 온 응답을 새 사용자의 안내로 쓰지 않는다', async () => {
const reply = deferred()
requests = installMockApi(() => reply.promise)
const store = useMailVacationStore()
const authStore = useAuthStore()
const loading = store.load()
authStore.clearAuth()
await nextTick()
authStore.startSession(OTHER)
await nextTick()
reply.finish(ok(ON))
await loading
expect(requests.map((request) => request.method)).toEqual(['GET'])
expect(store.vacation).toBeNull()
expect(store.status).toBe('idle')
})
it('저장하는 사이 세션이 바뀌면(같은 사람이 다시 들어와도) 결과를 돌려주기만 하고 스토어에는 쓰지 않는다', async () => {
const reply = deferred()
requests = installMockApi((request) => (request.method === 'PUT' ? reply.promise : ok(ON)))
const store = useMailVacationStore()
const authStore = useAuthStore()
const saving = store.save(ON)
authStore.clearAuth()
await nextTick()
authStore.startSession(USER)
await nextTick()
reply.finish(ok(ON))
await expect(saving).resolves.toEqual(ON)
expect(store.vacation).toBeNull()
expect(store.status).toBe('idle')
})
it('같은 세션이면 저장한 값이 곧 안내 값이다 — 그 전에 시작한 불러오기가 늦게 와도 덮지 않는다', async () => {
const stale = deferred()
const saved: MailVacation = { ...ON, subject: '휴가 중입니다' }
requests = installMockApi((request) => (request.method === 'PUT' ? ok(saved) : stale.promise))
const store = useMailVacationStore()
const loading = store.load()
await store.save(saved)
stale.finish(ok({ ...ON, isEnabled: false }))
await loading
expect(requests.map((request) => request.method)).toEqual(['GET', 'PUT'])
expect(store.vacation).toEqual(saved)
expect(store.status).toBe('ready')
})
})
@@ -0,0 +1,75 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMail, type MailRequestOptions } from '@/composables/useMail'
import type { MailVacation } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 부재중 자동 응답 스토어 — 계약 2절 "부재중 자동 응답" (사용자마다 하나, 엔진의 VacationResponse)
// - 메일 화면은 들어올 때 한 번 받는다(load) — 켜져 있으면 메일함 패널 아래(사용량 위)에 안내를 보인다. 못 받으면 알리지 않고 지난 값을 둔다
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창 · 메일 프로그램에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
// - 저장(save)한 값이 곧 패널 안내의 값이다
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset 이 올린다),
// 저장은 세션 세대(authStore.generation — 그사이 불러오기가 있어도 저장한 값이 더 새롭다)로 가린다
export const useMailVacationStore = defineStore('mail-vacation', () => {
const mailApi = useMail()
const authStore = useAuthStore()
const vacation = ref<MailVacation | null>(null)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
let seq = 0
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 그대로 둔다) */
async function load(): Promise<MailVacation | null> {
seq += 1
const current = seq
status.value = 'loading'
error.value = null
try {
const result = await mailApi.getVacation({ silent: true })
if (current !== seq) return vacation.value
vacation.value = result
status.value = 'ready'
return result
} catch (caught) {
if (current !== seq) return vacation.value
status.value = 'error'
error.value = toApiError(caught)
return null
}
}
/**
* 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다).
* 기다리는 사이 세션이 바뀌었으면 결과를 돌려주기만 하고 스토어에는 쓰지 않는다 (다음 사용자의 안내가 되지 않게)
*/
async function save(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
const session = authStore.generation
const result = await mailApi.saveVacation(payload, options)
if (authStore.generation !== session) return result
seq += 1
vacation.value = result
status.value = 'ready'
error.value = null
return result
}
function reset() {
seq += 1
vacation.value = null
status.value = 'idle'
error.value = null
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 설정이 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return { vacation, status, error, load, save, reset }
})
+14 -2
View File
@@ -5,17 +5,20 @@ import { useMail } from '@/composables/useMail'
import { useAuthStore } from '@/stores/auth.store'
import { createBulkActions } from './mail/bulk'
import { createListState } from './mail/list'
import { createMailboxAdmin } from './mail/mailbox-admin'
import { createMailboxesState } from './mail/mailboxes'
import { createMessageActions, createOpenedMessage } from './mail/message'
import { createSelection } from './mail/selection'
export type { BulkResult } from '@/shared/utils/mail-bulk'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, SearchOrigin } from './mail/types'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 포함)
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
//
// 오류 처리 원칙:
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
@@ -35,6 +38,7 @@ export const useMailStore = defineStore('mail', () => {
const selection = createSelection(list)
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
const lastBrowseLocation = ref<string | null>(null)
@@ -68,6 +72,7 @@ export const useMailStore = defineStore('mail', () => {
}
function reset() {
admin.reset()
mailboxState.reset()
list.reset()
selection.clearChecked()
@@ -147,6 +152,13 @@ export const useMailStore = defineStore('mail', () => {
purgeMessages: bulk.purgeMessages,
purgeChecked: bulk.purgeChecked,
restoreMoved: bulk.restoreMoved,
// 메일함 관리 (4단계)
runningMailboxOperations: admin.runningOperations,
isMailboxOperationRunning: admin.isOperationRunning,
isMailboxOperationBlocked: admin.isOperationBlocked,
createMailbox: admin.createMailbox,
renameMailbox: admin.renameMailbox,
runMailboxOperation: admin.runMailboxOperation,
// 쓰기 화면
lastBrowseLocation,
rememberBrowseLocation,
+18 -10
View File
@@ -17,7 +17,7 @@ import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-s
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { SelectionState } from './selection'
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult } from './types'
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult, MoveOptions } from './types'
interface BulkDeps {
mailApi: MailApi
@@ -122,11 +122,17 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
/**
* 메일을 옮긴다 (PATCH /messages { mailboxId }) — 휴지통 밖의 "삭제"도 휴지통으로 옮기는 것이다.
* 스팸 신고 · 스팸 아님은 스팸 표시(isJunk)를 같은 요청에 함께 보낸다 (계약 2절 "스팸 · 원문").
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
*/
async function moveMessages(targets: readonly MessageSummary[], targetId: string): Promise<BulkMoveResult> {
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false }
async function moveMessages(
targets: readonly MessageSummary[],
targetId: string,
options: MoveOptions = {},
): Promise<BulkMoveResult> {
const junk = options.isJunk === undefined ? {} : { isJunk: options.isJunk }
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false, ...junk }
const isStale = captureList()
const origins = new Map<string, string>()
for (const message of targets) {
@@ -137,7 +143,7 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
const result = await sendBulk(
targets.map((message) => message.id),
async (chunkIds) => {
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId })
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId, ...junk })
return { done: patched.updatedIds, skipped: patched.failedIds }
},
)
@@ -149,12 +155,12 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
}
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale }
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale, ...junk }
}
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
async function moveChecked(targetId: string): Promise<BulkMoveResult> {
const result = await moveMessages(selection.checkedMessages.value, targetId)
async function moveChecked(targetId: string, options: MoveOptions = {}): Promise<BulkMoveResult> {
const result = await moveMessages(selection.checkedMessages.value, targetId, options)
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
return result
}
@@ -190,13 +196,15 @@ export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkD
}
/**
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다.
* 스팸 신고 · 스팸 아님의 되돌리기는 표시도 되돌린다 — 옮길 때 보낸 값의 반대(isJunk)를 함께 보낸다
*/
async function restoreMoved(origins: readonly BulkOrigin[]): Promise<BulkResult> {
async function restoreMoved(origins: readonly BulkOrigin[], options: MoveOptions = {}): Promise<BulkResult> {
const junk = options.isJunk === undefined ? {} : { isJunk: options.isJunk }
const total = emptyBulkResult()
for (const [index, group] of origins.entries()) {
const result = await sendBulk(group.ids, async (chunkIds) => {
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId })
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId, ...junk })
return { done: patched.updatedIds, skipped: patched.failedIds }
})
total.doneIds.push(...result.doneIds)
+224
View File
@@ -0,0 +1,224 @@
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계, mail.store.ts 가 조립한다)
// - 만들기 · 이름 바꾸기: 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다 —
// 0(지우기는 deleted: true)이 될 때까지 같은 요청을 다시 보내며 진행을 알린다. 도중에 실패하면 거기까지 처리한 수를 돌려준다
// (다시 하면 남은 것만 처리한다). 요청 실패는 인터셉터가 알린다. 그동안 다른 일을 해도 된다 — 같은 메일함의 같은 처리만 한 번에 하나
// - 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다: 비우기는 차례마다 그때의 휴지통을 다시 읽어 지우므로, 그동안 지운 메일함의 메일이
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다). 스팸메일함 비우기는 상관없다 (isOperationBlocked)
// - 이미 지워진 메일함(지우기의 404 RES_001 — 다른 탭이 먼저 지웠거나, 응답이 끊긴 뒤 다시 보냈다)은 실패가 아니라 끝난 것으로 다룬다
// - 세션이 바뀌면(로그아웃 · 다른 계정 — reset) 하던 처리는 더 보내지 않고 멈춘다 (canceled — 알리지 않는다).
// 만들기 · 이름 바꾸기의 늦은 응답도 쓰지 않는다 — 메일함 id 는 계정마다 겹칠 수 있어 다음 사용자의 메일함을 덮게 된다
import { shallowRef } from 'vue'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { MAILBOX_OPERATION_MAX_STALLS } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { initialOperationTotal, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { MailApi } from './types'
interface MailboxAdminDeps {
mailApi: MailApi
mailboxes: MailboxesState
list: ListState
}
/** 진행 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
export interface MailboxOperationProgress {
processed: number
total: number
}
export type MailboxOperationOutcome =
/** 끝났다 — 처리한 수 (0 이면 이미 끝나 있었다) · 휴지통으로 옮긴 수 (지우기에서만) */
| { status: 'done'; processed: number; movedToTrash: number }
/** 도중에 멈췄다 — notified 가 false 면 아직 알리지 않은 실패다(서버가 나아가지 못함 — 부르는 쪽이 알린다) */
| { status: 'failed'; processed: number; total: number; error: ApiError; notified: boolean }
/** 세션이 바뀌었거나 같은 처리가 이미 돌고 있다 · 함께 돌 수 없는 처리가 돌고 있다 — 아무것도 알리지 않는다 */
| { status: 'canceled' }
/** 돌고 있는 처리 */
interface RunningOperation {
kind: MailboxOperationKind
mailboxId: string
}
/** 한 번 보낸 결과 — 이 메일함에서 처리한 수 · 휴지통으로 옮긴 수 · 남은 수 · 끝났는지 */
interface OperationStep {
processed: number
movedToTrash: number
remaining: number
done: boolean
}
function operationKey(kind: MailboxOperationKind, mailboxId: string): string {
return `${kind}:${mailboxId}`
}
export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDeps) {
/** 돌고 있는 처리 — "종류:메일함 id" (메뉴 항목 · 비우기 단추를 그동안 끈다) */
const runningOperations = shallowRef<ReadonlyMap<string, RunningOperation>>(new Map())
/** 세션이 바뀔 때마다 오른다 — 하던 처리 · 늦게 온 응답은 쓰지 않고 멈춘다 */
let epoch = 0
function isOperationRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
return runningOperations.value.has(operationKey(kind, mailboxId))
}
/**
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기 (위 설명).
* 스팸메일함 비우기 · 모두 읽음은 막지 않는다
*/
function isOperationBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
if (kind === 'delete') {
const trash = mailboxes.mailboxByRole('trash')
return trash !== null && isOperationRunning('empty', trash.id)
}
if (kind === 'empty' && mailbox.role === 'trash') {
return [...runningOperations.value.values()].some((operation) => operation.kind === 'delete')
}
return false
}
function markRunning(operation: RunningOperation, running: boolean) {
const next = new Map(runningOperations.value)
const key = operationKey(operation.kind, operation.mailboxId)
if (running) next.set(key, operation)
else next.delete(key)
runningOperations.value = next
}
/**
* 만들고 바로 보인다 — 같은 자리에 같은 이름이면 409 MAIL_009 (예외가 그대로 올라간다).
* 기다리는 사이 세션이 바뀌었으면 null — 보이지도 알리지도 않는다
*/
async function createMailbox(
name: string,
parentId: string | null,
options?: MailRequestOptions,
): Promise<Mailbox | null> {
const started = epoch
const created = await mailApi.createMailbox({ name, parentId }, options)
if (started !== epoch) return null
mailboxes.upsertMailbox(created)
void mailboxes.loadMailboxes({ background: true })
return created
}
/** 이름 바꾸기 — 기다리는 사이 세션이 바뀌었으면 null (다음 사용자의 같은 id 메일함을 덮지 않게) */
async function renameMailbox(id: string, name: string, options?: MailRequestOptions): Promise<Mailbox | null> {
const started = epoch
const renamed = await mailApi.renameMailbox(id, { name }, options)
if (started !== epoch) return null
mailboxes.upsertMailbox(renamed)
void mailboxes.loadMailboxes({ background: true })
return renamed
}
async function sendStep(kind: MailboxOperationKind, mailboxId: string): Promise<OperationStep> {
if (kind === 'delete') {
// 이미 지워진 메일함(RES_001)은 끝난 것으로 다룬다 — 알림을 끄고 아래 runMailboxOperation 이 받는다
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'] })
const processed = typeof result.processed === 'number' ? result.processed : result.movedToTrash
return { processed, movedToTrash: result.movedToTrash, remaining: result.remaining, done: result.deleted }
}
const result = kind === 'read' ? await mailApi.markMailboxRead(mailboxId) : await mailApi.emptyMailbox(mailboxId)
return { processed: result.processed, movedToTrash: 0, remaining: result.remaining, done: result.remaining <= 0 }
}
/** 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다) */
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep) {
if (kind === 'read') mailboxes.setCounts(mailboxId, { unreadCount: Math.max(0, step.remaining) })
else if (kind === 'delete' && step.done) mailboxes.removeMailbox(mailboxId)
else mailboxes.setCounts(mailboxId, { totalCount: Math.max(0, step.remaining) })
}
/**
* 끝난 뒤 — 개수 · 사용량(메일함 목록 포함)을 다시 맞추고, 그 메일함의 목록을 보고 있으면 다시 받는다
* (비우기는 메일이 빠졌고, 모두 읽음은 굵은 글자가 풀린다). 다른 목록은 건드리지 않는다
*/
function settle(kind: MailboxOperationKind, mailboxId: string) {
mailboxes.syncCounts()
const source = list.listSource.value
if (kind === 'delete' || !source || scopeMailboxIdOf(source) !== mailboxId) return
if (kind === 'read') void list.syncVisibleList({ includeSearch: true })
else void list.openList(source)
}
/**
* 오래 걸리는 처리 — 끝날 때까지(남은 수 0 · 지우기는 deleted: true) 같은 요청을 다시 보낸다. 다시 보낼 때마다 onProgress.
* 실패는 요청마다 인터셉터가 알린다(notified). 한 번도 나아가지 못한 응답이 이어지면 멈추고 BIZ_001 로 돌려준다(아직 알리지 않았다).
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled)
*/
async function runMailboxOperation(
kind: MailboxOperationKind,
mailbox: Mailbox,
onProgress: (progress: MailboxOperationProgress) => void,
): Promise<MailboxOperationOutcome> {
if (isOperationRunning(kind, mailbox.id) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
const operation: RunningOperation = { kind, mailboxId: mailbox.id }
const started = epoch
markRunning(operation, true)
let processed = 0
let movedToTrash = 0
let total = initialOperationTotal(kind, mailbox)
let stalls = 0
try {
for (;;) {
let step: OperationStep
try {
step = await sendStep(kind, mailbox.id)
} catch (error) {
if (started !== epoch) return { status: 'canceled' }
const apiError = toApiError(error)
if (kind === 'delete' && apiError.code === 'RES_001') {
// 이미 지워진 메일함 — 다른 탭이 먼저 지웠거나 끊긴 응답 뒤에 다시 보냈다
mailboxes.removeMailbox(mailbox.id)
settle(kind, mailbox.id)
return { status: 'done', processed, movedToTrash }
}
mailboxes.syncCounts()
return { status: 'failed', processed, total, error: apiError, notified: true }
}
if (started !== epoch) return { status: 'canceled' }
processed += Math.max(0, step.processed)
movedToTrash += Math.max(0, step.movedToTrash)
total = processed + Math.max(0, step.remaining)
applyStep(kind, mailbox.id, step)
if (step.done) break
stalls = step.processed > 0 ? 0 : stalls + 1
if (stalls >= MAILBOX_OPERATION_MAX_STALLS) {
mailboxes.syncCounts()
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
return { status: 'failed', processed, total, error, notified: false }
}
onProgress({ processed, total })
}
settle(kind, mailbox.id)
return { status: 'done', processed, movedToTrash }
} finally {
// 세션이 바뀐 뒤 끝난 옛 처리는 새 세션의 표시를 건드리지 않는다 (reset 이 이미 비웠다)
if (started === epoch) markRunning(operation, false)
}
}
function reset() {
epoch += 1
runningOperations.value = new Map()
}
return {
runningOperations,
isOperationRunning,
isOperationBlocked,
createMailbox,
renameMailbox,
runMailboxOperation,
reset,
}
}
export type MailboxAdminState = ReturnType<typeof createMailboxAdmin>
+21
View File
@@ -143,6 +143,24 @@ export function createMailboxesState(mailApi: MailApi) {
)
}
/** 서버가 돌려준 메일함을 바로 보인다 — 만들기 · 이름 바꾸기 (서버 기준은 뒤이어 받는 목록이 맞춘다) */
function upsertMailbox(next: Mailbox) {
const known = mailboxes.value.some((mailbox) => mailbox.id === next.id)
mailboxes.value = known
? mailboxes.value.map((mailbox) => (mailbox.id === next.id ? next : mailbox))
: [...mailboxes.value, next]
}
/** 지운 메일함을 뺀다 */
function removeMailbox(id: string) {
mailboxes.value = mailboxes.value.filter((mailbox) => mailbox.id !== id)
}
/** 오래 걸리는 처리의 남은 수를 바로 보인다 — 전체 읽음은 안 읽은 수, 비우기 · 지우기는 전체 수 */
function setCounts(id: string, counts: Partial<Pick<Mailbox, 'unreadCount' | 'totalCount'>>) {
mailboxes.value = mailboxes.value.map((mailbox) => (mailbox.id === id ? { ...mailbox, ...counts } : mailbox))
}
function reset() {
mailboxSeq += 1
quotaSeq += 1
@@ -171,6 +189,9 @@ export function createMailboxesState(mailApi: MailApi) {
ensureQuota,
syncCounts,
adjustUnread,
upsertMailbox,
removeMailbox,
setCounts,
reset,
}
}
+7
View File
@@ -22,6 +22,13 @@ export interface MailBulkResult extends BulkResult {
export interface BulkMoveResult extends MailBulkResult {
/** 되돌리기 — 원래 메일함별로 묶은 메일 */
origins: BulkOrigin[]
/** 옮기며 함께 보낸 스팸 표시 (스팸 신고 true · 스팸 아님 false) — 되돌리기는 반대 값을 보낸다. 그냥 옮기기는 undefined */
isJunk?: boolean
}
/** 옮기기에 함께 보낼 것 — 스팸 신고 · 스팸 아님은 이동과 스팸 표시를 한 요청에 보낸다 (계약 2절 "스팸 · 원문") */
export interface MoveOptions {
isJunk?: boolean
}
/** 검색을 시작한 곳 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
+70 -1
View File
@@ -7,7 +7,7 @@ import ToastHost from '@/components/feedback/ToastHost.vue'
import { useToast } from '@/composables/useToast'
import { createTestRouter } from '@/test-utils/router'
import { useAuthStore } from './auth.store'
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
import { MAX_VISIBLE_TOASTS, TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
@@ -121,4 +121,73 @@ describe('toast.store', () => {
expect(run).toHaveBeenCalledTimes(1)
})
})
describe('진행 알림 — 오래 걸리는 메일함 처리 (디자인 원본 MailActions4)', () => {
it('끝날 때까지 남고, 그 자리에서 문구를 바꾸다가 결과로 바뀌면 그때부터 시간을 센다', () => {
const store = useToastStore()
const id = store.show({ kind: 'progress', message: '받은편지함의 메일을 읽음으로 표시하는 중 · 0 / 1,234통' })
vi.advanceTimersByTime(60_000)
expect(store.update(id, { message: '받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통' })).toBe(true)
expect(messages()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통'])
store.update(id, { kind: 'success', message: '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.' })
expect(store.toasts.map((toast) => [toast.id, toast.kind])).toEqual([[id, 'success']])
vi.advanceTimersByTime(TOAST_DURATION_MS.success - 1)
expect(messages()).toHaveLength(1)
vi.advanceTimersByTime(1)
expect(messages()).toEqual([])
expect(store.update(id, { message: '이미 닫힌 알림' })).toBe(false)
})
it('알림이 넘치면 진행 알림이 아닌 오래된 알림부터 닫는다', () => {
const store = useToastStore()
store.show({ kind: 'progress', message: '휴지통을 비우는 중' })
store.show({ kind: 'success', message: '하나' })
store.show({ kind: 'success', message: '둘' })
store.show({ kind: 'success', message: '셋' })
expect(messages()).toHaveLength(MAX_VISIBLE_TOASTS)
expect(messages()).toEqual(['휴지통을 비우는 중', '둘', '셋'])
})
it('붙잡은 채 결과로 바뀌면 놓을 때부터 시간을 센다', () => {
const store = useToastStore()
const id = store.show({ kind: 'progress', message: '비우는 중' })
store.hold(id, 'pointer')
store.update(id, { kind: 'success', message: '비웠습니다' })
vi.advanceTimersByTime(60_000)
expect(messages()).toEqual(['비웠습니다'])
store.release(id, 'pointer')
vi.advanceTimersByTime(TOAST_DURATION_MS.success)
expect(messages()).toEqual([])
})
it('세션이 바뀌면 진행 알림도 닫는다 — useToast 의 진행 알림은 그 뒤로 바뀌지 않는다', async () => {
useAuthStore().startSession({ email: 'ara.kim@arasystem.test', name: '김아라' })
const progress = useToast().progress('메일함을 지우는 중')
expect(messages()).toEqual(['메일함을 지우는 중'])
useAuthStore().clearAuth()
await nextTick()
expect(messages()).toEqual([])
progress.succeed('지웠습니다')
expect(messages()).toEqual([])
})
it('useToast 의 진행 알림 — 끝나면 성공 · 실패로 바꾸고, 그사이 닫혔으면 결과를 새로 띄운다', () => {
const toast = useToast()
const store = useToastStore()
const done = toast.progress('읽음으로 표시하는 중')
done.update('읽음으로 표시하는 중 · 500 / 600통')
done.succeed('600통을 읽음으로 표시했습니다.')
expect(store.toasts.map((item) => [item.kind, item.message])).toEqual([['success', '600통을 읽음으로 표시했습니다.']])
const failed = toast.progress('비우는 중')
store.dismiss(store.toasts[1]?.id ?? -1)
failed.fail('21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.' })
})
})
})
+80 -18
View File
@@ -8,10 +8,14 @@ import { useAuthStore } from '@/stores/auth.store'
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
// 진행 알림(progress — 오래 걸리는 메일함 처리 "…하는 중 · 600 / 1,234통")은 끝날 때까지 남는다: 저절로 닫히지 않고,
// 알림이 넘쳐도 다른 알림부터 닫는다. 끝나면 같은 알림을 결과(성공 · 오류)로 바꾼다(update) — 그때부터 시간을 센다.
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림과 진행 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
export type ToastKind = 'error' | 'success'
export type ToastKind = 'error' | 'success' | 'progress'
/** 정해진 시간 뒤에 닫히는 알림 */
export type TimedToastKind = Exclude<ToastKind, 'progress'>
/** 알림의 단추 — 다른 화면으로 가는 링크(to) 또는 그 자리에서 하는 일(run, 예: 되돌리기). 누르면 알림을 닫는다 */
export type ToastAction = { label: string; to: RouteLocationRaw } | { label: string; run: () => void }
@@ -33,11 +37,11 @@ export interface ToastInput {
action?: ToastAction | null
}
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 */
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 (진행 알림은 뒤로 미룬다) */
export const MAX_VISIBLE_TOASTS = 3
/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다 */
export const TOAST_DURATION_MS: Readonly<Record<ToastKind, number>> = {
/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다. 진행 알림은 끝날 때까지 남는다 */
export const TOAST_DURATION_MS: Readonly<Record<TimedToastKind, number>> = {
error: 8000,
success: 5000,
}
@@ -48,10 +52,32 @@ export const TOAST_ACTION_DURATION_MS = 10_000
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
export type ToastHoldReason = 'pointer' | 'focus'
/** 닫히기까지의 시간 — 진행 알림은 없다(null) */
function durationOf(toast: Pick<Toast, 'kind' | 'action'>): number | null {
if (toast.kind === 'progress') return null
return toast.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[toast.kind]
}
/** 넘친 만큼 닫을 알림 — 오래된 것부터, 진행 중인 알림은 다른 알림이 모자랄 때만. 방금 띄운 것은 닫지 않는다 */
function overflowOf(all: readonly Toast[], keepId: number): Set<number> {
let excess = all.length - MAX_VISIBLE_TOASTS
const evicted = new Set<number>()
for (const preferProgress of [false, true]) {
for (const toast of all) {
if (excess <= 0) return evicted
if (toast.id === keepId || evicted.has(toast.id)) continue
if (!preferProgress && toast.kind === 'progress') continue
evicted.add(toast.id)
excess -= 1
}
}
return evicted
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
const timers = new Map<number, ReturnType<typeof setTimeout>>()
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 (진행 알림은 없다) */
const durations = new Map<number, number>()
const holds = new Map<number, Set<ToastHoldReason>>()
let nextId = 1
@@ -84,9 +110,24 @@ export const useToastStore = defineStore('toast', () => {
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
/** 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 */
/** 닫히기까지의 시간을 정한다 — 붙잡혀 있지 않으면 바로 센다 (진행 알림은 시간이 없다) */
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action'>) {
const duration = durationOf(toast)
stopTimer(id)
if (duration === null) {
durations.delete(id)
return
}
durations.set(id, duration)
if (!holds.has(id)) startTimer(id)
}
/**
* 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다.
* 진행 알림도 기억해 둔다 — 붙잡은 채 결과로 바뀌면 놓을 때부터 시간을 센다
*/
function hold(id: number, reason: ToastHoldReason) {
if (!durations.has(id)) return
if (!toasts.value.some((toast) => toast.id === id)) return
const reasons = holds.get(id) ?? new Set<ToastHoldReason>()
reasons.add(reason)
holds.set(id, reasons)
@@ -113,15 +154,34 @@ export const useToastStore = defineStore('toast', () => {
action: input.action ?? null,
}
const all = [...toasts.value, next]
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
overflow.forEach((toast) => forget(toast.id))
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
const evicted = overflowOf(all, id)
evicted.forEach((toastId) => forget(toastId))
toasts.value = all.filter((toast) => !evicted.has(toast.id))
durations.set(id, next.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[input.kind])
startTimer(id)
setDuration(id, next)
return id
}
/**
* 띄운 알림을 그 자리에서 바꾼다 — 진행 문구를 고치거나, 끝난 진행 알림을 결과(성공 · 오류)로 바꾼다.
* 종류 · 단추가 바뀌면 그에 맞는 시간을 처음부터 센다 (진행 알림이 되면 저절로 닫히지 않는다).
* 이미 닫힌 알림이면 false — 부르는 쪽이 새로 띄울지 정한다
*/
function update(id: number, patch: Partial<Omit<ToastInput, 'kind'>> & { kind?: ToastKind }): boolean {
const current = toasts.value.find((toast) => toast.id === id)
if (!current) return false
const next: Toast = {
...current,
kind: patch.kind ?? current.kind,
message: patch.message ?? current.message,
traceId: patch.traceId === undefined ? current.traceId : patch.traceId,
action: patch.action === undefined ? current.action : patch.action,
}
toasts.value = toasts.value.map((toast) => (toast.id === id ? next : toast))
if (next.kind !== current.kind || next.action !== current.action) setDuration(id, next)
return true
}
function clear() {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
@@ -130,13 +190,15 @@ export const useToastStore = defineStore('toast', () => {
toasts.value = []
}
/** 단추가 있는 알림을 닫는다 — 세션이 바뀌었을 때 */
function dismissActions() {
toasts.value.filter((toast) => toast.action !== null).forEach((toast) => dismiss(toast.id))
/** 세션에 묶인 알림을 닫는다 — 단추가 있는 알림 · 진행 알림 (세션이 바뀌었을 때) */
function dismissSessionBound() {
toasts.value
.filter((toast) => toast.action !== null || toast.kind === 'progress')
.forEach((toast) => dismiss(toast.id))
}
const authStore = useAuthStore()
watch(() => authStore.generation, dismissActions)
watch(() => authStore.generation, dismissSessionBound)
return { toasts, show, dismiss, hold, release, clear }
return { toasts, show, update, dismiss, hold, release, clear }
})
+72
View File
@@ -92,6 +92,78 @@ a:hover {
box-shadow: var(--shadow-segment);
}
/*
* 테두리 있는 입력칸 — 설정 화면 · 메일함 이름 대화상자 (디자인 원본 SettingsAccount · SettingsVacation · MailboxManage 의 .field · .pw-box)
* 높이 44 · 테두리 #D0D5DD. 초점은 강조색 1px 테두리 + 1px 그림자(상단 검색 칸과 같다), 오류는 빨간 테두리, 꺼진 칸은 흐리게.
* 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 .form-input 을, 안의 입력 요소에 .form-input__control 을 붙인다 —
* 입력 요소에 초점이 있을 때만 상자에 초점을 보인다 (안의 단추에 초점이 가면 단추 고리만 보인다).
* 오류 상태는 입력 요소의 aria-invalid="true" (상자는 .is-invalid) 로 그린다.
*/
.form-input {
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
}
.form-input:focus,
.form-input:has(.form-input__control:focus) {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.form-input[aria-invalid='true'],
.form-input.is-invalid {
border-color: var(--color-danger);
}
.form-input[aria-invalid='true']:focus,
.form-input.is-invalid:has(.form-input__control:focus) {
border-color: var(--color-danger);
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
}
.form-input:disabled {
background: var(--color-surface-panel);
color: var(--color-icon-muted);
}
.form-input__control {
flex: 1 1 auto;
min-width: 0;
padding: 0 var(--space-12);
border: 0;
background: transparent;
color: inherit;
}
.form-input__control:focus,
.form-input__control:focus-visible {
outline: none;
}
/* 칸 아래 안내 · 오류 (12.5px) — 오류는 빨간 글 + alert-circle 아이콘 (디자인 원본 SettingsAccount) */
.form-message {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.form-message--error {
color: var(--color-danger);
}
/* 아이콘을 첫 줄 가운데에 맞춘다 */
.form-message > .app-icon {
margin-top: var(--space-2);
}
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
.visually-hidden {
position: absolute;
+9
View File
@@ -148,6 +148,15 @@
--size-search-clear-height: 2.375rem; /* 38px — 검색 칸(40) 안쪽 높이를 채운다 · 밀도 예외 */
--size-notice-bar: 2.5rem; /* 40px — "새 메일 N통 · 맨 위로" 줄 높이 · 밀도 예외 */
--size-checkbox: 1rem; /* 16px — 고르기 상자 */
/*
* 4단계(계정 · 메일 설정) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "4단계 › 메일함 관리", 사용자 확정 캔버스).
* 메일함 행의 ⋯ 단추 32×40(행 높이 --size-row-min — 목록 표의 별 칸과 같은 폭) · ⋯ 메뉴 항목 40(--size-editor-menu-item)
*/
--size-mailbox-more: 2rem; /* 32px — 메일함 행의 ⋯ 단추 폭 · 밀도 예외 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
--form-wide-max-width: 32.5rem; /* 520px — 자동 응답 제목 칸 (디자인 원본 SettingsVacation) */
--note-max-width: 38.75rem; /* 620px — 설정 화면의 안내 상자 */
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
--size-rail-item: 3.75rem; /* 60px */
--size-row-min: 2.5rem; /* 40px — 메일함 항목 */
+13 -1
View File
@@ -35,13 +35,25 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
{ path: '/settings', name: 'settings', redirect: { name: 'settings-account-password' } },
{
path: '/settings/account/password',
name: 'settings-account-password',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/signature',
name: 'settings-mail-signature',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/vacation',
name: 'settings-mail-vacation',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}