diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue
index 7cf14e1..3359b46 100644
--- a/frontend/src/components/feedback/ModalDialog.vue
+++ b/frontend/src/components/feedback/ModalDialog.vue
@@ -1,19 +1,24 @@
@@ -40,16 +48,23 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
{{ title }}
-
-
+
@@ -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;
}
diff --git a/frontend/src/components/mail/MailSelectionToolbar.spec.ts b/frontend/src/components/mail/MailSelectionToolbar.spec.ts
index aa02b91..4cdfc21 100644
--- a/frontend/src/components/mail/MailSelectionToolbar.spec.ts
+++ b/frontend/src/components/mail/MailSelectionToolbar.spec.ts
@@ -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)
+ })
+ })
})
diff --git a/frontend/src/components/mail/MailSelectionToolbar.vue b/frontend/src/components/mail/MailSelectionToolbar.vue
index 83f1265..9c8e011 100644
--- a/frontend/src/components/mail/MailSelectionToolbar.vue
+++ b/frontend/src/components/mail/MailSelectionToolbar.vue
@@ -1,7 +1,8 @@
+
+
+
+
+
+ {{ VACATION_TEXT.noticeTitle }}
+ {{ detail }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxDeleteDialog.vue b/frontend/src/components/mail/MailboxDeleteDialog.vue
new file mode 100644
index 0000000..d738082
--- /dev/null
+++ b/frontend/src/components/mail/MailboxDeleteDialog.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxDialogs.vue b/frontend/src/components/mail/MailboxDialogs.vue
new file mode 100644
index 0000000..8ad4680
--- /dev/null
+++ b/frontend/src/components/mail/MailboxDialogs.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxEmptyButton.spec.ts b/frontend/src/components/mail/MailboxEmptyButton.spec.ts
new file mode 100644
index 0000000..a84aeaf
--- /dev/null
+++ b/frontend/src/components/mail/MailboxEmptyButton.spec.ts
@@ -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 | 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((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()
+ })
+})
diff --git a/frontend/src/components/mail/MailboxEmptyButton.vue b/frontend/src/components/mail/MailboxEmptyButton.vue
new file mode 100644
index 0000000..90a3366
--- /dev/null
+++ b/frontend/src/components/mail/MailboxEmptyButton.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxEmptyDialog.vue b/frontend/src/components/mail/MailboxEmptyDialog.vue
new file mode 100644
index 0000000..3bde697
--- /dev/null
+++ b/frontend/src/components/mail/MailboxEmptyDialog.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxMoreButton.vue b/frontend/src/components/mail/MailboxMoreButton.vue
new file mode 100644
index 0000000..11d8565
--- /dev/null
+++ b/frontend/src/components/mail/MailboxMoreButton.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxNameDialog.vue b/frontend/src/components/mail/MailboxNameDialog.vue
new file mode 100644
index 0000000..db98cbc
--- /dev/null
+++ b/frontend/src/components/mail/MailboxNameDialog.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+ {{ shownError ?? MAILBOX_DIALOG_TEXT.nameHelp }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MailboxPanel.spec.ts b/frontend/src/components/mail/MailboxPanel.spec.ts
new file mode 100644
index 0000000..5e5e165
--- /dev/null
+++ b/frontend/src/components/mail/MailboxPanel.spec.ts
@@ -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 {
+ 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
+ resolve: (result: MockResult) => void
+}
+
+function deferred(): Deferred {
+ let resolve: (result: MockResult) => void = () => undefined
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+type Reply = MockResult | Deferred
+
+describe('MailboxPanel — 메일함 관리', () => {
+ let pinia: Pinia
+ let requests: MockRequest[]
+ /** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
+ let replies: Map
+
+ 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('#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[]) =>
+ 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')
+ })
+})
diff --git a/frontend/src/components/mail/MailboxPanel.vue b/frontend/src/components/mail/MailboxPanel.vue
index 13acbeb..ded1036 100644
--- a/frontend/src/components/mail/MailboxPanel.vue
+++ b/frontend/src/components/mail/MailboxPanel.vue
@@ -1,33 +1,63 @@
+
+
+
diff --git a/frontend/src/components/mail/MailboxTree.vue b/frontend/src/components/mail/MailboxTree.vue
new file mode 100644
index 0000000..5782021
--- /dev/null
+++ b/frontend/src/components/mail/MailboxTree.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+ emit('action', action, mailbox)"
+ />
+
+
+ {{ MAILBOX_PANEL_TEXT.myMailboxes }}
+
+
+ emit('action', action, mailbox)"
+ />
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MessageDeleteButton.vue b/frontend/src/components/mail/MessageDeleteButton.vue
index be1524c..daa364e 100644
--- a/frontend/src/components/mail/MessageDeleteButton.vue
+++ b/frontend/src/components/mail/MessageDeleteButton.vue
@@ -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()
-const router = useRouter()
const mailStore = useMailStore()
const removal = useMessageRemoval()
-const { backToSourceList } = useMailNavigation()
const working = ref(false)
const confirmOpen = ref(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
}
diff --git a/frontend/src/components/mail/MessageMoreMenu.vue b/frontend/src/components/mail/MessageMoreMenu.vue
new file mode 100644
index 0000000..b36d2db
--- /dev/null
+++ b/frontend/src/components/mail/MessageMoreMenu.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts
index 13b14f9..bfc6466 100644
--- a/frontend/src/components/mail/MessageReader.spec.ts
+++ b/frontend/src/components/mail/MessageReader.spec.ts
@@ -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[]
+ /** 내려받기를 시작하는 임시 의 클릭 — 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)
+ })
+})
diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue
index 93ad7cc..cf8d0ae 100644
--- a/frontend/src/components/mail/MessageReader.vue
+++ b/frontend/src/components/mail/MessageReader.vue
@@ -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 } }"
/>
+
+
diff --git a/frontend/src/components/mail/MessageSpamButton.vue b/frontend/src/components/mail/MessageSpamButton.vue
new file mode 100644
index 0000000..6ad67f5
--- /dev/null
+++ b/frontend/src/components/mail/MessageSpamButton.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/EditorMenu.vue b/frontend/src/components/mail/editor/EditorMenu.vue
index ae4e464..b25a9a0 100644
--- a/frontend/src/components/mail/editor/EditorMenu.vue
+++ b/frontend/src/components/mail/editor/EditorMenu.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/PasswordField.vue b/frontend/src/components/settings/PasswordField.vue
new file mode 100644
index 0000000..b6dc1ad
--- /dev/null
+++ b/frontend/src/components/settings/PasswordField.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+ {{ hint }}
+
+
+
+ {{ error }}
+
+
+
+
+
diff --git a/frontend/src/components/settings/SettingsPageFrame.vue b/frontend/src/components/settings/SettingsPageFrame.vue
new file mode 100644
index 0000000..1343ca6
--- /dev/null
+++ b/frontend/src/components/settings/SettingsPageFrame.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationForm.vue b/frontend/src/components/settings/VacationForm.vue
new file mode 100644
index 0000000..6d1c2f6
--- /dev/null
+++ b/frontend/src/components/settings/VacationForm.vue
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationMessageFields.vue b/frontend/src/components/settings/VacationMessageFields.vue
new file mode 100644
index 0000000..fc70292
--- /dev/null
+++ b/frontend/src/components/settings/VacationMessageFields.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+ {{ subjectError }}
+
+
+
+
+
+
+
+
+ {{ bodyError }}
+
+
+ {{ VACATION_TEXT.bodyHint }}
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationPeriodFields.vue b/frontend/src/components/settings/VacationPeriodFields.vue
new file mode 100644
index 0000000..52936bf
--- /dev/null
+++ b/frontend/src/components/settings/VacationPeriodFields.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/VacationPreview.vue b/frontend/src/components/settings/VacationPreview.vue
new file mode 100644
index 0000000..28683a1
--- /dev/null
+++ b/frontend/src/components/settings/VacationPreview.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+ {{ VACATION_TEXT.previewTitle }}
+
+
+
+ -
+ {{ VACATION_TEXT.previewFrom }}
+
+ -
+ {{ sender }}
+
+ -
+ {{ VACATION_TEXT.previewSubject }}
+
+ -
+ {{ shownSubject }}
+
+
+
+
+
+
+ {{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
+
+
+
+
+
diff --git a/frontend/src/components/shell/AppRail.spec.ts b/frontend/src/components/shell/AppRail.spec.ts
index a475127..5d8e7ab 100644
--- a/frontend/src/components/shell/AppRail.spec.ts
+++ b/frontend/src/components/shell/AppRail.spec.ts
@@ -27,7 +27,7 @@ describe('AppRail', () => {
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
- // 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
+ // 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
expect(links).toEqual([
['메일', '/mail'],
['설정', '/settings'],
diff --git a/frontend/src/composables/useApi.spec.ts b/frontend/src/composables/useApi.spec.ts
index b22fe80..6a73ea1 100644
--- a/frontend/src/composables/useApi.spec.ts
+++ b/frontend/src/composables/useApi.spec.ts
@@ -66,6 +66,9 @@ describe('에러 코드 사전', () => {
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
+ // 웹메일 4단계
+ 'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
+ 'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
]
for (const code of usedByClient) {
diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts
index d66cf41..43d8258 100644
--- a/frontend/src/composables/useAuth.ts
+++ b/frontend/src/composables/useAuth.ts
@@ -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,
+ ): Promise {
+ await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
+ }
+
+ return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
}
diff --git a/frontend/src/composables/useBulkMailActions.ts b/frontend/src/composables/useBulkMailActions.ts
index e9fe35f..1122647 100644
--- a/frontend/src/composables/useBulkMailActions.ts
+++ b/frontend/src/composables/useBulkMailActions.ts
@@ -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(() => mailStore.currentMailbox?.role === 'trash')
+ /** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
+ const inJunk = computed(() => mailStore.currentMailbox?.role === 'junk')
+ /** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
+ const spamTarget = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed(() =>
mailStore.orderedMailboxes
@@ -55,11 +60,11 @@ export function useBulkMailActions() {
await router.replace(listLocationOf(source))
}
- async function moveTo(mailbox: Mailbox): Promise {
+ async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise {
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 {
+ 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 {
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,
+ }
}
diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts
index 7c8124b..93a0e12 100644
--- a/frontend/src/composables/useMail.ts
+++ b/frontend/src/composables/useMail.ts
@@ -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('/mail-quota', options)
}
+ // ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ─────────────
+
+ /** 새 메일함 — 같은 자리에 같은 이름이면 409 MAIL_009, 없는 parentId 는 404 RES_001. parentId 가 없으면 맨 위 */
+ function createMailbox(payload: MailboxCreatePayload, options?: MailRequestOptions): Promise {
+ const body: MailboxCreatePayload = payload.parentId ? payload : { name: payload.name }
+ return api.post('/mailboxes', body, options)
+ }
+
+ /** 이름 바꾸기 — 기본 메일함은 400 VAL_001 (화면은 메뉴에 두지 않는다) */
+ function renameMailbox(id: string, payload: MailboxRenamePayload, options?: MailRequestOptions): Promise {
+ return api.patch(`/mailboxes/${segment(id)}`, payload, options)
+ }
+
+ /**
+ * 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
+ * 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
+ */
+ function deleteMailbox(id: string, options?: MailRequestOptions): Promise {
+ return api.delete(`/mailboxes/${segment(id)}`, {
+ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
+ ...options,
+ })
+ }
+
+ /** 그 메일함의 안 읽은 메일을 읽음으로 — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
+ function markMailboxRead(id: string, options?: MailRequestOptions): Promise {
+ return api.patch(
+ `/mailboxes/${segment(id)}/messages`,
+ { isRead: true },
+ { timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS, ...options },
+ )
+ }
+
+ /** 휴지통 · 스팸메일함 비우기(영구 삭제) — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
+ function emptyMailbox(id: string, options?: MailRequestOptions): Promise {
+ return api.delete(`/mailboxes/${segment(id)}/messages`, {
+ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
+ ...options,
+ })
+ }
+
+ // ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
+
+ /** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
+ function getVacation(options?: MailRequestOptions): Promise {
+ return api.get('/mail-vacation', options)
+ }
+
+ /** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
+ function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise {
+ return api.put('/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 {
+ 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,
diff --git a/frontend/src/composables/useMailMoveFeedback.ts b/frontend/src/composables/useMailMoveFeedback.ts
index 2f167d5..97be0c6 100644
--- a/frontend/src/composables/useMailMoveFeedback.ts
+++ b/frontend/src/composables/useMailMoveFeedback.ts
@@ -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 {
- const result = await mailStore.restoreMoved(origins)
+ async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise {
+ 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,
)
}
diff --git a/frontend/src/composables/useMailboxActions.ts b/frontend/src/composables/useMailboxActions.ts
new file mode 100644
index 0000000..98564f7
--- /dev/null
+++ b/frontend/src/composables/useMailboxActions.ts
@@ -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): boolean {
+ return mailStore.isMailboxOperationBlocked(kind, mailbox)
+ }
+
+ async function saveName(
+ run: () => Promise,
+ done: (mailbox: Mailbox) => string,
+ ): Promise {
+ 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 {
+ return saveName(
+ () => mailStore.createMailbox(name, parentId, { silent: NAME_INLINE_CODES }),
+ (mailbox) => MAILBOX_RESULT_TEXT.created(mailbox.name),
+ )
+ }
+
+ function rename(mailbox: Mailbox, name: string): Promise {
+ 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 {
+ 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 {
+ await nextTick()
+ if (!focusIsLost()) return
+ document.querySelector(CURRENT_MAILBOX_LINK)?.focus()
+ }
+
+ async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise {
+ 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 {
+ return runOperation('read', mailbox)
+ }
+
+ /** 휴지통 · 스팸메일함 비우기(영구 삭제) — 확인 대화상자를 거친 뒤에만 부른다 */
+ function empty(mailbox: Mailbox): Promise {
+ return runOperation('empty', mailbox)
+ }
+
+ /** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
+ function remove(mailbox: Mailbox): Promise {
+ return runOperation('delete', mailbox)
+ }
+
+ return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
+}
diff --git a/frontend/src/composables/useMessageRemoval.ts b/frontend/src/composables/useMessageRemoval.ts
index 3a586c5..1fb0e11 100644
--- a/frontend/src/composables/useMessageRemoval.ts
+++ b/frontend/src/composables/useMessageRemoval.ts
@@ -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): 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 {
+ 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 {
+ 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 }
}
diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts
index 5771aec..787c117 100644
--- a/frontend/src/composables/useToast.ts
+++ b/frontend/src/composables/useToast.ts
@@ -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 }
}
diff --git a/frontend/src/pages/mail/MailLayout.vue b/frontend/src/pages/mail/MailLayout.vue
index 0fd76b1..81a9dd4 100644
--- a/frontend/src/pages/mail/MailLayout.vue
+++ b/frontend/src/pages/mail/MailLayout.vue
@@ -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({
diff --git a/frontend/src/pages/settings/AccountPasswordPage.spec.ts b/frontend/src/pages/settings/AccountPasswordPage.spec.ts
new file mode 100644
index 0000000..9b8fc28
--- /dev/null
+++ b/frontend/src/pages/settings/AccountPasswordPage.spec.ts
@@ -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 = () => 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((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)
+ })
+})
diff --git a/frontend/src/pages/settings/AccountPasswordPage.vue b/frontend/src/pages/settings/AccountPasswordPage.vue
new file mode 100644
index 0000000..688b4ad
--- /dev/null
+++ b/frontend/src/pages/settings/AccountPasswordPage.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+ {{ PASSWORD_TEXT.noteSessions }}
+ {{ PASSWORD_TEXT.noteImap }}
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/MailVacationPage.spec.ts b/frontend/src/pages/settings/MailVacationPage.spec.ts
new file mode 100644
index 0000000..186007c
--- /dev/null
+++ b/frontend/src/pages/settings/MailVacationPage.spec.ts
@@ -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
+}
+
+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('김아라 ')
+ 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((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')
+ })
+ })
+})
diff --git a/frontend/src/pages/settings/MailVacationPage.vue b/frontend/src/pages/settings/MailVacationPage.vue
new file mode 100644
index 0000000..b30462b
--- /dev/null
+++ b/frontend/src/pages/settings/MailVacationPage.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/SettingsLayout.spec.ts b/frontend/src/pages/settings/SettingsLayout.spec.ts
new file mode 100644
index 0000000..8db5683
--- /dev/null
+++ b/frontend/src/pages/settings/SettingsLayout.spec.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/pages/settings/SettingsLayout.vue b/frontend/src/pages/settings/SettingsLayout.vue
index 76f4bf4..12169ea 100644
--- a/frontend/src/pages/settings/SettingsLayout.vue
+++ b/frontend/src/pages/settings/SettingsLayout.vue
@@ -1,8 +1,14 @@
@@ -11,30 +17,38 @@ import AppShell from '@/components/shell/AppShell.vue'
class="settings-menu"
aria-label="설정 메뉴"
>
-
-
+
+
+
+
+
@@ -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;
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index d18caa6..256c6fb 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -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,
diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts
new file mode 100644
index 0000000..32dc721
--- /dev/null
+++ b/frontend/src/router/routes.ts
@@ -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: '페이지를 찾을 수 없음' },
+ },
+]
diff --git a/frontend/src/shared/constants/account.ts b/frontend/src/shared/constants/account.ts
new file mode 100644
index 0000000..0e1b2c5
--- /dev/null
+++ b/frontend/src/shared/constants/account.ts
@@ -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
diff --git a/frontend/src/shared/constants/icons.ts b/frontend/src/shared/constants/icons.ts
index 7bdd1bd..ca5d2f1 100644
--- a/frontend/src/shared/constants/icons.ts
+++ b/frontend/src/shared/constants/icons.ts
@@ -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
export type IconName = keyof typeof ICONS
diff --git a/frontend/src/shared/constants/mail-vacation.ts b/frontend/src/shared/constants/mail-vacation.ts
new file mode 100644
index 0000000..d30b0c0
--- /dev/null
+++ b/frontend/src/shared/constants/mail-vacation.ts
@@ -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
diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts
index 1a99e14..a3187b0 100644
--- a/frontend/src/shared/constants/mail.ts
+++ b/frontend/src/shared/constants/mail.ts
@@ -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절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
diff --git a/frontend/src/shared/constants/mailbox-manage.ts b/frontend/src/shared/constants/mailbox-manage.ts
new file mode 100644
index 0000000..47ec34e
--- /dev/null
+++ b/frontend/src/shared/constants/mailbox-manage.ts
@@ -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
diff --git a/frontend/src/shared/constants/settings-menu.ts b/frontend/src/shared/constants/settings-menu.ts
new file mode 100644
index 0000000..2b4ab03
--- /dev/null
+++ b/frontend/src/shared/constants/settings-menu.ts
@@ -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' },
+ ],
+ },
+]
diff --git a/frontend/src/shared/types/auth.ts b/frontend/src/shared/types/auth.ts
index dc62938..8e8dc0c 100644
--- a/frontend/src/shared/types/auth.ts
+++ b/frontend/src/shared/types/auth.ts
@@ -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
+}
diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts
index 512bc64..50de694 100644
--- a/frontend/src/shared/types/mail.ts
+++ b/frontend/src/shared/types/mail.ts
@@ -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'
diff --git a/frontend/src/shared/utils/korean.spec.ts b/frontend/src/shared/utils/korean.spec.ts
index 90317d8..582da42 100644
--- a/frontend/src/shared/utils/korean.spec.ts
+++ b/frontend/src/shared/utils/korean.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/shared/utils/korean.ts b/frontend/src/shared/utils/korean.ts
index 68e28e7..9a821a3 100644
--- a/frontend/src/shared/utils/korean.ts
+++ b/frontend/src/shared/utils/korean.ts
@@ -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 ? '를' : '을'
+}
+
/**
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
diff --git a/frontend/src/shared/utils/mail-bulk.spec.ts b/frontend/src/shared/utils/mail-bulk.spec.ts
index 5fae373..be82839 100644
--- a/frontend/src/shared/utils/mail-bulk.spec.ts
+++ b/frontend/src/shared/utils/mail-bulk.spec.ts
@@ -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통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
+ )
+ })
})
diff --git a/frontend/src/shared/utils/mail-bulk.ts b/frontend/src/shared/utils/mail-bulk.ts
index 013a542..b4009e5 100644
--- a/frontend/src/shared/utils/mail-bulk.ts
+++ b/frontend/src/shared/utils/mail-bulk.ts
@@ -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)
}
diff --git a/frontend/src/shared/utils/mail-vacation.spec.ts b/frontend/src/shared/utils/mail-vacation.spec.ts
new file mode 100644
index 0000000..208af0e
--- /dev/null
+++ b/frontend/src/shared/utils/mail-vacation.spec.ts
@@ -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 {
+ 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()
+ })
+})
diff --git a/frontend/src/shared/utils/mail-vacation.ts b/frontend/src/shared/utils/mail-vacation.ts
new file mode 100644
index 0000000..4616dfb
--- /dev/null
+++ b/frontend/src/shared/utils/mail-vacation.ts
@@ -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'
+
+/** 화면의 칸 — 날짜는 값(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): { 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
+}
diff --git a/frontend/src/shared/utils/mailbox-operation.spec.ts b/frontend/src/shared/utils/mailbox-operation.spec.ts
new file mode 100644
index 0000000..9d96428
--- /dev/null
+++ b/frontend/src/shared/utils/mailbox-operation.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/shared/utils/mailbox-operation.ts b/frontend/src/shared/utils/mailbox-operation.ts
new file mode 100644
index 0000000..c1f6579
--- /dev/null
+++ b/frontend/src/shared/utils/mailbox-operation.ts
@@ -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): 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,
+ }
+}
diff --git a/frontend/src/shared/utils/mailbox.spec.ts b/frontend/src/shared/utils/mailbox.spec.ts
index ccce059..e192662 100644
--- a/frontend/src/shared/utils/mailbox.spec.ts
+++ b/frontend/src/shared/utils/mailbox.spec.ts
@@ -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 & Pick): 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')
+ })
+})
diff --git a/frontend/src/shared/utils/mailbox.ts b/frontend/src/shared/utils/mailbox.ts
index 9344a0a..b84eed9 100644
--- a/frontend/src/shared/utils/mailbox.ts
+++ b/frontend/src/shared/utils/mailbox.ts
@@ -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): 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): MailboxKind {
return mailbox.role ?? CUSTOM_MAILBOX_KEY
}
diff --git a/frontend/src/shared/utils/password-change.spec.ts b/frontend/src/shared/utils/password-change.spec.ts
new file mode 100644
index 0000000..ed636b8
--- /dev/null
+++ b/frontend/src/shared/utils/password-change.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/shared/utils/password-change.ts b/frontend/src/shared/utils/password-change.ts
new file mode 100644
index 0000000..8c69a27
--- /dev/null
+++ b/frontend/src/shared/utils/password-change.ts
@@ -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
+}
diff --git a/frontend/src/shared/utils/popup-placement.spec.ts b/frontend/src/shared/utils/popup-placement.spec.ts
new file mode 100644
index 0000000..a121308
--- /dev/null
+++ b/frontend/src/shared/utils/popup-placement.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/shared/utils/popup-placement.ts b/frontend/src/shared/utils/popup-placement.ts
new file mode 100644
index 0000000..3a23b28
--- /dev/null
+++ b/frontend/src/shared/utils/popup-placement.ts
@@ -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
+}
diff --git a/frontend/src/stores/mail-vacation.store.spec.ts b/frontend/src/stores/mail-vacation.store.spec.ts
new file mode 100644
index 0000000..307ff71
--- /dev/null
+++ b/frontend/src/stores/mail-vacation.store.spec.ts
@@ -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((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')
+ })
+})
diff --git a/frontend/src/stores/mail-vacation.store.ts b/frontend/src/stores/mail-vacation.store.ts
new file mode 100644
index 0000000..0500b5d
--- /dev/null
+++ b/frontend/src/stores/mail-vacation.store.ts
@@ -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(null)
+ const status = ref('idle')
+ const error = ref(null)
+
+ let seq = 0
+
+ /** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 그대로 둔다) */
+ async function load(): Promise {
+ 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 {
+ 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 }
+})
diff --git a/frontend/src/stores/mail.store.ts b/frontend/src/stores/mail.store.ts
index 68c6457..ba55c5c 100644
--- a/frontend/src/stores/mail.store.ts
+++ b/frontend/src/stores/mail.store.ts
@@ -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(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,
diff --git a/frontend/src/stores/mail/bulk.ts b/frontend/src/stores/mail/bulk.ts
index ba891f6..1c04be5 100644
--- a/frontend/src/stores/mail/bulk.ts
+++ b/frontend/src/stores/mail/bulk.ts
@@ -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 {
- if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false }
+ async function moveMessages(
+ targets: readonly MessageSummary[],
+ targetId: string,
+ options: MoveOptions = {},
+ ): Promise {
+ 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()
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 {
- const result = await moveMessages(selection.checkedMessages.value, targetId)
+ async function moveChecked(targetId: string, options: MoveOptions = {}): Promise {
+ 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 {
+ async function restoreMoved(origins: readonly BulkOrigin[], options: MoveOptions = {}): Promise {
+ 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)
diff --git a/frontend/src/stores/mail/mailbox-admin.ts b/frontend/src/stores/mail/mailbox-admin.ts
new file mode 100644
index 0000000..181bbda
--- /dev/null
+++ b/frontend/src/stores/mail/mailbox-admin.ts
@@ -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>(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): 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
diff --git a/frontend/src/stores/mail/mailboxes.ts b/frontend/src/stores/mail/mailboxes.ts
index 36b538c..5b53d31 100644
--- a/frontend/src/stores/mail/mailboxes.ts
+++ b/frontend/src/stores/mail/mailboxes.ts
@@ -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>) {
+ 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,
}
}
diff --git a/frontend/src/stores/mail/types.ts b/frontend/src/stores/mail/types.ts
index cc5253c..5aed64c 100644
--- a/frontend/src/stores/mail/types.ts
+++ b/frontend/src/stores/mail/types.ts
@@ -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
}
/** 검색을 시작한 곳 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
diff --git a/frontend/src/stores/toast.store.spec.ts b/frontend/src/stores/toast.store.spec.ts
index 45115aa..acd6ec3 100644
--- a/frontend/src/stores/toast.store.spec.ts
+++ b/frontend/src/stores/toast.store.spec.ts
@@ -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통까지 처리했습니다 — 다시 시도해 주세요.' })
+ })
+ })
})
diff --git a/frontend/src/stores/toast.store.ts b/frontend/src/stores/toast.store.ts
index 9b1f42c..386be09 100644
--- a/frontend/src/stores/toast.store.ts
+++ b/frontend/src/stores/toast.store.ts
@@ -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
/** 알림의 단추 — 다른 화면으로 가는 링크(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> = {
+/** 자동으로 닫히기까지의 시간 — 오류는 문의 코드를 옮겨 적을 수 있게 더 오래 둔다. 진행 알림은 끝날 때까지 남는다 */
+export const TOAST_DURATION_MS: Readonly> = {
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): 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 {
+ let excess = all.length - MAX_VISIBLE_TOASTS
+ const evicted = new Set()
+ 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([])
const timers = new Map>()
- /** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
+ /** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 (진행 알림은 없다) */
const durations = new Map()
const holds = new Map>()
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) {
+ 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()
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> & { 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 }
})
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index dc76633..446098d 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -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;
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index e46fbf2..2886a0e 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -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 — 메일함 항목 */
diff --git a/frontend/src/test-utils/router.ts b/frontend/src/test-utils/router.ts
index cd259af..0826aac 100644
--- a/frontend/src/test-utils/router.ts
+++ b/frontend/src/test-utils/router.ts
@@ -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 },
+ },
],
})
}