feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기

- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만
- [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두),
  같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름"
- 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시
- 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님
- 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창
- 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나
- 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다
- 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:37:55 +09:00
parent 1ca210dec4
commit d02a983d57
89 changed files with 7105 additions and 371 deletions
@@ -5,11 +5,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -55,6 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -85,7 +90,10 @@ function onStar() {
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="row.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<MailCheckbox
class="thread-row__check"
@@ -10,6 +10,8 @@
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
// - [모두 펼치기](15단계)로 펼친 줄은 본문을 바로 받지 않는다 — 이 안(data-conversation-panel)이 화면에 들어오면 칸이 스토어에 알려 차례로 받는다.
// 받기 전에는 받는 중과 같은 뼈대(aria-busy)다 (useConversationPanelVisibility)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -70,6 +72,7 @@ function onFrameKeydown(event: KeyboardEvent) {
<div
:id="panelId"
class="conversation-panel"
:data-conversation-panel="row.message.id"
>
<p
v-if="recipients"
@@ -6,6 +6,9 @@
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// - 펼친 줄 머리 오른쪽 [답장](15단계 — 디자인 원본 Reader15 "6", 36 — 안내 줄 단추 크기): 긴 메일을 끝까지 내리지 않고 답장한다.
// 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀(마우스를 올린 바탕은 틀에) 안에 펼침 단추와 [답장]을 나란히 둔다(단추 안에 단추를 넣지 않는다).
// 펼친 안 아래의 [답장] · [이 메일만 열기]는 그대로다. 임시 저장본 · 예약한 메일 · 그사이 지워진 메일의 줄에는 없다(아래 [답장]과 같다)
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
@@ -39,6 +42,13 @@ const emit = defineEmits<Emits>()
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */
const headReply = computed(() => {
if (!props.expanded || !props.row.replyLocation) return null
const detail = props.detail
if (detail?.status === 'error' && detail.error.code === 'RES_001') return null
return props.row.replyLocation
})
</script>
<template>
@@ -69,39 +79,49 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
</div>
<template v-else>
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<div :class="['conversation-row__head', { 'conversation-row__head--reply': headReply }]">
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
<RouterLink
v-if="headReply"
class="conversation-row__reply"
:to="headReply"
data-conversation-head-reply
>
{{ MAIL_CONVERSATION_TEXT.reply }}
</RouterLink>
</div>
<ConversationMessagePanel
v-if="expanded"
:panel-id="panelId"
@@ -132,10 +152,47 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
text-align: left;
}
button.conversation-row__line:hover {
/* 다른 메일의 머리 — 펼침 단추와 [답장]을 감싸고, 마우스를 올린 바탕은 머리 전체에 (디자인 원본 Reader15 "6") */
.conversation-row__head {
display: flex;
align-items: center;
background: var(--color-surface);
}
.conversation-row__head:hover {
background: var(--color-surface-panel);
}
.conversation-row__head .conversation-row__line {
flex: 1 1 auto;
min-width: 0;
background: transparent;
}
/* [답장]이 있으면 단추 오른쪽 안쪽을 줄인다 — [답장]의 안쪽 여백이 시각과 사이를 둔다 */
.conversation-row__head--reply .conversation-row__line {
padding-right: var(--space-6);
}
/* 펼친 줄 머리의 [답장] — 36 글자 단추(안내 줄 단추의 밀도 예외 재사용), 펼친 안 아래의 [답장]과 같은 모양 */
.conversation-row__reply {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
margin-right: var(--space-4);
padding: 0 var(--space-10);
border-radius: var(--radius-md);
color: var(--color-brand);
font-weight: 600;
text-decoration: none;
}
.conversation-row__reply:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
button.conversation-row__line:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
@@ -6,11 +6,14 @@
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
@@ -47,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -80,8 +85,11 @@ function onStar() {
<tr
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="row.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
@@ -3,17 +3,21 @@
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
// 15단계(디자인 원본 Reader15): 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-menu.ts — 읽기 화면의 [이동 ▾]와 같은 모양 · 같은 규칙).
// 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄로 남긴다("고른 메일이 있는 메일함"). 메뉴가 화면 밖으로 넘치면 좌우를 바꿔 연다(EditorPopup)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { hasMoveTarget, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
interface Props {
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
mailboxes: readonly Mailbox[]
/** 옮길 곳 — 메일함 패널 차례 · 깊이, 고른 메일이 든 메일함 · 임시보관함 · 휴지통은 빼거나 고를 수 없는 줄로 (mail-move-targets.ts) */
mailboxes: readonly MailMoveTarget[]
labelMode: 'always' | 'wide' | 'never'
disabled?: boolean
}
@@ -35,10 +39,10 @@ const open = ref<boolean>(false)
const byKeyboard = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
const items = computed<EditorMenuItem[]>(() => props.mailboxes.map((mailbox) => ({ id: mailbox.id, label: mailbox.name })))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(props.mailboxes, MAIL_BULK_TEXT.moveHereChecked))
function openMenu(keyboard: boolean) {
if (props.disabled || items.value.length === 0) return
if (props.disabled || !hasMoveTarget(props.mailboxes)) return
byKeyboard.value = keyboard
open.value = true
}
@@ -64,10 +68,11 @@ function onClose(restoreFocus: boolean) {
}
function onSelect(id: string) {
const target = props.mailboxes.find((row) => row.mailbox.id === id && !row.disabled)
if (!target) return
open.value = false
trigger.value?.focus()
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
if (target) emit('move', target)
emit('move', target.mailbox)
}
</script>
@@ -88,6 +93,7 @@ function onSelect(id: string) {
/>
<EditorMenu
v-if="open"
class="bulk-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
@@ -103,4 +109,13 @@ function onSelect(id: string) {
.bulk-move {
position: relative;
}
/* 옮길 메일함 메뉴 — 이름 길이만큼(폭 200 부터 320 까지), 그보다 긴 이름은 줄을 바꾼다 (디자인 원본 Reader15) */
.bulk-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
// 목록 줄을 끄는 동안 마우스를 따라오는 표 — "메일 3통" · "대화 1개" (15단계 — 디자인 원본 ListDrag15 "끌어다 놓는 동안의 모양")
// 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투(메일) · 말풍선(대화) 아이콘. 마우스 오른쪽 아래에 붙는다.
// 브라우저는 끌기를 시작하는 순간 그려진 요소의 모습을 찍는다(setDragImage) — 그래서 메일 화면(MailLayout)에 늘 그려 두되 화면 밖에 두고,
// 수 · 아이콘은 끌기를 시작할 때 useMailDrag 가 이 요소에 바로 적는다(화면 갱신을 기다리면 늦다). 화면 낭독에는 읽히지 않는다(aria-hidden)
import { onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { registerMailDragChip } from '@/composables/useMailDrag'
const hostRef = ref<HTMLElement | null>(null)
onMounted(() => {
registerMailDragChip(hostRef.value)
})
onBeforeUnmount(() => {
registerMailDragChip(null)
})
</script>
<template>
<div
ref="hostRef"
class="drag-chip-host"
aria-hidden="true"
data-kind="messages"
data-mail-drag-chip
>
<span class="drag-chip">
<AppIcon
name="mail"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--messages"
/>
<AppIcon
name="message-square"
:size="14"
:stroke-width="2"
class="drag-chip__icon drag-chip__icon--conversations"
/>
<span data-drag-chip-text />
</span>
</div>
</template>
<style scoped>
/*
* 화면 밖에 그려 둔다 — 보이지 않아야 하지만 그려져 있어야 브라우저가 모습을 찍는다(display: none 이면 찍히지 않는다).
* 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 16 · 아래 22 만큼 비켜 둔다 (디자인 원본 ListDrag15)
*/
.drag-chip-host {
position: fixed;
top: 0;
left: -100rem;
padding: var(--space-22) 0 0 var(--space-16);
pointer-events: none;
}
.drag-chip {
display: inline-flex;
align-items: center;
gap: var(--space-6);
/* 위아래 5 · 오른쪽 10 · 왼쪽 8 (디자인 원본 ListDrag15) */
padding: 0.3125rem var(--space-10) 0.3125rem var(--space-8);
border-radius: var(--radius-md);
background: var(--color-brand-surface);
color: var(--color-surface);
font-size: var(--font-size-label);
font-weight: 600;
line-height: var(--line-height-tight);
white-space: nowrap;
box-shadow: var(--shadow-toast-strong);
}
/* 끄는 것에 맞는 아이콘만 — 메일 줄은 봉투, 대화 줄은 말풍선 */
.drag-chip-host[data-kind='messages'] .drag-chip__icon--conversations,
.drag-chip-host[data-kind='conversations'] .drag-chip__icon--messages {
display: none;
}
</style>
@@ -0,0 +1,422 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { endMailDrag, MAIL_DRAG_TYPE } from '@/composables/useMailDrag'
import type { ConversationSummary, Mailbox, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer, type FakeDataTransfer } from '@/test-utils/drag'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
import MailDragChip from './MailDragChip.vue'
import MessageList from './MessageList.vue'
import MessageTable from './MessageTable.vue'
// 15단계 — 목록 줄을 메일함 패널로 끌어다 놓기 · 목록 [이동 ▾]의 들여 쓴 나무 · 스팸메일함으로 옮기는 세 길 (디자인 원본 ListDrag15 · Reader15)
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-quotes', null, '견적', { parentId: 'project' }),
]
function summary(id: string, mailboxId = 'inbox', partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '박지훈', email: 'jihun.park@example.com' },
to: [],
subject: `견적 조건 문의드립니다 ${id}`,
preview: '',
receivedAt: '2026-11-08T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '박지훈', email: 'jihun.park@example.com' }],
messageCount: 3,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
describe('목록 줄 끌어다 놓기 · 목록 [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
// 끌기 자료는 탭(모듈)에 하나다 — 놓지 않고 끝난 시험의 끌기가 다음 시험에 남지 않게
endMailDrag()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function serve(messages: MessageSummary[], conversations: ConversationSummary[] = []) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 9 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
}
if (request.url === '/conversations') {
return ok({
items: conversations,
pageInfo: { hasNext: false, nextCursor: null, total: conversations.length },
basis: 'index',
})
}
if (request.method === 'PATCH' && request.url === '/messages') {
return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
}
/** 메일 화면처럼 — 메일함 패널 · 목록 · 끄는 동안의 표 */
async function mountScreen(
options: {
boxId?: string
messages?: MessageSummary[]
conversations?: ConversationSummary[]
view?: 'split' | 'list'
} = {},
) {
const boxId = options.boxId ?? 'inbox'
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
serve(messages, options.conversations)
const store = useMailStore()
store.mailboxes = MAILBOXES
store.mailboxesStatus = 'ready'
if (options.conversations) {
store.listSource = { kind: 'conversations', mailboxId: boxId }
store.conversations = options.conversations
} else {
store.currentMailboxId = boxId
store.messages = messages
}
store.pageInfo = { hasNext: false, nextCursor: null, total: options.conversations?.length ?? messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}`)
await router.isReady()
const List = options.view === 'list' ? MessageTable : MessageList
const Screen = defineComponent({
setup: () => () => h('div', [h(MailboxPanel), h(List), h(MailDragChip)]),
})
const wrapper = mount(Screen, { global: { plugins: [pinia, router] }, attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
return { wrapper, store, router }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
function panelRow(wrapper: VueWrapper, name: string, index = 0): DOMWrapper<Element> {
const rows = wrapper.findAll('.mailbox-row').filter((row) => row.get('.mailbox-row__name').text() === name)
const row = rows[index]
if (!row) throw new Error(`${name} 줄이 없다`)
return row
}
function listRow(wrapper: VueWrapper, id: string): DOMWrapper<Element> {
const row = wrapper.find(`[data-row-id="${id}"]`)
if (!row.exists()) throw new Error(`${id} 줄이 없다`)
return row
}
/** 줄을 끌기 시작한다 — 줄 안의 링크에서 시작해도 줄이 받는다 (브라우저는 링크의 주소를 먼저 실어 둔다) */
function startDrag(row: DOMWrapper<Element>): FakeDataTransfer {
const transfer = fakeDataTransfer()
transfer.setData('text/uri-list', 'http://localhost/mail/inbox/m2')
transfer.setData('text/plain', 'http://localhost/mail/inbox/m2')
const origin = row.find('a').exists() ? row.get('a').element : row.element
origin.dispatchEvent(dragEvent('dragstart', transfer))
return transfer
}
/** 패널 줄 위로 들어와 머문다 — dragover 를 받았는가(놓을 수 있는가) */
function hover(row: DOMWrapper<Element>, transfer: FakeDataTransfer): boolean {
row.element.dispatchEvent(dragEvent('dragenter', transfer))
const over = dragEvent('dragover', transfer)
row.element.dispatchEvent(over)
return over.defaultPrevented
}
async function dropOn(row: DOMWrapper<Element>, transfer: FakeDataTransfer) {
hover(row, transfer)
row.element.dispatchEvent(dragEvent('drop', transfer))
await flushPromises()
}
describe('끄는 것과 끄는 동안의 모양', () => {
it('줄을 끌면 우리 끌기 자료만 싣는다 — 링크의 메일 주소는 지우고, 마우스를 따라오는 표는 "메일 1통"', async () => {
const { wrapper } = await mountScreen()
const transfer = startDrag(listRow(wrapper, 'm2'))
expect(transfer.types).toEqual([MAIL_DRAG_TYPE])
expect(transfer.getData(MAIL_DRAG_TYPE)).not.toBe('')
expect(transfer.effectAllowed).toBe('move')
const chip = wrapper.get('[data-mail-drag-chip]')
expect(transfer.dragImage).toBe(chip.element)
expect(chip.text()).toBe('메일 1통')
expect(chip.attributes('data-kind')).toBe('messages')
expect(chip.attributes('aria-hidden')).toBe('true')
// 끈 줄은 흐리게 하지 않는다 — 줄은 그대로
expect(listRow(wrapper, 'm2').classes()).toEqual(['message-row'])
})
it('놓을 수 있는 메일함 위에서는 점선 · 옅은 청록(is-drop-target), 나가면 지운다 — 지금 메일함 · 임시보관함 · 「예약됨」은 받지 않는다', async () => {
const { wrapper, store } = await mountScreen()
store.scheduledCount = 1
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm2'))
const clients = panelRow(wrapper, '거래처')
expect(hover(clients, transfer)).toBe(true)
expect(transfer.dropEffect).toBe('move')
await flushPromises()
expect(clients.classes()).toContain('is-drop-target')
clients.element.dispatchEvent(dragEvent('dragleave', transfer))
await flushPromises()
expect(clients.classes()).not.toContain('is-drop-target')
for (const name of ['받은편지함', '임시보관함']) {
const row = panelRow(wrapper, name)
expect(hover(row, transfer)).toBe(false)
await flushPromises()
expect(row.classes()).not.toContain('is-drop-target')
}
const scheduled = wrapper.get('[data-scheduled-mailbox]')
const over = dragEvent('dragover', transfer)
scheduled.element.dispatchEvent(over)
expect(over.defaultPrevented).toBe(false)
// 휴지통은 놓을 수 있다 — [삭제]와 같다
expect(hover(panelRow(wrapper, '휴지통'), transfer)).toBe(true)
})
it('파일 끌기는 메일함 패널이 받지 않는다 (쓰기 화면의 첨부 놓기와 겹치지 않게)', async () => {
const { wrapper } = await mountScreen()
const files = fakeDataTransfer([new File(['x'], '보고서.pdf')])
expect(hover(panelRow(wrapper, '거래처'), files)).toBe(false)
})
it('보내기를 기다리는 메일의 줄은 끌 수 없다 — draggable 이 아니고 링크를 끌어도 막는다', async () => {
const held = summary('m9', 'sent', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-10T00:00:00Z' } })
const { wrapper } = await mountScreen({ boxId: 'sent', messages: [summary('m1', 'sent'), held] })
expect(listRow(wrapper, 'm9').attributes('draggable')).toBe('false')
expect(listRow(wrapper, 'm1').attributes('draggable')).toBe('true')
const transfer = fakeDataTransfer()
const start = dragEvent('dragstart', transfer)
listRow(wrapper, 'm9').get('a').element.dispatchEvent(start)
expect(start.defaultPrevented).toBe(true)
expect(transfer.types).toEqual([])
})
})
describe('놓으면', () => {
it('내 메일함에 놓으면 [이동 ▾]와 같다 — "메일 1통을 거래처로 옮겼습니다." + [되돌리기]', async () => {
const { wrapper, store } = await mountScreen()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm3'])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처로 옮겼습니다.')
expect(toast?.action && 'label' in toast.action ? toast.action.label : null).toBe('되돌리기')
expect(panelRow(wrapper, '거래처').classes()).not.toContain('is-drop-target')
})
it('같은 이름의 메일함이 또 있으면 위 메일함 이름을 붙인다 — "메일 1통을 프로젝트 › 견적으로 옮겼습니다."', async () => {
const { wrapper } = await mountScreen()
await dropOn(panelRow(wrapper, '견적', 1), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project-quotes' }])
expect(toastTexts()).toEqual(['메일 1통을 프로젝트 › 견적으로 옮겼습니다.'])
})
it('고른 줄을 끌면 고른 것 모두 — 표는 "메일 2통", 휴지통에 놓으면 [삭제]와 같고 고른 것을 푼다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
store.toggleCheck('m3')
await flushPromises()
const transfer = startDrag(listRow(wrapper, 'm3'))
expect(wrapper.get('[data-mail-drag-chip]').text()).toBe('메일 2통')
await dropOn(panelRow(wrapper, '휴지통'), transfer)
expect(patches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'trash' }])
expect(toastTexts()).toEqual(['메일 2통을 휴지통으로 옮겼습니다.'])
expect(store.checkedCount).toBe(0)
})
it('고르지 않은 줄을 끌면 그 줄만 — 고른 것은 그대로 둔다', async () => {
const { wrapper, store } = await mountScreen()
store.toggleCheck('m1')
await flushPromises()
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
expect([...store.checkedIds]).toEqual(['m1'])
})
it('스팸메일함에 놓으면 [스팸 신고]와 같다 — 스팸 표시를 함께, [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountScreen({ view: 'list' })
await dropOn(panelRow(wrapper, '스팸메일함'), startDrag(listRow(wrapper, 'm2')))
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'junk', isJunk: true }])
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: ['m2'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함에서 꺼내 놓으면 [스팸 아님]과 같다 — 휴지통에 놓으면 표시는 그대로', async () => {
const { wrapper } = await mountScreen({ boxId: 'junk', messages: [summary('m1', 'junk'), summary('m2', 'junk')] })
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm1')))
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다.'])
await dropOn(panelRow(wrapper, '휴지통'), startDrag(listRow(wrapper, 'm2')))
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'trash' })
})
it('대화로 묶은 목록의 줄은 그 대화에서 이 메일함에 든 메일 — 표는 "대화 1개", 알림은 "받은편지함에 있는 메일 2통을 …"', async () => {
const rows = [
conversation('c1', ['m6', 'm5']),
conversation('c2', ['m4', 'm3'], { pendingSendIds: ['m3'] }),
conversation('c3', ['m2'], { pendingSendIds: ['m2'] }),
]
const { wrapper } = await mountScreen({ conversations: rows })
// 보내기를 기다리는 메일뿐인 줄은 끌 수 없다
expect(listRow(wrapper, 'c3').attributes('draggable')).toBe('false')
const transfer = startDrag(listRow(wrapper, 'c1'))
const chip = wrapper.get('[data-mail-drag-chip]')
expect(chip.text()).toBe('대화 1개')
expect(chip.attributes('data-kind')).toBe('conversations')
await dropOn(panelRow(wrapper, '견적'), transfer)
expect(patches()).toEqual([{ ids: ['m6', 'm5'], mailboxId: 'quotes' }])
expect(toastTexts()).toEqual(['받은편지함에 있는 메일 2통을 거래처 › 견적으로 옮겼습니다.'])
// 붙잡힌 메일은 빼고 옮긴다 — [이동 ▾]와 같다
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'c2')))
expect(patches().at(-1)).toEqual({ ids: ['m4'], mailboxId: 'clients' })
})
it('다른 탭 · 바깥에서 끌어 온 같은 종류의 자료는 받지 않는다 (이 탭에서 시작한 끌기만)', async () => {
const { wrapper } = await mountScreen()
const foreign = fakeDataTransfer()
foreign.setData(MAIL_DRAG_TYPE, 'other-tab')
expect(hover(panelRow(wrapper, '거래처'), foreign)).toBe(false)
panelRow(wrapper, '거래처').element.dispatchEvent(dragEvent('drop', foreign))
await flushPromises()
expect(patches()).toEqual([])
})
})
describe('목록 [이동 ▾] — 읽기 화면과 같은 나무 · 같은 규칙', () => {
async function openMoveMenu(wrapper: VueWrapper) {
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
it('메일함 패널처럼 들여 쓰고 화면 낭독은 위 메일함부터 — 고른 메일이 든 메일함에 하위가 있으면 흐린 줄 "고른 메일이 있는 메일함"', async () => {
const { wrapper, store } = await mountScreen({
boxId: 'clients',
messages: [summary('m1', 'clients'), summary('m2', 'clients')],
view: 'list',
})
store.toggleCheck('m1')
await flushPromises()
const menu = await openMoveMenu(wrapper)
const items = menu.findAll('[role="menuitem"]')
expect(items.map((item) => item.attributes('aria-label') ?? item.find('.menu__label').text())).toEqual([
'받은편지함',
'보낸편지함',
'스팸메일함',
'거래처',
'거래처 안의 견적',
'프로젝트',
'프로젝트 안의 견적',
])
const here = items[3]
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('고른 메일이 있는 메일함')
expect(menu.findAll('hr')).toHaveLength(1)
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 · 알림의 메일함 이름은 같은 이름이 있을 때만 위 메일함까지', async () => {
const { wrapper, store } = await mountScreen({ view: 'list' })
store.toggleCheck('m1')
await flushPromises()
let menu = await openMoveMenu(wrapper)
await menu.findAll('[role="menuitem"]').find((item) => item.text() === '스팸메일함')?.trigger('click')
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
expect(toastTexts()).toEqual(['메일 1통을 스팸메일함으로 옮겼습니다.'])
store.toggleCheck('m2')
await flushPromises()
menu = await openMoveMenu(wrapper)
await menu
.findAll('[role="menuitem"]')
.find((item) => item.attributes('aria-label') === '거래처 안의 견적')
?.trigger('click')
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'quotes' })
expect(toastTexts().at(-1)).toBe('메일 1통을 거래처 › 견적으로 옮겼습니다.')
})
})
})
@@ -4,6 +4,11 @@
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
// 15단계(디자인 원본 Reader15 "2"):
// - collapsible: 읽기 칸이 좁으면 이름을 숨기고 아이콘만(40×44) 둘 수 있는 단추 — 숨기는 때는 읽기 화면이 정한다(MessageReader 의 컨테이너 쿼리).
// 이름은 숨겨도 화면 낭독(aria-label)과 마우스를 올리면 뜨는 글(title)로 읽힌다
// - menu: 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때도 ▾ 는 남긴다 — [이동 ▾])
import { ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
@@ -26,6 +31,10 @@ interface Props {
/** 까닭 · 설명 글의 id — aria-describedby */
describedby?: string
iconStrokeWidth?: number
/** 좁은 읽기 칸에서 아이콘만 둘 수 있다 (15단계) — 이름은 aria-label · title 로도 */
collapsible?: boolean
/** 메뉴를 여는 단추 — 이름 뒤 ▾ */
menu?: boolean
}
interface Emits {
@@ -41,33 +50,46 @@ const props = withDefaults(defineProps<Props>(), {
busy: false,
describedby: undefined,
iconStrokeWidth: 1.75,
collapsible: false,
menu: false,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onButtonClick(event: MouseEvent) {
if (props.unavailable || props.busy) return
emit('click', event)
}
defineExpose({
/** 단추 요소(단추일 때) — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
element: buttonRef,
})
</script>
<template>
<RouterLink
v-if="to"
:to="to"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
>
<AppIcon
:name="icon"
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</RouterLink>
<a
v-else-if="href"
:href="href"
:class="['tool-button', `tool-button--${tone}`]"
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="emit('click', $event)"
>
<AppIcon
@@ -75,16 +97,23 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
</a>
<button
v-else
ref="buttonRef"
type="button"
:class="['tool-button', `tool-button--${tone}`]"
:class="[
'tool-button',
`tool-button--${tone}`,
{ 'tool-button--collapsible': collapsible, 'tool-button--menu': menu },
]"
:disabled="disabled"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
:aria-describedby="describedby"
:aria-label="collapsible ? label : undefined"
:title="collapsible ? label : undefined"
@click="onButtonClick"
>
<AppIcon
@@ -92,7 +121,14 @@ function onButtonClick(event: MouseEvent) {
:size="15"
:stroke-width="iconStrokeWidth"
/>
<span>{{ label }}</span>
<span class="tool-button__label">{{ label }}</span>
<AppIcon
v-if="menu"
name="chevron-down"
:size="12"
:stroke-width="2"
class="tool-button__chevron"
/>
</button>
</template>
@@ -125,6 +161,13 @@ function onButtonClick(event: MouseEvent) {
cursor: progress;
}
/* 메뉴를 여는 단추 — ▾ 쪽 안쪽을 줄인다 (디자인 원본 Reader15 [이동 ▾] — 왼쪽 10 · 오른쪽 8) */
.tool-button--menu {
justify-content: center;
min-width: var(--size-icon-button);
padding-right: var(--space-8);
}
.tool-button--accent {
color: var(--color-brand-strong);
font-weight: 600;
@@ -1,8 +1,10 @@
<script setup lang="ts">
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// 메일함 지우기 확인 (디자인 원본 MailboxManage · Mailbox15, 공용 모양 ModalDialog — role=alertdialog)
// - "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
// - 하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계 — 지금까지의 "지울 수 없습니다" 창을 대신한다):
// "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" · "하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 …"
// (메일이 하나도 없으면 "하위 메일함까지 모두 비어 있습니다. …"). 하위 메일함 수 · 메일 수는 모든 깊이를 센다(부르는 쪽 — MailboxDialogs)
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
import { computed } from 'vue'
@@ -14,10 +16,10 @@ import { formatCount } from '@/shared/utils/format'
interface Props {
open: boolean
mailboxName: string
/** 안의 메일 수 */
/** 휴지통으로 옮길 메일 수 — 하위 메일함이 있으면 하위까지 더한 수 */
messageCount: number
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
hasChildren: boolean
/** 모든 깊이의 하위 메일함 수 — 있으면 하위까지 지울지 묻는다 (15단계) */
childCount?: number
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
blocked?: boolean
}
@@ -28,15 +30,22 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
childCount: 0,
blocked: false,
})
const emit = defineEmits<Emits>()
const title = computed(() =>
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
props.childCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeTitle(props.mailboxName, formatCount(props.childCount))
: MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
)
const message = computed(() => {
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
if (props.childCount > 0) {
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteTreeMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteTreeEmptyMessage
}
return props.messageCount > 0
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
@@ -56,35 +65,23 @@ const message = computed(() => {
>
<template #actions>
<button
v-if="hasChildren"
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
<template v-else>
<button
id="mailbox-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="blocked"
@click="emit('confirm')"
>
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
</button>
<button
id="mailbox-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ MAILBOX_DIALOG_TEXT.cancel }}
</button>
</template>
</template>
</ModalDialog>
</template>
+86 -27
View File
@@ -1,21 +1,24 @@
<script setup lang="ts">
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · Mailbox15 · useMailboxActions)
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 옮기기(15단계): 옮길 곳 창. 같은 이름(MAIL_009 — 그사이 다른 창에서 만듦)은 목록 아래에 보이고 창을 둔다. 메일함 · 옮길 곳이 사라졌으면(RES_001) ·
// 옮길 수 없는 자리였으면(VAL_001 — 그사이 나무가 바뀜) 알림(인터셉터)을 보고 목록을 다시 맞춘다 — RES_001 은 창을 닫는다. 지금 있는 곳은 고를 수 없다
// - 지우기: 하위 메일함이 있으면 하위까지 지울지 묻는다(15단계 — 하위 메일함 수 · 메일 수는 모든 깊이). 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
// - 비우기: 확인하면 닫고 비우기를 시작한다
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
import MailboxMoveDialog from '@/components/mail/MailboxMoveDialog.vue'
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { notifyApiError } from '@/composables/useApi'
import { useMailboxActions } from '@/composables/useMailboxActions'
import { useMailboxActions, type MailboxNameResult } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import { hasChildMailbox } from '@/shared/utils/mailbox'
import { descendantMailboxes, hasChildMailbox, mailboxMoveTargets } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -34,6 +37,8 @@ const actions = useMailboxActions()
const pending = ref<boolean>(false)
const nameError = ref<string | null>(null)
/** 옮기기 창 목록 아래의 거절 (MAIL_009) */
const moveError = ref<string | null>(null)
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
let unmounted = false
@@ -49,6 +54,16 @@ const target = computed<Mailbox | null>(() => {
})
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
/** 모든 깊이의 하위 메일함 — 지우기 확인 창의 "하위 메일함 2개" · 메일 수 (15단계) */
const descendants = computed(() => (target.value ? descendantMailboxes(mailStore.mailboxes, target.value.id) : []))
/** 지우면 휴지통으로 갈 메일 수 — 하위 메일함까지 더한다 */
const treeMessageCount = computed(() =>
[target.value, ...descendants.value].reduce((sum, mailbox) => sum + Math.max(0, mailbox?.totalCount ?? 0), 0),
)
/** 옮길 곳 — 옮기기 창이 열려 있을 때만 */
const moveTargets = computed(() =>
props.request?.kind === 'move' && target.value ? mailboxMoveTargets(mailStore.mailboxes, target.value) : [],
)
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
@@ -56,9 +71,44 @@ const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', t
function close() {
pending.value = false
nameError.value = null
moveError.value = null
emit('close')
}
/**
* 이름 · 옮기기 창의 결과를 받는다 — 칸 안에 보일 실패(inline)는 보여 주고 창을 둔다, 그사이 사라졌으면(RES_001) 닫고 목록을 다시 맞춘다.
* 기다리는 사이 화면이 사라졌으면 칸 안에 보일 실패를 알림으로, 다른 창으로 바뀌었으면 건드리지 않는다
*/
function settleResult(
request: MailboxDialogRequest,
result: MailboxNameResult | null,
showInline: (message: string) => void,
) {
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
showInline(result.error.message)
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
return
}
// 옮길 수 없는 자리(VAL_001 — 그사이 나무가 바뀜)는 알림을 본 뒤 바뀐 나무로 다시 고를 수 있게 목록만 다시 맞춘다
if (request.kind === 'move' && result.error.code === 'VAL_001') void mailStore.loadMailboxes({ background: true })
}
async function onSubmitName(name: string) {
const request = props.request
if (!request || pending.value) return
@@ -73,34 +123,31 @@ async function onSubmitName(name: string) {
: request.kind === 'create'
? await actions.create(name, request.parent?.id ?? null)
: null
if (unmounted) {
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
return
}
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
if (props.request !== request) return
pending.value = false
if (!result || result.status !== 'failed') {
close()
return
}
if (result.inline) {
nameError.value = result.error.message
return
}
if (result.error.code === 'RES_001') {
void mailStore.loadMailboxes({ background: true })
close()
}
settleResult(request, result, (message) => {
nameError.value = message
})
}
async function onSubmitMove(parentId: string | null) {
const request = props.request
const mailbox = target.value
if (!request || request.kind !== 'move' || !mailbox || pending.value) return
pending.value = true
moveError.value = null
const result = await actions.move(mailbox, parentId)
settleResult(request, result, (message) => {
moveError.value = message
})
}
function onConfirmDelete() {
const mailbox = target.value
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
if (!mailbox || deleteBlocked.value) return
// 하위 메일함이 있으면 확인 창이 하위까지 지운다고 물었다 — 그때만 하위까지 지운다 (15단계)
const withChildren = hasChildren.value
close()
void actions.remove(mailbox)
void actions.remove(mailbox, { withChildren })
}
function onConfirmEmpty() {
@@ -115,6 +162,7 @@ watch(
() => props.request,
() => {
nameError.value = null
moveError.value = null
},
)
@@ -135,11 +183,22 @@ onBeforeUnmount(() => {
@cancel="close"
@clear-error="nameError = null"
/>
<MailboxMoveDialog
:open="request?.kind === 'move'"
:mailbox-name="target?.name ?? ''"
:has-children="hasChildren"
:targets="moveTargets"
:pending="pending"
:error="moveError"
@submit="onSubmitMove"
@cancel="close"
@clear-error="moveError = null"
/>
<MailboxDeleteDialog
:open="request?.kind === 'delete'"
:mailbox-name="target?.name ?? ''"
:message-count="target?.totalCount ?? 0"
:has-children="hasChildren"
:message-count="treeMessageCount"
:child-count="descendants.length"
:blocked="deleteBlocked"
@confirm="onConfirmDelete"
@cancel="close"
@@ -0,0 +1,371 @@
<script setup lang="ts">
// 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때", 공용 모양 ModalDialog — 폭 400 medium)
// 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") →
// 옮길 곳 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56): 맨 위 · 받은편지함(과 그 아래) · "내 메일함" 묶음 → [옮기기] [취소]
// - 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없다. 고른 줄은 청록 바탕 · 굵은 글자 · 오른쪽 체크
// - 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳 · 같은 이름의 메일함이 있는 곳
// (대소문자 무시 — 서버에 묻기 전에 화면이 하는 검사). 맨 위 줄은 평소 "다른 메일함 안에 두지 않습니다"
// - 줄은 radiogroup 의 radio — 화살표 · Home · End 로 줄을 옮기면(꺼진 줄에도 초점은 간다 — 까닭을 읽게) 고를 수 있는 줄은 바로 고른다.
// Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄의 이름은 위 메일함부터 읽는다("거래처 안의 2025")
// - 그사이 같은 이름이 생겨 서버가 거절하면(MAIL_009) 목록 아래 빨간 사전 문구 — 알림 없이 창을 둔다. 다른 곳을 고르면 지운다(clear-error)
// - 옮기는 동안(pending)은 [옮기기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게
// 고정 id — #mailbox-move-dialog · #mailbox-move-title · #mailbox-move-message · #mailbox-move-error · #mailbox-move-confirm · #mailbox-move-cancel
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import type { IconName } from '@/shared/constants/icons'
import { MAILBOX_MOVE_TEXT, MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import { mailboxIcon, mailboxSpokenName, type MailboxMoveTarget } from '@/shared/utils/mailbox'
interface Props {
open: boolean
/** 옮기는 메일함의 이름 */
mailboxName: string
/** 하위 메일함이 있다 — 제목 아래 글이 바뀐다 */
hasChildren: boolean
/** 옮길 곳 (mailbox.ts 의 mailboxMoveTargets) */
targets: readonly MailboxMoveTarget[]
/** 옮기는 중 — [옮기기]를 다시 누를 수 없다 */
pending?: boolean
/** 서버가 거절한 까닭(사전 문구 — MAIL_009). 다른 곳을 고르면 부모가 지운다(clear-error) */
error?: string | null
}
interface Emits {
/** parentId — 새 상위, 맨 위면 null */
(e: 'submit', parentId: string | null): void
(e: 'cancel'): void
(e: 'clear-error'): void
}
const props = withDefaults(defineProps<Props>(), {
pending: false,
error: null,
})
const emit = defineEmits<Emits>()
/** 들여쓰기는 3단계까지 (메일함 패널과 같다) */
const MAX_INDENT_DEPTH = 3
/** 맨 위 줄의 열쇠 — 메일함 id 는 비지 않는다 */
const TOP_KEY = ''
const ERROR_ID = 'mailbox-move-error'
interface MoveRow {
key: string
parentId: string | null
icon: IconName
name: string
/** 화면 낭독 이름 — 들여 쓴 줄은 위 메일함부터 */
spokenName: string
depth: number
/** 이름 오른쪽 작은 글 — 고를 수 없는 까닭 · 맨 위 줄의 설명 */
note: string | null
pickable: boolean
/** 이 줄 앞에 "내 메일함" 묶음 이름을 둔다 */
headingBefore: boolean
}
/** 고를 수 없는 까닭의 글 — 옮기는 메일함 자신 · 하위는 글이 없다 */
const BLOCK_NOTES = {
self: null,
current: MAILBOX_MOVE_TEXT.current,
sameName: MAILBOX_MOVE_TEXT.sameName,
} as const
const listRef = ref<HTMLElement | null>(null)
/** 고른 줄 — 처음에는 없다 */
const chosenKey = ref<string | null>(null)
/** 탭으로 들어올 줄 (로빙 tabindex) — 고른 줄, 없으면 첫 고를 수 있는 줄 */
const focusKey = ref<string>(TOP_KEY)
const message = computed(() => (props.hasChildren ? MAILBOX_MOVE_TEXT.messageWithChildren : MAILBOX_MOVE_TEXT.message))
const rows = computed<MoveRow[]>(() => {
let headingShown = false
return props.targets.map((target) => {
const entry = target.entry
const headingBefore = target.group === 'mine' && !headingShown
if (headingBefore) headingShown = true
const note = target.block ? BLOCK_NOTES[target.block] : entry ? null : MAILBOX_MOVE_TEXT.topNote
return {
key: target.parentId ?? TOP_KEY,
parentId: target.parentId,
icon: entry ? mailboxIcon(entry.mailbox) : 'arrow-up',
name: entry ? entry.mailbox.name : MAILBOX_MOVE_TEXT.top,
spokenName: entry ? mailboxSpokenName(entry) : MAILBOX_MOVE_TEXT.top,
depth: entry ? Math.min(entry.depth, MAX_INDENT_DEPTH) : 0,
note,
pickable: target.block === null,
headingBefore,
}
})
})
/** 고른 줄 — 목록이 바뀌어(다시 받음) 고를 수 없게 됐으면 고르지 않은 것으로 본다 */
const chosen = computed<MoveRow | null>(() => rows.value.find((row) => row.key === chosenKey.value && row.pickable) ?? null)
/** 줄마다 겹치지 않는 이름 — 맨 위 줄과 메일함 줄을 가른다 (메일함 id 가 "top" 이어도 겹치지 않게) */
function rowName(row: MoveRow): string {
return row.parentId === null ? 'top' : `m-${row.key}`
}
function noteId(row: MoveRow): string | undefined {
return row.note ? `mailbox-move-note-${rowName(row)}` : undefined
}
function rowElement(key: string): HTMLElement | null {
const list = listRef.value
if (!list) return null
return [...list.querySelectorAll<HTMLElement>('[data-move-row]')].find((element) => element.dataset.moveRow === key) ?? null
}
function choose(row: MoveRow) {
focusKey.value = row.key
if (!row.pickable || props.pending) return
if (chosenKey.value !== row.key && props.error) emit('clear-error')
chosenKey.value = row.key
}
/** 화살표 · Home · End — 줄을 옮기고 고를 수 있으면 고른다 (꺼진 줄에도 초점은 간다) */
function moveFocus(target: MoveRow | undefined) {
if (!target) return
choose(target)
void nextTick(() => rowElement(target.key)?.focus())
}
function onListKeydown(event: KeyboardEvent) {
const list = rows.value
const index = list.findIndex((row) => row.key === focusKey.value)
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault()
moveFocus(list[(index + 1) % list.length])
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault()
moveFocus(list[(index - 1 + list.length) % list.length])
} else if (event.key === 'Home') {
event.preventDefault()
moveFocus(list[0])
} else if (event.key === 'End') {
event.preventDefault()
moveFocus(list[list.length - 1])
} else if (event.key === 'Enter') {
// Enter 는 [옮기기]와 같다 (줄 단추의 눌림으로 넘기지 않는다)
event.preventDefault()
submit()
}
}
function submit() {
const row = chosen.value
if (!row || props.pending) return
emit('submit', row.parentId)
}
/** 처음 초점 — 고를 수 있는 첫 줄 (없으면 첫 줄) */
function initialFocusKey(): string {
return (rows.value.find((row) => row.pickable) ?? rows.value[0])?.key ?? TOP_KEY
}
// 열 때마다 아무것도 고르지 않은 채로
watch(
() => props.open,
(open) => {
if (!open) return
chosenKey.value = null
focusKey.value = initialFocusKey()
},
{ immediate: true },
)
</script>
<template>
<ModalDialog
id="mailbox-move-dialog"
:open="open"
:title="MAILBOX_MOVE_TEXT.title(mailboxName)"
:message="message"
title-id="mailbox-move-title"
message-id="mailbox-move-message"
medium
@cancel="emit('cancel')"
>
<div
ref="listRef"
role="radiogroup"
class="mailbox-move__list"
:aria-label="MAILBOX_MOVE_TEXT.listLabel"
:aria-describedby="error ? ERROR_ID : undefined"
@keydown="onListKeydown"
>
<template
v-for="row in rows"
:key="rowName(row)"
>
<p
v-if="row.headingBefore"
class="mailbox-move__heading"
>
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
</p>
<button
type="button"
role="radio"
:class="['mailbox-move__row', `mailbox-move__row--depth-${row.depth}`]"
:aria-checked="chosen?.key === row.key ? 'true' : 'false'"
:aria-disabled="row.pickable ? undefined : 'true'"
:aria-label="row.spokenName"
:aria-describedby="noteId(row)"
:tabindex="row.key === focusKey ? 0 : -1"
:autofocus="row.key === focusKey"
:data-move-row="row.key"
@click="choose(row)"
>
<AppIcon
:name="row.icon"
class="mailbox-move__icon"
/>
<span class="mailbox-move__name">{{ row.name }}</span>
<span
v-if="row.note"
:id="noteId(row)"
class="mailbox-move__note"
>{{ row.note }}</span>
<AppIcon
v-if="chosen?.key === row.key"
name="check"
:stroke-width="2"
class="mailbox-move__check"
/>
</button>
</template>
</div>
<p
v-if="error"
:id="ERROR_ID"
class="form-message form-message--error"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<template #actions>
<button
id="mailbox-move-confirm"
type="button"
class="modal-dialog__action"
:disabled="!chosen"
:aria-disabled="pending ? 'true' : undefined"
:aria-busy="pending"
@click="submit"
>
{{ MAILBOX_MOVE_TEXT.confirm }}
</button>
<button
id="mailbox-move-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAILBOX_MOVE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 옮길 곳 목록 — 테두리 칸 · 안쪽 4 · 줄 사이 1 (디자인 원본 Mailbox15). 메일함이 많으면 칸 안에서 스크롤한다 */
.mailbox-move__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: min(20rem, 50vh);
overflow-y: auto;
margin-top: var(--space-4);
padding: var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
/* "내 메일함" 묶음 이름 — 패널과 같은 12 굵게 회색 */
.mailbox-move__heading {
margin: var(--space-8) var(--space-10) var(--space-2);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
/* 줄 40 — 메일함 패널의 줄과 같은 높이 · 들여쓰기 */
.mailbox-move__row {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-strong);
font: inherit;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.mailbox-move__row--depth-1 {
padding-left: var(--space-28);
}
.mailbox-move__row--depth-2 {
padding-left: var(--space-44);
}
.mailbox-move__row--depth-3 {
padding-left: var(--space-56);
}
.mailbox-move__row:hover:not([aria-disabled='true']):not([aria-checked='true']) {
background: var(--color-surface-muted);
}
/* 고른 줄 — 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 */
.mailbox-move__row[aria-checked='true'] {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
/* 고를 수 없는 줄 — 이름 · 아이콘만 흐리게 (까닭 글은 그대로) */
.mailbox-move__row[aria-disabled='true'] {
cursor: default;
}
.mailbox-move__row[aria-disabled='true'] .mailbox-move__icon,
.mailbox-move__row[aria-disabled='true'] .mailbox-move__name {
opacity: 0.5;
}
.mailbox-move__icon,
.mailbox-move__check {
flex: 0 0 auto;
}
.mailbox-move__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-move__note {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 400;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,437 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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 { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPanel from './MailboxPanel.vue'
// 메일함 패널 — 15단계 메일함 옮기기 · 하위 메일함까지 지우기 (디자인 원본 Mailbox15 · 계약 2절 "메일함 관리")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
}
/** 보드의 예 — 거래처 안에 2025(그 안에 정산), 보관 안에도 2025, 프로젝트 안에 견적 */
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
box('junk', 'junk', '스팸메일함', { totalCount: 5 }),
box('trash', 'trash', '휴지통', { totalCount: 21 }),
box('c', null, '거래처', { totalCount: 1 }),
box('c25', null, '2025', { parentId: 'c', totalCount: 4 }),
box('c25s', null, '정산', { parentId: 'c25', totalCount: 35 }),
box('b', null, '보관'),
box('b25', null, '2025', { parentId: 'b' }),
box('p', null, '프로젝트', { totalCount: 2 }),
box('pq', null, '견적', { parentId: 'p' }),
]
interface Deferred {
promise: Promise<MockResult>
resolve: (result: MockResult) => void
}
function deferred(): Deferred {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
type Reply = MockResult | Deferred
describe('MailboxPanel — 메일함 옮기기 · 하위까지 지우기 (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
let replies: Map<string, Reply[]>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
replies = new Map()
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function reply(key: string, ...results: Reply[]) {
replies.set(key, results)
}
async function mountPanel(mailboxes: Mailbox[] = MAILBOXES, viewing = '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: 12 } })
}
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(`/mail/${viewing}`)
await router.isReady()
store.currentMailboxId = viewing
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router, store }
}
/** 패널의 줄 — 같은 이름이 둘이면 몇 번째인지 */
const row = (wrapper: VueWrapper, name: string, nth = 0) => {
const found = wrapper.findAll('.mailbox-row').filter((item) => item.get('.mailbox-row__name').text() === name)[nth]
if (!found) throw new Error(`${name} 줄이 없다`)
return found
}
async function choose(wrapper: VueWrapper, name: string, label: string, nth = 0) {
await row(wrapper, name, nth).get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').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)
const moveRows = (wrapper: VueWrapper) => wrapper.findAll('[data-move-row]')
const moveRow = (wrapper: VueWrapper, key: string) => wrapper.get(`[data-move-row="${key}"]`)
const panelNames = (wrapper: VueWrapper) =>
wrapper.findAll('.mailbox-tree__list')[1]?.findAll('.mailbox-row__name').map((name) => name.text())
/** 알림의 단추를 누른다 — 알림을 닫고 그 단추의 일을 한다 (ToastHost 와 같은 길 — takeAction) */
function runToastAction(index = 0) {
const toast = useToastStore().toasts[index]
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
}
describe('옮기기', () => {
it('⋯ 「옮기기」 → 창 — 맨 위 · 받은편지함 · 내 메일함 차례, 고를 수 없는 줄은 흐리게 · 까닭, 처음에는 아무것도 고르지 않고 [옮기기]가 꺼져 있다', async () => {
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(wrapper.get('#mailbox-move-title').text()).toBe('‘2025’ 메일함을 어디로 옮길까요?')
expect(wrapper.get('#mailbox-move-message').text()).toBe('하위 메일함과 안의 메일도 함께 옮깁니다.')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe('옮길 곳')
expect(wrapper.get('.mailbox-move__heading').text()).toBe('내 메일함')
expect(
moveRows(wrapper).map((item) => [
item.get('.mailbox-move__name').text(),
item.find('.mailbox-move__note').exists() ? item.get('.mailbox-move__note').text() : null,
item.attributes('aria-disabled') ?? null,
]),
).toEqual([
['맨 위', '다른 메일함 안에 두지 않습니다', null],
['받은편지함', null, null],
['거래처', '지금 있는 곳', 'true'],
['2025', null, 'true'],
['정산', null, 'true'],
['보관', '같은 이름의 메일함이 있습니다', 'true'],
['2025', null, null],
['프로젝트', null, null],
['견적', null, null],
])
// 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 옮길 곳이 아니다
expect(moveRows(wrapper).some((item) => ['sent', 'drafts', 'junk', 'trash'].includes(item.attributes('data-move-row') ?? ''))).toBe(
false,
)
// 들여 쓴 줄은 위 메일함부터 읽는다 · 까닭 글은 설명으로
expect(moveRow(wrapper, 'b25').attributes('aria-label')).toBe('보관 안의 2025')
expect(moveRow(wrapper, 'c25s').attributes('aria-label')).toBe('거래처 안의 2025 안의 정산')
expect(moveRow(wrapper, 'c25s').classes()).toContain('mailbox-move__row--depth-2')
expect(moveRow(wrapper, 'c').attributes('aria-describedby')).toBe('mailbox-move-note-m-c')
expect(moveRows(wrapper).every((item) => item.attributes('aria-checked') === 'false')).toBe(true)
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
// 처음 초점은 고를 수 있는 첫 줄 — 로빙 tabindex
expect(document.activeElement).toBe(moveRow(wrapper, '').element)
expect(moveRows(wrapper).filter((item) => item.attributes('tabindex') === '0')).toHaveLength(1)
})
it('줄을 눌러 고르고 [옮기기] → PATCH { parentId } · 패널에 바로 · "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기]는 원래 자리로', async () => {
const moved = box('c25', null, '2025', { parentId: 'p', totalCount: 4 })
reply('PATCH /mailboxes/c25', ok(moved), ok({ ...moved, parentId: 'c' }))
const { wrapper, store } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
// 고를 수 없는 줄은 눌러도 고르지 않는다
await moveRow(wrapper, 'b').trigger('click')
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
await moveRow(wrapper, 'p').trigger('click')
expect(moveRow(wrapper, 'p').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'p').find('.mailbox-move__check').exists()).toBe(true)
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(panelNames(wrapper)).toEqual(['거래처', '보관', '2025', '프로젝트', '2025', '정산', '견적'])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.'])
expect(useToastStore().toasts[0]?.action?.label).toBe('되돌리기')
runToastAction()
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }, { parentId: 'c' }])
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.'])
expect(store.mailboxes.find((mailbox) => mailbox.id === 'c25')?.parentId).toBe('c')
})
it('맨 위로는 parentId: null — "다른 메일함 밖으로", 받은편지함 안으로는 따옴표 없이', async () => {
reply(
'PATCH /mailboxes/pq',
ok(box('pq', null, '견적')),
ok(box('pq', null, '견적', { parentId: 'inbox' })),
)
const { wrapper } = await mountPanel()
await choose(wrapper, '견적', '옮기기')
// 하위 메일함이 없으면 글이 짧다
expect(wrapper.get('#mailbox-move-message').text()).toBe('안의 메일도 함께 옮깁니다.')
await moveRow(wrapper, '').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/pq').map((request) => request.data)).toEqual([{ parentId: null }])
expect(toastTexts()).toEqual(['‘견적’ 메일함을 다른 메일함 밖으로 옮겼습니다.'])
await choose(wrapper, '견적', '옮기기')
// 맨 위에 있는 메일함 — 맨 위 줄이 지금 있는 곳
expect(moveRow(wrapper, '').get('.mailbox-move__note').text()).toBe('지금 있는 곳')
await moveRow(wrapper, 'inbox').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toContain('‘견적’ 메일함을 받은편지함 안으로 옮겼습니다.')
})
it('키보드 — 화살표로 줄을 옮기며 고른다(꺼진 줄에도 초점은 간다 — 고른 것은 그대로) · Enter 는 [옮기기]', async () => {
reply('PATCH /mailboxes/c25', ok(box('c25', null, '2025', { parentId: 'inbox' })))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'inbox').element)
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
expect(moveRow(wrapper, 'inbox').attributes('tabindex')).toBe('0')
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'c').element)
expect(moveRow(wrapper, 'c').attributes('aria-checked')).toBe('false')
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'c').trigger('keydown', { key: 'End' })
await flushPromises()
expect(document.activeElement).toBe(moveRow(wrapper, 'pq').element)
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await flushPromises()
expect(moveRow(wrapper, '').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowUp' })
await flushPromises()
// 처음에서 위로 가면 끝으로 돈다
expect(moveRow(wrapper, 'pq').attributes('aria-checked')).toBe('true')
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'inbox' }])
})
it('그사이 같은 이름(409 MAIL_009) — 알림 없이 목록 아래 빨간 사전 문구 · 창은 그대로, 다른 곳을 고르면 사라진다', async () => {
reply('PATCH /mailboxes/c25', fail(409, 'MAIL_009'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
const error = wrapper.get('#mailbox-move-error')
expect(error.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
expect(error.attributes('role')).toBe('alert')
expect(wrapper.get('[role="radiogroup"]').attributes('aria-describedby')).toBe('mailbox-move-error')
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
expect(useToastStore().toasts).toEqual([])
await moveRow(wrapper, 'pq').trigger('click')
expect(wrapper.find('#mailbox-move-error').exists()).toBe(false)
})
it('메일함이 그사이 사라졌으면(404 RES_001) — 알림을 보고 창을 닫은 뒤 메일함 목록을 다시 받는다', async () => {
reply('PATCH /mailboxes/c25', fail(404, 'RES_001'))
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
const before = sent('GET', '/mailboxes').length
await moveRow(wrapper, 'p').trigger('click')
await wrapper.get('#mailbox-move-confirm').trigger('click')
await flushPromises()
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.RES_001])
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(before)
})
it('옮기는 동안 [옮기기]는 aria-disabled · aria-busy — 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
reply('PATCH /mailboxes/c25', pending)
const { wrapper } = await mountPanel()
await choose(wrapper, '2025', '옮기기')
await moveRow(wrapper, 'p').trigger('click')
const confirm = wrapper.get('#mailbox-move-confirm')
await confirm.trigger('click')
await flushPromises()
expect(confirm.attributes('aria-disabled')).toBe('true')
expect(confirm.attributes('aria-busy')).toBe('true')
await confirm.trigger('click')
await flushPromises()
expect(sent('PATCH', '/mailboxes/c25')).toHaveLength(1)
pending.resolve(ok(box('c25', null, '2025', { parentId: 'p' })))
await flushPromises()
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
})
})
describe('하위 메일함까지 지우기', () => {
it('확인(처음 초점 「취소」) 뒤 DELETE ?withChildren=true 를 deleted 가 될 때까지 — 진행 수는 나무 전체, 지우는 동안 하위 메일함의 메뉴도 꺼지고 끝나면 나무 전체가 빠진다', async () => {
const second = deferred()
reply(
'DELETE /mailboxes/c',
ok({ deleted: false, processed: 12, movedToTrash: 12, remaining: 28 }),
second,
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
)
const { wrapper, store } = await mountPanel()
await choose(wrapper, '거래처', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함과 하위 메일함 2개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 12 / 40통'],
])
// 지우는 나무의 메일함은 모든 항목을 누를 수 없다
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role^="menuitem"]')
expect(items.length).toBeGreaterThan(0)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
second.resolve(ok({ deleted: false, processed: 28, movedToTrash: 28, remaining: 0 }))
await flushPromises()
expect(sent('DELETE', '/mailboxes/c').map((request) => request.params)).toEqual([
{ withChildren: true },
{ withChildren: true },
{ withChildren: true },
])
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.'])
expect(store.mailboxes.map((mailbox) => mailbox.id)).not.toEqual(expect.arrayContaining(['c']))
expect(store.mailboxes.some((mailbox) => ['c', 'c25', 'c25s'].includes(mailbox.id))).toBe(false)
expect(panelNames(wrapper)).toEqual(['보관', '2025', '프로젝트', '견적'])
})
it('하위 메일함을 보고 있었어도 지운 뒤 받은편지함으로 간다', async () => {
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 40, movedToTrash: 40, remaining: 0 }))
const { wrapper, router } = await mountPanel(MAILBOXES, 'c25s')
await choose(wrapper, '거래처', '지우기')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('하위 메일함까지 비어 있으면 "하위 메일함까지 모두 비어 있습니다." · 하위가 없는 메일함은 지금 지우기 그대로(withChildren 없이)', async () => {
reply('DELETE /mailboxes/b', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
reply('DELETE /mailboxes/pq', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
const { wrapper } = await mountPanel()
await choose(wrapper, '보관', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘보관’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
)
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/b').map((request) => request.params)).toEqual([{ withChildren: true }])
await choose(wrapper, '견적', '지우기')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘견적’ 메일함을 지울까요?')
await wrapper.get('#mailbox-delete-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/pq').map((request) => request.params)).toEqual([{}])
})
})
describe('차단할 때 받은 메일 옮기기와 스팸메일함 비우기', () => {
it('이 탭에서 차단 정리가 도는 동안은 「스팸메일함 비우기」를 누를 수 없다 · 확인 단추도 꺼지고 스토어도 시작하지 않는다 — 끝나면 다시 켜진다', async () => {
const { wrapper, store } = await mountPanel()
const blockRun = useMailBlockRunStore()
const junk = store.mailboxes.find((mailbox) => mailbox.id === 'junk')
if (!junk) throw new Error('스팸메일함이 없다')
// 확인 창을 연 뒤에 정리가 시작돼도 누르는 순간 다시 본다
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
blockRun.running = true
await flushPromises()
expect(wrapper.get('#mailbox-empty-confirm').attributes('disabled')).toBeDefined()
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/junk/messages')).toEqual([])
await wrapper.get('#mailbox-empty-cancel').trigger('click')
await flushPromises()
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === '스팸메일함 비우기')
expect(item?.attributes('aria-disabled')).toBe('true')
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
expect((await store.runMailboxOperation('empty', junk, () => undefined)).status).toBe('canceled')
// 휴지통 비우기는 상관없다
expect(store.isMailboxOperationBlocked('empty', { role: 'trash' })).toBe(false)
blockRun.running = false
await flushPromises()
expect(store.isMailboxOperationBlocked('empty', junk)).toBe(false)
})
})
})
@@ -169,15 +169,18 @@ describe('MailboxPanel — 메일함 관리', () => {
['스팸메일함 비우기', true],
])
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
// 내 메일함에는 「옮기기」 — 이름 바꾸기 다음 · 지우기 앞 (15단계)
expect(await labels('프로젝트')).toEqual([
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(await labels('견적')).toEqual([
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
@@ -302,16 +305,17 @@ describe('MailboxPanel — 메일함 관리', () => {
})
describe('지우기 — 안의 메일은 휴지통으로', () => {
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
it('하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계) — [취소]면 보내지 않는다', 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('확인')
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함과 하위 메일함 1개를 지울까요?')
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
'하위 메일함에 든 메일까지 모두 24통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
await wrapper.get('#mailbox-delete-cancel').trigger('click')
await flushPromises()
@@ -603,6 +607,7 @@ describe('MailboxPanel — 메일함 관리', () => {
['모두 읽음으로 표시', false],
['하위 메일함 만들기', false],
['이름 바꾸기', false],
['옮기기', false],
['지우기', true],
])
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 옮기기(15단계) · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
@@ -42,6 +42,7 @@ function onAction(action: MailboxAction, mailbox: Mailbox) {
}
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
else if (action === 'move') dialog.value = { kind: 'move', mailbox }
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
else dialog.value = { kind: 'empty', mailbox }
}
@@ -4,11 +4,22 @@
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
import { computed, ref } from 'vue'
// - 목록 줄을 끌어다 놓는 곳(15단계 — 디자인 원본 ListDrag15): 놓을 수 있는 메일함 위에서는 줄에 2px 청록 점선 · 옅은 청록 바탕
// (쓰기 화면 파일 놓기와 같은 모양 — 초점 선(실선)과 헷갈리지 않게). 놓을 수 없는 곳(메일이 이미 든 메일함 · 임시보관함)은 줄이 그대로이고
// dragover 를 받지 않아 브라우저의 금지 커서가 뜬다. 우리 메일 끌기만 받는다 — 파일 끌기는 받지 않는다(쓰기 화면의 첨부 놓기와 겹치지 않게).
// 놓으면 drop-mail 로 알리고 옮기는 일은 메일함 나무(MailboxTree)가 한다
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import {
activeMailDrag,
canDropDragOn,
mailDragging,
takeMailDrop,
type MailDragPayload,
} from '@/composables/useMailDrag'
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
@@ -23,6 +34,8 @@ interface Props {
interface Emits {
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
/** 목록 줄을 이 메일함에 끌어다 놓았다 (15단계) */
(e: 'drop-mail', mailbox: Mailbox, drag: MailDragPayload): void
}
const props = defineProps<Props>()
@@ -34,6 +47,9 @@ const mailStore = useMailStore()
const MAX_INDENT_DEPTH = 3
const menuOpen = ref<boolean>(false)
/** 놓을 수 있는 끌기가 이 줄 위에 있다 — 들어온 요소 수를 세어 줄 안의 링크 · 단추 사이를 지날 때 깜빡이지 않게 */
const dropDepth = ref<number>(0)
const dropOver = computed(() => dropDepth.value > 0)
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
function isDisabled(action: MailboxAction): boolean {
@@ -67,10 +83,52 @@ function onSelect(id: string) {
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
if (action) emit('action', action, props.mailbox)
}
/** 이 메일함에 놓을 수 있는 끌기인가 — 우리 메일 끌기이고 놓을 수 있는 메일함일 때만 (아니면 null — 브라우저의 금지 커서) */
function droppableDrag(event: DragEvent): MailDragPayload | null {
const drag = activeMailDrag(event)
return drag && canDropDragOn(drag, props.mailbox) ? drag : null
}
function onDragEnter(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
dropDepth.value += 1
}
function onDragOver(event: DragEvent) {
if (!droppableDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onDragLeave(event: DragEvent) {
if (!droppableDrag(event)) return
dropDepth.value = Math.max(0, dropDepth.value - 1)
}
function onDrop(event: DragEvent) {
dropDepth.value = 0
if (!droppableDrag(event)) return
event.preventDefault()
const drag = takeMailDrop(event)
if (drag) emit('drop-mail', props.mailbox, drag)
}
// 끌기가 끝나면(다른 곳에 놓음 · Esc 로 그만둠) 점선을 끈다 — 마지막 dragleave 를 놓쳤어도 남지 않게
watch(mailDragging, (dragging) => {
if (!dragging) dropDepth.value = 0
})
</script>
<template>
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
<li
:class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen, 'is-drop-target': dropOver }]"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<RouterLink
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
:class="['mailbox-row__link', depthClass]"
@@ -166,6 +224,19 @@ function onSelect(id: string) {
color: var(--color-brand-strong);
}
/* 끌어다 놓을 수 있는 메일함 위 — 2px 청록 점선 · 옅은 청록 바탕 (쓰기 화면 파일 놓기와 같은 모양, 디자인 원본 ListDrag15) */
.mailbox-row.is-drop-target {
outline: var(--focus-ring-width) dashed var(--color-brand);
outline-offset: calc(var(--focus-ring-width) * -1);
background: var(--color-brand-tint);
}
.mailbox-row.is-drop-target .mailbox-row__link,
.mailbox-row.is-drop-target .mailbox-row__count {
color: var(--color-brand-strong);
font-weight: 600;
}
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
.mailbox-row__more {
opacity: 0;
@@ -4,11 +4,15 @@
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
// 목록 줄을 메일함 줄에 끌어다 놓으면(15단계 — 디자인 원본 ListDrag15) 여기서 옮긴다 — 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고
// 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다 — useBulkMailActions 의 moveDragged). 「예약됨」에는 놓을 수 없다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
import MailboxScheduledRow from '@/components/mail/MailboxScheduledRow.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { MailDragPayload } from '@/composables/useMailDrag'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import type { Mailbox } from '@/shared/types/mail'
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
@@ -34,6 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
const bulk = useBulkMailActions()
const MY_GROUP_ID = 'mailbox-panel-mine'
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
const AT_GROUP_END = ''
@@ -43,6 +49,11 @@ const groups = computed(() => splitMailboxGroups(props.nodes))
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
*/
/** 끌어다 놓은 메일을 그 메일함으로 옮긴다 */
function onDropMail(mailbox: Mailbox, drag: MailDragPayload) {
void bulk.moveDragged(drag, mailbox)
}
const scheduledAfterId = computed<string | null>(() => {
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
const system = groups.value.system
@@ -67,6 +78,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
<MailboxScheduledRow
v-if="scheduledAfterId === node.mailbox.id"
@@ -98,6 +110,7 @@ const scheduledAfterId = computed<string | null>(() => {
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
@drop-mail="onDropMail"
/>
</ul>
<button
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
import MessageBlockDialog from './MessageBlockDialog.vue'
// 보낸 사람 차단 창의 15단계 새 칸 — 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」
// (디자인 원본 Mailbox15 "보낸 사람 차단 창" · 디자인 README "15단계")
interface DialogProps {
open: boolean
senderEmail: string
inJunk?: boolean
inInbox?: boolean
cleanupBlock?: MailBlockRunBlock | null
}
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountDialog(props: Partial<DialogProps> = {}) {
const wrapper = mount(MessageBlockDialog, {
props: { open: true, senderEmail: 'News@Example.com', inInbox: true, ...props },
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
const moveBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-move]')
const cleanBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-clean]')
const confirmed = (wrapper: VueWrapper) => wrapper.emitted('confirm') ?? []
describe('MessageBlockDialog — 받은편지함의 메일도 옮기기 (15단계)', () => {
it('처음에는 첫 칸 켜짐 · 새 칸 꺼짐 — 새 칸 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." · [차단]은 지금처럼', async () => {
const wrapper = mountDialog()
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
)
expect(cleanBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-clean-note')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('새 칸을 켜면 받은편지함의 메일이면 첫 칸은 켠 채 잠긴다(aria-disabled · 까닭) — 눌러도 그대로, 새 칸을 끄면 원래 값으로', async () => {
const wrapper = mountDialog()
// 먼저 첫 칸을 꺼 둔다
await moveBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
await cleanBox(wrapper).setValue(true)
expect(moveBox(wrapper).element.checked).toBe(true)
expect(moveBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(moveBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-move-lock')
expect(wrapper.get('#block-dialog-move-lock').text()).toBe('받은편지함의 메일을 옮길 때 함께 옮깁니다.')
// 잠긴 칸은 눌러도 바뀌지 않는다
await moveBox(wrapper).trigger('click')
await nextTick()
expect(moveBox(wrapper).element.checked).toBe(true)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper).at(-1)).toEqual(['news@example.com', true, true])
await cleanBox(wrapper).setValue(false)
expect(moveBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.find('#block-dialog-move-lock').exists()).toBe(false)
})
it('이 메일이 받은편지함이 아니면 잠그지 않는다 · 스팸메일함의 메일이면 첫 칸이 없고 새 칸만', async () => {
const elsewhere = mountDialog({ inInbox: false })
await cleanBox(elsewhere).setValue(true)
expect(moveBox(elsewhere).attributes('aria-disabled')).toBeUndefined()
await moveBox(elsewhere).setValue(false)
await elsewhere.get('[data-block-confirm]').trigger('click')
expect(confirmed(elsewhere)).toEqual([['news@example.com', false, true]])
const inJunk = mountDialog({ inInbox: false, inJunk: true })
expect(inJunk.find('[data-block-move]').exists()).toBe(false)
await cleanBox(inJunk).setValue(true)
await inJunk.get('[data-block-confirm]').trigger('click')
expect(confirmed(inJunk)).toEqual([['news@example.com', false, true]])
})
it('「이 도메인 전체」를 고르면 새 칸이 "이 도메인의 메일"로 바뀌고 @도메인으로 막는다', async () => {
const wrapper = mountDialog()
await wrapper.get('[data-block-scope="domain"]').setValue(true)
await cleanBox(wrapper).setValue(true)
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
'받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['@example.com', true, true]])
})
it('이 탭에서 받은편지함을 정리하는 중이면 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 — 눌러도 켜지지 않는다', async () => {
const wrapper = mountDialog({ cleanupBlock: 'inbox' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
await cleanBox(wrapper).trigger('click')
await nextTick()
expect(cleanBox(wrapper).element.checked).toBe(false)
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸을 끄고 칸 아래 글을 그 까닭으로 — 켜 둔 칸도 꺼진 것으로 보낸다, 끝나면 다시 고를 수 있다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ cleanupBlock: 'junk' })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
// 첫 칸의 잠금도 풀린다 — 새 칸이 꺼진 것과 같다
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
await wrapper.get('[data-block-confirm]').trigger('click')
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
await wrapper.setProps({ cleanupBlock: null })
expect(cleanBox(wrapper).attributes('aria-disabled')).toBeUndefined()
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
it('열 때마다 처음 값으로 — 새 칸은 꺼진 채 시작한다', async () => {
const wrapper = mountDialog()
await cleanBox(wrapper).setValue(true)
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(cleanBox(wrapper).element.checked).toBe(false)
expect(moveBox(wrapper).element.checked).toBe(true)
})
})
@@ -1,56 +1,108 @@
<script setup lang="ts">
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · Mailbox15 · README "9단계 › 차단 확인 창" · "15단계 › 보낸 사람 차단 창") — 모달 400(medium)
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// 체크 둘(붙여 둔다 — 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다):
// 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다)
// 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」(15단계 — 처음 꺼짐, 「이 도메인 전체」를 고르면 "이 도메인의 메일") +
// 그 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다."
// → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// - 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠그고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." —
// 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다. 이 메일이 다른 메일함에 있으면 잠그지 않는다
// - 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이면(cleanupBlock — 15단계 리뷰) 새 칸을 끄고(aria-disabled)
// 칸 아래 글을 그 까닭으로 바꾼다
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
// 열 때마다 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐으로 시작한다. Esc 는 [취소]와 같다
// 칸 아래 글은 고르기 상자 글 선(28)에 맞춰 들여 쓴다 — 고정 id #block-dialog-move-lock · #block-dialog-clean-note
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { domainBlockOf, emailDomainOf, isPublicMailDomain } from '@/shared/utils/mail-block'
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
interface Props {
open: boolean
/** 보낸 사람 주소 */
senderEmail: string
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
/** 이 메일이 이미 스팸메일함에 있다 — 첫 칸을 두지 않는다 */
inJunk?: boolean
/** 이 메일이 받은편지함에 있다 — 새 칸을 켜면 첫 칸을 켠 채 잠근다 (15단계) */
inInbox?: boolean
/** 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk), 없으면 null (15단계) */
cleanupBlock?: MailBlockRunBlock | null
}
interface Emits {
/** target 은 주소 또는 "@도메인" (소문자) */
(e: 'confirm', target: string, moveToJunk: boolean): void
/** target 은 주소 또는 "@도메인" (소문자) · cleanInbox 는 받은편지함의 그 보낸 사람 메일도 옮긴다 (15단계) */
(e: 'confirm', target: string, moveToJunk: boolean, cleanInbox: boolean): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
inJunk: false,
inInbox: false,
cleanupBlock: null,
})
const emit = defineEmits<Emits>()
const LOCK_NOTE_ID = 'block-dialog-move-lock'
const CLEAN_NOTE_ID = 'block-dialog-clean-note'
const scope = ref<'address' | 'domain'>('address')
/** 첫 칸에 고른 값 — 잠긴 동안에도 기억했다가 새 칸을 끄면 돌아온다 */
const moveToJunk = ref<boolean>(true)
const cleanInbox = ref<boolean>(false)
/** 새 칸을 고를 수 없다 */
const cleanupBusy = computed(() => props.cleanupBlock !== null)
const address = computed(() => normalizeEmail(props.senderEmail))
const domain = computed(() => emailDomainOf(props.senderEmail))
const domainTarget = computed(() => domainBlockOf(domain.value))
/** 「이 도메인 전체」를 고를 수 없다 — 여러 사람이 쓰는 메일 서비스 · 도메인이 없는 주소 */
const domainDisabled = computed(() => domain.value === '' || isPublicMailDomain(domain.value))
const blocksDomain = computed(() => scope.value === 'domain' && !domainDisabled.value)
/** 새 칸이 켜져 있다 — 고를 수 없는 동안은 꺼진 것으로 본다 */
const cleaning = computed(() => cleanInbox.value && !cleanupBusy.value)
/** 첫 칸을 켠 채 잠근다 — 받은편지함의 메일을 옮길 때 이 메일도 함께 옮긴다 */
const moveLocked = computed(() => cleaning.value && props.inInbox && !props.inJunk)
const moveChecked = computed(() => moveLocked.value || moveToJunk.value)
const cleanLabel = computed(() => (blocksDomain.value ? MAIL_BLOCK_TEXT.cleanInboxDomain : MAIL_BLOCK_TEXT.cleanInboxSender))
const cleanNote = computed(() => {
if (props.cleanupBlock === 'junk') return MAIL_BLOCK_TEXT.cleanJunkBusy
return props.cleanupBlock === 'inbox' ? MAIL_BLOCK_TEXT.cleanInboxBusy : MAIL_BLOCK_TEXT.cleanInboxNote
})
function confirm() {
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
emit('confirm', target, moveToJunk.value && !props.inJunk)
/** 잠긴 칸 · 고를 수 없는 칸은 눌러도 바꾸지 않는다 (aria-disabled — 초점은 받는다) */
function onMoveClick(event: MouseEvent) {
if (moveLocked.value) event.preventDefault()
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
function onMoveChange(event: Event) {
moveToJunk.value = (event.target as HTMLInputElement).checked
}
function onCleanClick(event: MouseEvent) {
if (cleanupBusy.value) event.preventDefault()
}
function onCleanChange(event: Event) {
cleanInbox.value = (event.target as HTMLInputElement).checked
}
function confirm() {
const target = blocksDomain.value ? domainTarget.value : address.value
emit('confirm', target, moveChecked.value && !props.inJunk, cleaning.value)
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'address'
moveToJunk.value = true
cleanInbox.value = false
},
)
</script>
@@ -117,18 +169,49 @@ watch(
{{ MAIL_BLOCK_TEXT.scopeDomainPublic }}
</p>
</div>
<label
v-if="!inJunk"
class="block-dialog__move"
>
<input
v-model="moveToJunk"
type="checkbox"
class="block-dialog__input"
data-block-move
<div class="block-dialog__checks">
<template v-if="!inJunk">
<label :class="['block-dialog__check', { 'is-locked': moveLocked }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="moveChecked"
:aria-disabled="moveLocked ? 'true' : undefined"
:aria-describedby="moveLocked ? LOCK_NOTE_ID : undefined"
data-block-move
@click="onMoveClick"
@change="onMoveChange"
>
<span class="block-dialog__check-label">{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
<p
v-if="moveLocked"
:id="LOCK_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--lock"
>
{{ MAIL_BLOCK_TEXT.moveToJunkLocked }}
</p>
</template>
<label :class="['block-dialog__check', { 'is-disabled': cleanupBusy }]">
<input
type="checkbox"
class="block-dialog__input"
:checked="cleaning"
:aria-disabled="cleanupBusy ? 'true' : undefined"
:aria-describedby="CLEAN_NOTE_ID"
data-block-clean
@click="onCleanClick"
@change="onCleanChange"
>
<span class="block-dialog__check-label">{{ cleanLabel }}</span>
</label>
<p
:id="CLEAN_NOTE_ID"
class="form-message block-dialog__note block-dialog__note--clean"
>
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
{{ cleanNote }}
</p>
</div>
<p class="form-message">
{{ MAIL_BLOCK_TEXT.settingsHint }}
</p>
@@ -220,20 +303,45 @@ watch(
color: var(--color-icon-muted);
}
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
/* 칸 아래 까닭 · 안내 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
.block-dialog__note {
margin-left: var(--space-28);
}
.block-dialog__move {
/* 체크 둘은 붙여 둔다 (디자인 원본 Mailbox15 — 줄 44 · 위 12, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다) */
.block-dialog__checks {
display: flex;
align-items: center;
flex-direction: column;
}
.block-dialog__check {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding-top: var(--space-12);
cursor: pointer;
}
.block-dialog__move .block-dialog__input {
margin: 0;
/* 잠긴 첫 칸 · 고를 수 없는 새 칸 — 상자와 이름을 흐리게 (aria-disabled — 초점은 받는다) */
.block-dialog__check.is-locked,
.block-dialog__check.is-disabled {
cursor: default;
}
.block-dialog__check.is-locked .block-dialog__input,
.block-dialog__check.is-locked .block-dialog__check-label,
.block-dialog__check.is-disabled .block-dialog__input,
.block-dialog__check.is-disabled .block-dialog__check-label {
opacity: 0.5;
}
/* 잠긴 첫 칸의 까닭은 그 칸 바로 아래로 당긴다 (보드 -10) */
.block-dialog__note--lock {
margin-top: calc(var(--space-10) * -1);
}
.block-dialog__note--clean {
margin-top: var(--space-2);
}
</style>
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 15단계 — 같은 대화 칸의 [모두 펼치기] · [모두 접기]와 펼친 줄 머리의 [답장] (디자인 원본 Reader15 "6")
// - [모두 펼치기]는 불러온 줄을 모두 펼친다 — 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(한꺼번에 부르지 않는다)
// - 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이다(펼쳤어도 화면 밖이면 그대로)
// 화면에 들어오고 나가는 것은 가짜 IntersectionObserver 로 흉내 낸다 (jsdom 에는 없다)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 2 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS]
const NOW = new Date(2026, 10, 9, 10).getTime()
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: 'Re: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다.',
receivedAt: at(2026, 10, 3, 9, 30),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
const FIRST = summary('c1', {
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
receivedAt: at(2026, 10, 3, 14, 10),
})
const SECOND = summary('c2', { isRead: false, receivedAt: at(2026, 10, 5, 9, 30) })
const THIRD = summary('c3', { isRead: false, receivedAt: at(2026, 10, 5, 13, 5) })
const DRAFT = summary('c5', { mailboxIds: ['drafts'], from: USER, receivedAt: at(2026, 10, 9, 9, 52) })
const CURRENT = summary('c4', { receivedAt: at(2026, 10, 9, 9, 30) })
const THREAD = [FIRST, SECOND, THIRD, CURRENT, DRAFT]
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
/** 가짜 IntersectionObserver — 테스트가 요소를 화면에 넣고 뺀다 */
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = []
readonly targets = new Set<Element>()
constructor(private readonly callback: IntersectionObserverCallback) {
FakeIntersectionObserver.instances.push(this)
}
observe(element: Element) {
this.targets.add(element)
}
unobserve(element: Element) {
this.targets.delete(element)
}
disconnect() {
this.targets.clear()
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
/** 그 요소들이 화면에 들어왔다 · 나갔다 — 보고 있는 것만 알린다 */
static set(elements: Element[], visible: boolean) {
for (const instance of FakeIntersectionObserver.instances) {
const entries = elements
.filter((element) => instance.targets.has(element))
.map((target) => ({ target, isIntersecting: visible }) as IntersectionObserverEntry)
if (entries.length > 0) instance.callback(entries, instance as unknown as IntersectionObserver)
}
}
}
describe('MessageConversation — [모두 펼치기] · 펼친 줄 머리의 [답장] (15단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let details: Map<string, ReturnType<typeof deferred>>
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
FakeIntersectionObserver.instances = []
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
details = new Map()
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
/** 본문은 테스트가 풀어 줄 때 온다 (deferredDetails) — 아니면 바로 */
function serve(options: { conversation?: (request: MockRequest) => MockResult | Promise<MockResult>; hold?: boolean } = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return options.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...SECOND, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
const id = request.url.split('/').pop() ?? ''
const body = ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
if (!options.hold) return body
const wait = deferred()
details.set(id, wait)
return wait.promise.then(() => body)
}
return ok(null)
})
}
async function mountReader() {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [SECOND, THIRD, CURRENT]
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
store.listStatus = 'ready'
store.selected = detail(CURRENT)
store.selectedId = CURRENT.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/inbox/${CURRENT.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: 'split' },
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, store }
}
const detailGets = () =>
requests
.filter((request) => request.method === 'GET' && /^\/messages\/[^/]+$/.test(request.url))
.map((request) => request.url.split('/').pop())
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.url)
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
const toggleAll = (wrapper: VueWrapper) => wrapper.get('[data-conversation-toggle-all]')
const panelOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-panel="${id}"]`).element
async function show(wrapper: VueWrapper, ids: string[], visible = true) {
FakeIntersectionObserver.set(
ids.map((id) => panelOf(wrapper, id)),
visible,
)
await flushPromises()
}
it('칸 머리 오른쪽에 [모두 펼치기] — 누르면 불러온 줄(지금 메일 빼고)을 모두 펼치고 [모두 접기]가 된다', async () => {
serve()
const { wrapper } = await mountReader()
const button = toggleAll(wrapper)
expect(button.text()).toBe('모두 펼치기')
expect(button.element.tagName).toBe('BUTTON')
// 칸 제목과 같은 머리 줄
expect(button.element.parentElement?.querySelector('h3')?.textContent?.trim()).toBe('같은 대화 5통')
await button.trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
}
// 지금 보는 메일의 줄은 지금처럼 펼치지 않는다
expect(rowOf(wrapper, 'c4').find('[data-conversation-toggle]').exists()).toBe(false)
// 누른 것만으로는 본문을 받지 않는다 — 화면에 들어온 줄부터
expect(detailGets()).toEqual([])
expect(rowOf(wrapper, 'c3').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
for (const id of ['c1', 'c2', 'c3', 'c5']) {
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
}
expect(wrapper.find('[data-conversation-panel]').exists()).toBe(false)
})
it('본문은 화면에 들어온 줄부터 하나씩 받는다 — 앞 줄을 받는 동안 다음 줄은 기다리고, 차례 전에 화면 밖으로 나간 줄은 건너뛴다', async () => {
serve({ hold: true })
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c1', 'c2', 'c3'])
expect(detailGets()).toEqual(['c1'])
// c3 는 차례가 오기 전에 화면 밖으로 나갔다
await show(wrapper, ['c3'], false)
details.get('c1')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(detailGets()).toEqual(['c1', 'c2'])
expect(rowOf(wrapper, 'c1').find('iframe').exists()).toBe(true)
expect(rowOf(wrapper, 'c3').find('iframe').exists()).toBe(false)
// 다시 들어오면 받는다 · 한 번 받은 줄은 다시 들어와도 다시 받지 않는다
await show(wrapper, ['c3', 'c1'])
expect(detailGets()).toEqual(['c1', 'c2', 'c3'])
})
it('읽음은 본문이 화면에 보인 메일만 — 받는 사이 화면 밖으로 나갔으면 들어올 때 읽음으로', async () => {
serve({ hold: true })
const { wrapper, store } = await mountReader()
await toggleAll(wrapper).trigger('click')
await flushPromises()
await show(wrapper, ['c2'])
expect(detailGets()).toEqual(['c2'])
// 받는 사이 내려서 화면 밖으로
await show(wrapper, ['c2'], false)
details.get('c2')?.resolve(ok(null))
await flushPromises()
expect(patches()).toEqual([])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(true)
// 다시 드나들어도 다시 묻지 않는다
await show(wrapper, ['c2'], false)
await show(wrapper, ['c2'])
expect(patches()).toEqual(['/messages/c2'])
})
it('하나씩 다 펼쳐도 [모두 접기] — 하나라도 접으면 다시 [모두 펼치기]', async () => {
serve()
const { wrapper } = await mountReader()
for (const id of ['c1', 'c2', 'c3', 'c5']) await rowOf(wrapper, id).get('[data-conversation-toggle]').trigger('click')
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await rowOf(wrapper, 'c1').get('[data-conversation-toggle]').trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('[더 보기]로 더 받은 줄은 접힌 채로 오고 단추는 다시 [모두 펼치기]', async () => {
const filler = Array.from({ length: 95 }, (_, index) =>
summary(`p${index}`, { receivedAt: at(2026, 9, 1, 9, index % 60) }),
)
const later = summary('c9', { receivedAt: at(2026, 10, 9, 9, 59) })
serve({
conversation: ({ params }) =>
params.cursor ? page([later], null, null) : page([...filler, ...THREAD], 'next', null),
})
const { wrapper } = await mountReader()
await toggleAll(wrapper).trigger('click')
expect(toggleAll(wrapper).text()).toBe('모두 접기')
await wrapper.get('[data-conversation-more]').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'c9').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
expect(rowOf(wrapper, 'c2').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼칠 줄이 없으면(지금 메일만 받았고 나머지를 받는 중) [모두 펼치기]가 없다', async () => {
const rest = deferred()
serve({ conversation: ({ params }) => (params.cursor ? rest.promise : page([CURRENT], 'next', 3)) })
const { wrapper } = await mountReader()
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
expect(wrapper.find('[data-conversation-toggle-all]').exists()).toBe(false)
rest.resolve(page([FIRST, SECOND], null, 3))
await flushPromises()
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
})
it('펼친 줄 머리 오른쪽에 [답장](펼침 단추와 따로 놓인 링크) — 접힌 줄 · 임시 저장본의 줄에는 없다', async () => {
serve()
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'c2').find('[data-conversation-head-reply]').exists()).toBe(false)
await rowOf(wrapper, 'c2').get('[data-conversation-toggle]').trigger('click')
await rowOf(wrapper, 'c5').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const reply = rowOf(wrapper, 'c2').get('[data-conversation-head-reply]')
expect(reply.text()).toBe('답장')
expect(reply.attributes('href')).toBe('/mail/compose?replyTo=c2')
// 단추 안에 넣지 않고 머리 틀 안에 나란히 둔다
expect(reply.element.closest('button')).toBeNull()
expect(reply.element.parentElement?.querySelector('[data-conversation-toggle]')).not.toBeNull()
// 아래쪽 [답장] · [이 메일만 열기]는 그대로
expect(rowOf(wrapper, 'c2').find('[data-conversation-reply]').exists()).toBe(true)
expect(rowOf(wrapper, 'c2').find('[data-conversation-open]').exists()).toBe(true)
expect(rowOf(wrapper, 'c5').find('[data-conversation-head-reply]').exists()).toBe(false)
})
})
@@ -11,11 +11,15 @@
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
// - 칸 머리 오른쪽 [모두 펼치기](15단계 — 디자인 원본 Reader15 "6" · 36 글자 단추): 불러온 줄을 모두 펼치고 [모두 접기]가 된다(하나씩 다 펼쳐도).
// 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(useConversationPanelVisibility → 스토어의 noteConversationPanelVisible).
// 펼칠 줄이 없으면(지금 메일뿐 — 나머지를 받는 중) 단추가 없다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
import { useClock } from '@/composables/useClock'
import { useConversationPanelVisibility } from '@/composables/useConversationPanelVisibility'
import { useConversationSync } from '@/composables/useConversationSync'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount, formatTraceCode } from '@/shared/utils/format'
@@ -77,6 +81,15 @@ function loadMore() {
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
}
/** 펼칠 줄이 있다 — [모두 펼치기] · [모두 접기]를 둔다 */
const canExpandAll = computed(() => mailStore.conversationExpandableIds.length > 0)
/** [모두 펼치기] ↔ [모두 접기] — 하나라도 접혀 있으면 모두 펼친다 */
function toggleAll() {
if (mailStore.conversationAllExpanded) mailStore.collapseAllConversation()
else mailStore.expandAllConversation()
}
// ── 초점 지키기 ─────────────────────────────────────────
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
@@ -146,6 +159,13 @@ watch(
{ flush: 'pre' },
)
// 펼친 안이 화면에 들어오고 나가는 것을 스토어에 알린다 — [모두 펼치기]의 본문은 화면에 들어온 줄부터 하나씩 (15단계)
useConversationPanelVisibility({
root: sectionRef,
watchKey: () => renderKey.value,
onChange: (id, inView) => mailStore.noteConversationPanelVisible(id, inView),
})
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
watch(failed, async (isFailed) => {
announcement.value = ''
@@ -163,14 +183,25 @@ watch(failed, async (isFailed) => {
aria-labelledby="reader-conversation-title"
data-conversation
>
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<div class="conversation__head">
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<button
v-if="canExpandAll"
type="button"
class="conversation__toggle-all"
data-conversation-toggle-all
@click="toggleAll"
>
{{ mailStore.conversationAllExpanded ? MAIL_CONVERSATION_TEXT.collapseAll : MAIL_CONVERSATION_TEXT.expandAll }}
</button>
</div>
<ol class="conversation__list">
<ConversationMessageRow
v-for="row in rows"
@@ -245,6 +276,33 @@ watch(failed, async (isFailed) => {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 칸 머리 — 제목 · 오른쪽 [모두 펼치기] (디자인 원본 Reader15 "6" — 높이 36) */
.conversation__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
min-height: var(--size-notice-action);
}
/* [모두 펼치기] · [모두 접기] — 36 글자 단추, 펼친 줄 머리의 [답장]과 같은 크기(안내 줄 단추의 밀도 예외 재사용). 글자 끝을 상자 끝에 맞춘다 */
.conversation__toggle-all {
flex: 0 0 auto;
min-height: var(--size-notice-action);
margin-right: calc(var(--space-10) * -1);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation__toggle-all:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.conversation__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
@@ -9,6 +9,7 @@
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리 — 「영구 삭제」는 이름이 길어 더 넓은 폭에서 접는다)
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
@@ -95,6 +96,7 @@ watch([held, permanent, () => props.message.id], () => {
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
tone="danger"
:disabled="working"
collapsible
data-reader-delete
@click="onClick"
/>
@@ -10,12 +10,15 @@
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -60,6 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
@@ -104,7 +109,10 @@ function onMouseDown(event: MouseEvent) {
},
]"
:data-row-id="message.id"
:draggable="!held"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<MailCheckbox
class="message-row__check"
@@ -44,6 +44,7 @@ import { isHeldMessage } from '@/shared/utils/pending-send'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
@@ -63,6 +64,8 @@ const mailStore = useMailStore()
const filterStore = useMailFilterStore()
const contactsStore = useContactsStore()
const blockStore = useMailBlockStore()
/** 차단 창의 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중 (15단계 — startBlock) */
const blockRunStore = useMailBlockRunStore()
const contactDialog = useMailContactDialog()
const print = useMailPrint()
const senderBlock = useSenderBlock()
@@ -93,6 +96,11 @@ const blockable = computed(
/** 이미 막고 있는 차단 (주소 또는 도메인) */
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
const inJunk = computed(() => removal.isInJunk(props.message))
/** 받은편지함의 메일이다 — 차단 창의 새 칸을 켜면 「이 메일도 …」를 켠 채 잠근다 (15단계) */
const inInbox = computed(() => {
const inboxId = mailStore.mailboxByRole('inbox')?.id
return inboxId !== undefined && props.message.mailboxIds.includes(inboxId)
})
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [
@@ -206,14 +214,15 @@ function focusButton() {
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: boolean) {
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
blockDialogOpen.value = false
const message = props.message
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
const outcome = await senderBlock.block(message, target, moveToJunk)
const outcome = await senderBlock.block(message, target, moveToJunk, cleanInbox)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
@@ -269,6 +278,8 @@ watch(
:open="blockDialogOpen"
:sender-email="sender"
:in-junk="inJunk"
:in-inbox="inInbox"
:cleanup-block="blockRunStore.startBlock"
@confirm="onBlock"
@cancel="blockDialogOpen = false"
/>
@@ -0,0 +1,138 @@
<script setup lang="ts">
// 읽기 화면의 「이동 ▾」 — 열린 메일 한 통을 고른 메일함으로 (15단계 — 디자인 원본 Reader15 "1 · 2 · 3", README "15단계")
// - 자리: 답장 · 전체 답장 · 전달 | 이동 ▾ · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다
// - 메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품 · 같은 목록(mail-move-menu.ts): 메일함 패널처럼 들여 쓴 나무에서 그 메일이 든 메일함
// (검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통을 뺀다. 그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는
// 흐린 줄로 남기고 이름 아래에 "이 메일이 있는 메일함". 휴지통으로는 [삭제]로 옮긴다
// - 옮긴 뒤는 [삭제]와 같은 길: 분할 보기는 다음 메일, 목록 보기는 목록으로 + "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기]
// (같은 이름이 다른 곳에도 있을 때만 위 메일함 이름 — useMailMoveFeedback). 스팸메일함으로 옮기면 [스팸 신고], 스팸메일함에서 꺼내면 [스팸 아님]과 같다
// - 보내기를 기다리는 메일(보내는 중 · 예약)에는 없다 — 읽기 화면이 그리지 않는다(취소해야만 보낸편지함을 떠난다)
// - 키보드는 목록의 [이동 ▾]와 같다: Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). 메뉴가 화면 밖으로 넘치면 단추 오른쪽 끝에 맞춘다(EditorPopup 의 좌우 바꾸기)
// - 읽기 칸이 좁으면 이름을 숨기고 아이콘 + ▾ 만(MailToolButton 의 collapsible — MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { moveMenuItems } from '@/components/mail/mail-move-menu'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { hasMoveTarget, mailMoveTargets } from '@/shared/utils/mail-move-targets'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageSummary
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
layout: 'split' | 'list'
}
const props = defineProps<Props>()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const buttonRef = ref<InstanceType<typeof MailToolButton> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const working = ref<boolean>(false)
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
/** 옮길 곳 — 그 메일이 든 메일함은 빼거나(하위가 있으면) 고를 수 없는 줄로 */
const targets = computed(() => mailMoveTargets(mailStore.mailboxes, new Set(props.message.mailboxIds)))
const items = computed<EditorMenuItem[]>(() => moveMenuItems(targets.value, MAIL_BULK_TEXT.moveHere))
/** 옮길 곳이 하나도 없으면(메일함 목록을 아직 못 받음) 그리지 않는다 */
const available = computed(() => hasMoveTarget(targets.value))
function openMenu(keyboard: boolean) {
if (working.value || !available.value) return
byKeyboard.value = keyboard
open.value = true
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
openMenu(true)
}
function onClose(restoreFocus: boolean) {
open.value = false
if (restoreFocus) trigger.value?.focus()
}
async function onSelect(id: string) {
const target = targets.value.find((row) => row.mailbox.id === id && !row.disabled)
if (!target || working.value) return
open.value = false
trigger.value?.focus()
const message = props.message
const source = mailStore.listSource
working.value = true
try {
const outcome = await removal.moveTo(message, target.mailbox)
await removal.leaveRemoved(outcome, source, props.layout, message.id)
} finally {
working.value = false
}
}
</script>
<template>
<div
v-if="available"
class="reader-move"
>
<MailToolButton
ref="buttonRef"
icon="folder-move"
:label="MAIL_BULK_TEXT.move"
collapsible
menu
aria-haspopup="menu"
:aria-expanded="open"
:disabled="working"
data-reader-move
@click="onClick"
@keydown="onKeydown"
/>
<EditorMenu
v-if="open"
class="reader-move__menu"
:label="MAIL_BULK_TEXT.moveMenu"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onClose"
/>
</div>
</template>
<style scoped>
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
.reader-move {
position: relative;
display: inline-flex;
}
/* 옮길 메일함 메뉴 — 목록의 [이동 ▾]와 같은 폭 (MailBulkMoveButton) */
.reader-move__menu {
width: max-content;
min-width: 12.5rem;
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
max-width: min(20rem, calc(100vw - 5rem));
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,606 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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 { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 읽기 화면의 15단계 — 예약 안내 줄 [지금 보내기] · 보낸 사람 차단 창의 「받은편지함에 있는 이 보낸 사람의 메일도 …」
// (디자인 원본 Reader15 "지금 보내기" · Mailbox15 "보낸 사람 차단 창" · 계약 2절 "보내기 취소 · 예약 발송" · "보낸 사람 차단")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 120, unreadCount: 0 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', totalCount: 2 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', totalCount: 0 }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 0 }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS, JUNK, TRASH]
const NEWSLETTER: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' },
to: [USER],
subject: '[클라우드 브리프] 11월 둘째 주',
preview: '',
receivedAt: '2026-11-09T00:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '이번 주 소식입니다.',
hasBlockedImages: false,
imagesShown: false,
attachments: [],
}
const SEND_AT = '2026-11-10T00:00:00.000Z'
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
const SCHEDULED: MessageDetail = {
...NEWSLETTER,
id: 's1',
mailboxIds: ['sent'],
from: USER,
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
subject: '11월 셋째 주 배포 일정 확인 부탁드립니다',
pendingSend: { kind: 'scheduled', sendAt: SEND_AT },
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
interface MountOptions {
message?: MessageDetail
source?: MailListSource
layout?: 'split' | 'list'
extra?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
}
describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은 메일 옮기기', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(options: MountOptions = {}) {
const message = options.message ?? NEWSLETTER
const source = options.source ?? { kind: 'mailbox', mailboxId: message.mailboxIds[0] ?? 'inbox' }
requests = installMockApi((request) => {
const handled = options.extra?.(request)
if (handled) return handled
if (request.url === '/mail-filters') return ok({ rules: [], version: 'v1', isActive: true })
if (request.url === '/contacts') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.url === '/mail-blocks' && request.method === 'GET') return ok({ senders: [], isActive: true })
if (request.url === '/mail-blocks' && request.method === 'POST') {
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-11-09T05:00:00.000Z' }, 201)
}
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
if (request.url === '/messages' && request.method === 'GET') {
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
if (request.url === '/scheduled-messages') {
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = source
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = message
store.selectedId = message.id
store.selectedStatus = 'ready'
const router = createTestRouter()
const listPath = source.kind === 'scheduled' ? '/mail/scheduled' : `/mail/${message.mailboxIds[0] ?? 'inbox'}`
await router.push(listPath)
await router.push(`${listPath}/${message.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, store }
}
const releases = () => requests.filter((request) => request.method === 'POST' && request.url.endsWith('/release'))
const toasts = () => useToastStore().toasts
/** 설정 › 보내기 취소 10초를 받아 두었다 (메일 화면에 들어올 때 받는다 — MailLayout) */
function knowUndoSeconds() {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
settings.status = 'ready'
}
describe('예약 안내 줄 [지금 보내기]', () => {
it('POST …/release(본문 없음) → "메일을 보냈습니다." + [보내기 취소](설정한 초로 센 마감) · 예약됨에서 빠지고 다음 예약 메일 · 예약됨 수를 다시 묻는다', async () => {
knowUndoSeconds()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
: undefined,
})
const countsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(releases().map((request) => [request.url, request.data])).toEqual([['/scheduled-messages/s1/release', undefined]])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(countsBefore)
const [toast] = toasts()
expect(toasts()).toHaveLength(1)
expect(toast).toMatchObject({ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } })
// 마감은 설정한 10초에서 1초를 뺀 때 — 이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게
const span = (toast?.expiresAt ?? 0) - (toast?.shownAt ?? 0)
expect(span).toBeGreaterThan(8_900)
expect(span).toBeLessThanOrEqual(9_000)
})
it('누르는 동안 세 단추를 함께 막는다(aria-disabled) — [지금 보내기]는 aria-busy, 다시 눌러도 한 번만 보낸다', async () => {
const pending = deferred()
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? pending.promise : undefined),
})
const notice = wrapper.get('[data-notice="scheduled"]')
await notice.get('[data-scheduled-send]').trigger('click')
expect(notice.findAll('button').map((button) => button.attributes('aria-disabled'))).toEqual(['true', 'true', 'true'])
expect(notice.get('[data-scheduled-send]').attributes('aria-busy')).toBe('true')
await notice.get('[data-scheduled-send]').trigger('click')
await notice.get('[data-scheduled-cancel]').trigger('click')
await flushPromises()
expect(releases()).toHaveLength(1)
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
pending.resolve(ok({ id: 's1', rejectedRecipients: [], pendingSend: null }))
await flushPromises()
})
it('바로 보냈거나 예약 시각이 막 지나 이미 보낸 메일(pendingSend: null) — "메일을 보냈습니다." + [보낸편지함 보기]', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release' ? ok({ id: 's1', rejectedRecipients: [], pendingSend: null }) : undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '메일을 보냈습니다.',
expiresAt: null,
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
},
])
})
it('받는 사람 일부가 받지 못하면 그 문장 — 보내기 취소를 기다리는 동안이면 [보내기 취소]와 함께', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({
id: 's1',
rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'not-found' }],
pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() },
})
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)', action: { label: '보내기 취소' } },
])
})
it('보낸편지함에서 연 예약 메일 — 그 자리에 남고 안내 줄만 사라진다(초점은 본문으로) · 붙잡힌 발송은 보내기 취소를 기다리는 것으로 바뀐다', async () => {
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) =>
request.url === '/scheduled-messages/s1/release'
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt } })
: undefined,
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toEqual({ kind: 'undo', sendAt })
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
describe('보내지 못했거나 결과를 모를 때(503 MAIL_001 · SYS_001) — 그 메일을 다시 받아 붙잡은 발송 · 메일함으로 가른다', () => {
/** 예약은 풀렸는데 다시 내지 못한 메일 — 서버가 결과를 모를 때 남기는 꼴(보낸편지함 · 붙잡은 발송 없음) */
const UNSCHEDULED: MessageDetail = { ...SCHEDULED, pendingSend: null }
/** 보낸편지함 목록 — 다시 맞추면 그 메일은 남고(예약만 풀림) 다음 예약 메일도 있다 */
const sentList = (first: MessageDetail) =>
ok({ items: [first, { ...SCHEDULED, id: 'm2', subject: '다음 메일' }], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
it('임시보관함으로 되돌아갔으면 "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] · 예약됨을 떠났으니 안내 줄을 내리고 다음 예약 메일', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
// 엔진이 거절해 서버가 그 메일을 임시보관함으로 되돌렸다
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
const listsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
// 사전 문구(MAIL_001) 대신 화면 글 하나
expect(toasts()).toMatchObject([
{
kind: 'error',
message: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
action: { label: '임시보관함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'drafts' } } },
},
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(listsBefore)
})
it('예약이 그대로면(예약을 거두기 전에 멈춤) "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도] · 안내 줄은 그대로 — [다시 시도]는 같은 요청을 보낸다', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
// 예약 제출이 아직 기다린다 — 예약 표시 그대로
if (request.method === 'GET' && request.url === '/messages/s1') return ok(SCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '지금 보내지 못했습니다. 다시 시도해 주세요.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
expect(wrapper.get('[data-scheduled-send]').attributes('aria-disabled')).toBeUndefined()
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('예약만 풀렸으면(보낸편지함 · 기다리는 발송 없음 — 이미 나갔을 수 있다) 예약됨에서 연 메일: "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 예약됨에서 빠지고 다음 예약 메일', async () => {
let attempt = 0
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
attempt += 1
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
}
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [failed] = toasts()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
// 분할 보기 화면이 다음 메일을 연다 (이 시험은 읽기 화면만 띄운다)
store.selectedId = 'm2'
// [다시 시도]는 같은 요청 — 서버가 이어 낸다. 다른 메일을 보고 있으니 그 자리에 둔다
const action = failed ? useToastStore().takeAction(failed.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(releases()).toHaveLength(2)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('예약만 풀렸으면 보낸편지함에서 연 메일도 안내 줄을 내린다 — 그 자리에 남고(목록에도 남는다) 초점은 본문으로', async () => {
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
expect(store.selected?.pendingSend).toBeNull()
expect(store.selected?.mailboxIds).toEqual(['sent'])
expect(store.messages.map((message) => message.id)).toEqual(['s1', 'm2'])
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
it('결과를 모르는 실패(504 SYS_001 — 앞단이 기다리다 끊음)도 같은 갈래 — 사전 문구 알림 없이 다시 받아 가른다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(504, 'SYS_001')
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages/s1')).toHaveLength(1)
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.'])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
it('연결이 끊겼는데(SYS_001) 다시 받아 보니 다시 낸 발송이 보내기 취소를 기다리면 보낸 것과 같다 — "메일을 보냈습니다." + [보내기 취소]', async () => {
knowUndoSeconds()
const sendAt = new Date(Date.now() + 10_000).toISOString()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return 'network'
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, pendingSend: { kind: 'undo', sendAt } })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } }])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
})
it('그 메일을 다시 받지 못하면 보냈다 · 못 보냈다 · 되돌렸다고 하지 않는다 — "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 안내 줄은 마지막으로 안 그대로', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
if (request.method === 'GET' && request.url === '/messages/s1') return 'network'
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts()).toMatchObject([{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } }])
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
})
it('받는 사람이 모두 받지 않으면(400 MAIL_010 — 임시보관함으로) 지금처럼 사전 문구 · 예약 표시를 서버 기준으로 다시 맞춘다', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') return fail(400, 'MAIL_010')
if (request.method === 'GET' && request.url === '/messages/s1') {
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
}
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_010])
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
})
it('혹시 409 MAIL_014(이미 보냄)로 와도 오류가 아니다 — "메일을 보냈습니다."', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? fail(409, 'MAIL_014') : undefined),
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
})
it('알림의 [보내기 취소]는 지금의 보내기 취소와 같다 — DELETE 로 취소하고 그 메일로 쓰기 화면', async () => {
knowUndoSeconds()
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.url === '/scheduled-messages/s1/release') {
return ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
}
if (request.method === 'DELETE' && request.url === '/scheduled-messages/s1') return ok({ draftId: 's1' })
return undefined
},
})
await wrapper.get('[data-scheduled-send]').trigger('click')
await flushPromises()
const [toast] = toasts()
const action = toast ? useToastStore().takeAction(toast.id) : null
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/scheduled-messages/s1',
])
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
})
})
describe('차단 창 — 받은편지함에 있는 이 보낸 사람의 메일도 옮기기', () => {
async function openBlockDialog(wrapper: VueWrapper) {
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]').at(-1)?.trigger('click')
await flushPromises()
return wrapper.get('[data-block-dialog]')
}
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
const { wrapper, router } = await mountWith({
extra: (request) =>
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
})
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-clean]').setValue(true)
// 받은편지함의 메일이라 첫 칸은 켠 채 잠긴다
expect(dialog.get('[data-block-move]').attributes('aria-disabled')).toBe('true')
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
const order = requests
.filter((request) => request.method !== 'GET')
.map((request) => `${request.method} ${request.url}`)
expect(order).toEqual(['POST /mail-blocks', 'PATCH /messages', 'POST /mail-block-runs'])
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
expect(requests.find((request) => request.url === '/mail-block-runs')?.data).toEqual({
sender: 'news@cloudbrief.example',
cursor: null,
})
expect(toasts()).toMatchObject([
{
kind: 'success',
message: '‘news@cloudbrief.example’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.',
action: { label: '스팸메일함 보기' },
},
])
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
})
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
const { wrapper } = await mountWith()
useMailFilterRunStore().running = true
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
})
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸이 꺼져 있다 — 그 까닭 글, 비우기가 끝나면 다시 고를 수 있다', async () => {
const emptying = deferred()
const { wrapper, store } = await mountWith({
extra: (request) =>
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : undefined,
})
const empty = store.runMailboxOperation('empty', JUNK, () => undefined)
const dialog = await openBlockDialog(wrapper)
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
expect(dialog.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
emptying.resolve(ok({ processed: 0, remaining: 0 }))
await empty
await flushPromises()
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBeUndefined()
expect(dialog.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
})
})
})
@@ -565,7 +565,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
it('도구 줄 — 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
const { wrapper } = await mountWith()
const toolbar = wrapper.get('.reader__toolbar')
@@ -573,6 +573,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
@@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import type { Mailbox, MessageDetail, MessagesPatch, 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 } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('clients-2025', null, '2025', { parentId: 'clients' }),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-2025', null, '2025', { parentId: 'project' }),
]
function message(id: string, mailboxId: string, partial: Partial<MessageSummary> = {}): MessageDetail {
return {
id,
threadId: `t-${id}`,
mailboxIds: [mailboxId],
from: { name: '(주)한빛솔루션 영업팀', email: 'sales@example.com' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
subject: `2025년 유지보수 정산 자료 ${id}`,
preview: '',
receivedAt: '2026-11-09T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
...partial,
}
}
describe('MessageReader — [이동 ▾] (15단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountReader(
options: { box?: string; layout?: 'split' | 'list'; open?: Partial<MessageSummary> } = {},
) {
const boxId = options.box ?? 'inbox'
requests = installMockApi((request) => {
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 10 } })
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: boxId }
const open = message('m1', boxId, options.open)
store.messages = [open, message('m2', boxId)] as MessageSummary[]
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/${boxId}/m1`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
slots: options.layout === 'list' ? { 'toolbar-start': () => h('a', { href: `/mail/${boxId}` }, '목록으로') } : {},
global: { plugins: [pinia, router] },
attachTo: document.body,
})
mounted.push(wrapper)
return { wrapper, router, store }
}
const patches = () =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
async function openMenu(wrapper: VueWrapper) {
await wrapper.get('[data-reader-move]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
return wrapper.get('[role="menu"]')
}
async function pick(wrapper: VueWrapper, label: string) {
const menu = await openMenu(wrapper)
const item = menu.findAll('[role="menuitem"]').find((entry) => (entry.attributes('aria-label') ?? entry.text()) === label)
if (!item) throw new Error(`${label} 항목이 없다`)
await item.trigger('click')
await flushPromises()
}
it('[전달] 뒤 구분선 다음, [스팸 신고] 앞 — 메뉴를 여는 단추(▾)이고 키보드로 열면 첫 항목에 초점', async () => {
const { wrapper } = await mountReader()
const toolbar = wrapper.get('.reader__toolbar')
const move = wrapper.get('[data-reader-move]')
const order = [...toolbar.element.children].map((child) =>
child.matches('.toolbar-divider') ? '|' : (child.textContent ?? '').trim() || (child.getAttribute('aria-label') ?? ''),
)
expect(order.slice(0, 7)).toEqual(['답장', '전체 답장', '전달', '|', '이동', '스팸 신고', '삭제'])
expect(move.attributes('aria-haspopup')).toBe('menu')
expect(move.attributes('aria-expanded')).toBe('false')
expect(move.find('.tool-button__chevron').exists()).toBe(true)
const menu = await openMenu(wrapper)
expect(menu.attributes('aria-label')).toBe('옮길 메일함')
expect(document.activeElement).toBe(menu.find('[role="menuitem"]').element)
expect(move.attributes('aria-expanded')).toBe('true')
})
it('옮길 곳은 메일함 패널처럼 들여 쓴다 — 그 메일이 든 받은편지함 · 임시보관함 · 휴지통은 없고, 기본 메일함과 내 메일함 사이에 선', async () => {
const { wrapper } = await mountReader()
const menu = await openMenu(wrapper)
const entries = [...menu.element.children].map((child) => {
if (child.tagName === 'HR') return '---'
const depth = [...child.classList].find((name) => name.startsWith('menu__item--depth-'))
return `${child.textContent?.trim()}${depth ? ` (${depth.slice(-1)})` : ''}`
})
expect(entries).toEqual(['보낸편지함', '스팸메일함', '---', '거래처', '2025 (1)', '견적 (1)', '프로젝트', '2025 (1)'])
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
const labels = menu.findAll('[role="menuitem"]').map((item) => item.attributes('aria-label') ?? null)
expect(labels).toEqual([null, null, null, '거래처 안의 2025', '거래처 안의 견적', null, '프로젝트 안의 2025'])
// 앞에 패널과 같은 아이콘
expect(menu.findAll('[role="menuitem"] .menu__icon')).toHaveLength(7)
})
it('그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄 "이 메일이 있는 메일함" — 눌러도 옮기지 않는다', async () => {
const { wrapper } = await mountReader({ box: 'clients' })
const menu = await openMenu(wrapper)
const here = menu.findAll('[role="menuitem"]').find((item) => item.text().startsWith('거래처'))
expect(here?.attributes('aria-disabled')).toBe('true')
expect(here?.get('.menu__note').text()).toBe('이 메일이 있는 메일함')
expect(here?.attributes('aria-describedby')).toBe(here?.get('.menu__note').attributes('id'))
// 받은편지함이 처음으로 든다 (그 메일은 거래처에 있다)
expect(menu.findAll('[role="menuitem"]')[0]?.text()).toBe('받은편지함')
await here?.trigger('click')
await flushPromises()
expect(patches()).toEqual([])
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('옮기면 [삭제]와 같은 길 — 다음 메일을 열고 "메일 1통을 거래처 › 2025로 옮겼습니다."(같은 이름이 또 있어서) + [되돌리기]', async () => {
const { wrapper, router, store } = await mountReader()
await pick(wrapper, '거래처 안의 2025')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients-2025' }])
expect(store.messages.map((item) => item.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 거래처 › 2025로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
expect(toastTexts().at(-1)).toBe('메일 1통을 받은편지함으로 되돌렸습니다.')
})
it('같은 이름이 하나뿐이면 이름만 — "메일 1통을 견적으로 옮겼습니다." · 목록 보기는 목록으로 돌아간다', async () => {
const { wrapper, router } = await mountReader({ layout: 'list' })
await pick(wrapper, '거래처 안의 견적')
expect(toastTexts()).toEqual(['메일 1통을 견적으로 옮겼습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 보내고 [되돌리기]는 표시도 되돌린다', async () => {
const { wrapper } = await mountReader()
await pick(wrapper, '스팸메일함')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
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('스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같다 — "스팸이 아닌 메일로 표시해 …로 옮겼습니다."', async () => {
const { wrapper } = await mountReader({ box: 'junk' })
await pick(wrapper, '프로젝트')
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project', isJunk: false }])
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 프로젝트로 옮겼습니다.'])
})
it('보내기를 기다리는 메일(보내는 중 · 예약)에는 [이동 ▾]가 없다 — [스팸 신고]처럼', async () => {
const sendAt = '2026-11-10T00:00:00Z'
const { wrapper } = await mountReader({ box: 'sent', open: { pendingSend: { kind: 'undo', sendAt } } })
expect(wrapper.find('[data-reader-move]').exists()).toBe(false)
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
})
})
describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단계 — 디자인 원본 Reader15 "2")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
afterEach(() => {
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountWith(boxId: string, layout: 'split' | 'list' = 'split') {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = { kind: 'mailbox', mailboxId: boxId }
const open = message('m1', boxId)
store.selected = open
store.selectedId = open.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${boxId}/m1`)
await router.isReady()
return mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
}
it('도구 줄의 이름 단추는 이름을 숨겨도 읽히게 aria-label · title 을 둔다 (숨기는 때는 읽기 칸 폭의 컨테이너 쿼리)', async () => {
const wrapper = await mountWith('inbox')
const buttons = wrapper.findAll('.reader__toolbar .tool-button--collapsible')
expect(buttons.map((button) => [button.attributes('aria-label'), button.attributes('title')])).toEqual([
['답장', '답장'],
['전체 답장', '전체 답장'],
['전달', '전달'],
['이동', '이동'],
['스팸 신고', '스팸 신고'],
['삭제', '삭제'],
])
// 이름은 따로 감싼 글 — 좁으면 이것만 숨긴다
expect(buttons.map((button) => button.get('.tool-button__label').text())).toEqual([
'답장',
'전체 답장',
'전달',
'이동',
'스팸 신고',
'삭제',
])
})
it('접는 폭을 가르는 모양 — 분할 보기 · 목록 보기, 「영구 삭제」가 있는 휴지통은 따로', async () => {
const inbox = await mountWith('inbox')
expect(inbox.get('.reader__toolbar').classes()).toEqual(['reader__toolbar', 'reader__toolbar--split'])
inbox.unmount()
const trash = await mountWith('trash')
expect(trash.get('.reader__toolbar').classes()).toContain('reader__toolbar--long')
expect(trash.get('[data-reader-delete]').text()).toBe('영구 삭제')
trash.unmount()
const list = await mountWith('inbox', 'list')
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
list.unmount()
})
})
@@ -289,12 +289,12 @@ describe('MessageReader — 9단계', () => {
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [지금 보내기](15단계) [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
const notice = wrapper.get('[data-notice="scheduled"]')
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
expect(notice.findAll('button').map((button) => button.text())).toEqual(['지금 보내기', '고치기', '예약 취소'])
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
})
+107 -12
View File
@@ -3,10 +3,13 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 도구 줄: 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
@@ -29,10 +32,12 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageConversation from '@/components/mail/MessageConversation.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageMoveButton from '@/components/mail/MessageMoveButton.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
@@ -49,21 +54,30 @@ interface Props {
layout?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const mailStore = useMailStore()
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
const print = useMailPrint()
const removal = useMessageRemoval()
const bodyFrameRef = ref<InstanceType<typeof MailBodyFrame> | null>(null)
const message = computed(() => mailStore.selected)
/** 예약한 메일 — 답장 · 전달이 없다 (9단계) */
const scheduled = computed(() => isScheduledMessage(message.value))
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 스팸 신고(옮기기)가 없다 */
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 이동 · 스팸 신고(옮기기)가 없다 */
const held = computed(() => isHeldMessage(message.value))
/** 도구 줄이 「영구 삭제」를 보인다 — 이름이 길어 더 넓은 폭에서 아이콘만으로 접는다 (MessageDeleteButton 과 같은 판정) */
const longDelete = computed(() => Boolean(message.value && !held.value && removal.deletesPermanently(message.value)))
/** 도구 줄의 모양 — 보기 방식(목록 보기는 앞에 [목록으로] · 이전/다음)과 「영구 삭제」에 따라 아이콘만으로 접는 폭이 다르다 */
const toolbarClass = computed(() => [
'reader__toolbar',
`reader__toolbar--${props.layout}`,
{ 'reader__toolbar--long': longDelete.value },
])
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
@@ -128,7 +142,7 @@ onBeforeUnmount(() => {
class="reader__toolbar-bar"
>
<div
class="reader__toolbar"
:class="toolbarClass"
role="group"
aria-label="이 메일로 할 일"
>
@@ -138,24 +152,32 @@ onBeforeUnmount(() => {
<MailToolButton
icon="reply"
label="답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
collapsible
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
collapsible
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
v-if="!held"
:message="message"
:layout="layout"
/>
<template v-if="!held">
<MessageMoveButton
:message="message"
:layout="layout"
/>
<MessageSpamButton
:message="message"
:layout="layout"
/>
</template>
</template>
<MessageDeleteButton
:message="message"
@@ -325,11 +347,15 @@ onBeforeUnmount(() => {
flex-direction: column;
}
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
/*
* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다.
* 띠가 읽기 칸 폭의 컨테이너다(15단계) — 단추 이름을 숨길지 이 폭으로 정한다 (아래 @container). 크기 봉쇄는 가로뿐이라 메뉴는 띠 밖으로 나온다
*/
.reader__toolbar-bar {
position: sticky;
top: 0;
z-index: 1;
container: reader-tools / inline-size;
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
@@ -345,6 +371,75 @@ onBeforeUnmount(() => {
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
}
/* 목록 보기 — 앞의 [목록으로] · 이전/다음까지 이름이 다 보이는 줄은 콘텐츠 열(800)보다 조금 넓다. 줄의 최대 폭을 풀어 넓은 칸에서는 한 줄에 둔다 */
.reader__toolbar--list {
max-width: none;
}
/*
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
*/
@container reader-tools (width < 35.625rem) {
.reader__toolbar :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 37.1875rem) {
.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 53.125rem) {
.reader__toolbar--list :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
@container reader-tools (width < 54.6875rem) {
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible) {
justify-content: center;
min-width: var(--size-icon-button);
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
padding: 0;
}
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
display: none;
}
}
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
.reader__content {
display: flex;
@@ -1,11 +1,11 @@
<script setup lang="ts">
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 차례: 예약한 메일(청록 — [지금 보내기](15단계) [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
// - 예약 안내 줄의 단추는 지금 보내기 · 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 세 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [지금 보내기] · [고치기] · [예약 취소])
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
import { computed, ref } from 'vue'
@@ -42,7 +42,7 @@ const scheduledActions = useScheduledMessageActions()
const senderBlock = useSenderBlock()
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
const working = ref<'send' | 'edit' | 'cancel' | 'unblock' | null>(null)
const pending = computed(() => messagePendingSend(props.message))
const scheduledTime = computed(() =>
@@ -63,16 +63,18 @@ async function showImages() {
if (await mailStore.showImages()) emit('settled')
}
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
async function runScheduled(kind: 'edit' | 'cancel') {
/**
* [지금 보내기] · [고치기] · [예약 취소] — 했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다).
* 지금 보내기(15단계)는 예약을 취소하지 않고 보낸다 — 보내기 취소를 기다리는 동안은 보낸편지함 날짜 칸이 "보내는 중"이다
*/
async function runScheduled(kind: 'send' | 'edit' | 'cancel') {
if (working.value) return
working.value = kind
let done = false
try {
done =
kind === 'edit'
? await scheduledActions.editScheduled(props.message.id)
: await scheduledActions.cancelScheduled(props.message, props.layout)
if (kind === 'send') done = await scheduledActions.sendNow(props.message, props.layout)
else if (kind === 'edit') done = await scheduledActions.editScheduled(props.message.id)
else done = await scheduledActions.cancelScheduled(props.message, props.layout)
} finally {
working.value = null
}
@@ -103,26 +105,39 @@ async function unblock() {
class="reader__notice-icon"
/>
<span class="reader__notice-text reader__notice-text--strong">{{ SEND_LATER_TEXT.notice(scheduledTime) }}</span>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
<!-- 단추 셋은 한 묶음 — 줄이 좁으면 묶음째 다음 줄 오른쪽으로 (15단계 — 글이 한 글자 폭으로 눌리지 않게) -->
<span class="reader__notice-actions">
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'send' ? 'true' : undefined"
data-scheduled-send
@click="runScheduled('send')"
>
{{ SEND_LATER_TEXT.sendNow }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'edit' ? 'true' : undefined"
data-scheduled-edit
@click="runScheduled('edit')"
>
{{ SEND_LATER_TEXT.edit }}
</button>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'cancel' ? 'true' : undefined"
data-scheduled-cancel
@click="runScheduled('cancel')"
>
{{ SEND_LATER_TEXT.cancelSchedule }}
</button>
</span>
</div>
<div
@@ -216,6 +231,7 @@ async function unblock() {
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
.reader__notice--brand {
flex-wrap: wrap;
gap: var(--space-8);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
@@ -223,6 +239,19 @@ async function unblock() {
--reader-notice-button-hover: var(--color-brand-tint);
}
/* 단추 셋 — [지금 보내기] [고치기] [예약 취소] (15단계). 넓으면 글 오른쪽 한 줄, 좁으면 묶음째 다음 줄 오른쪽 */
.reader__notice-actions {
flex: 0 0 auto;
display: flex;
gap: var(--space-8);
margin-left: auto;
}
/* 글이 이 폭보다 좁아지면 단추 묶음이 다음 줄로 간다 */
.reader__notice--brand .reader__notice-text {
flex-basis: 12rem;
}
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
.reader__notice--muted {
background: var(--color-surface-muted);
@@ -4,6 +4,7 @@
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리)
import { computed, ref } from 'vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
@@ -49,6 +50,7 @@ async function onClick() {
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
:tone="inJunk ? 'accent' : 'default'"
:disabled="working"
collapsible
data-reader-spam
@click="onClick"
/>
@@ -7,12 +7,15 @@
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
@@ -45,6 +48,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
@@ -80,8 +85,11 @@ function onMouseDown(event: MouseEvent) {
<tr
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="message.id"
:draggable="!held"
@click="onRowClick"
@mousedown="onMouseDown"
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
@dragend="drag.end"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
@@ -4,6 +4,7 @@
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
// 들여 쓴 항목(depth — 15단계 [이동 ▾]의 메일함 나무)은 메일함 패널과 같은 10 · 28 · 44 · 56 에서 시작하고, 화면 낭독 이름이 따로 있으면(ariaLabel) 그것으로 읽힌다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -43,6 +44,14 @@ function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 들여쓰기는 3단계까지 — 그 아래는 같은 깊이로 (메일함 패널과 같다) */
const MAX_MENU_DEPTH = 3
function depthClass(depth: number | undefined): string | undefined {
if (!depth || depth <= 0) return undefined
return `menu__item--depth-${Math.min(depth, MAX_MENU_DEPTH)}`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
@@ -70,8 +79,13 @@ function select(item: EditorMenuItem, event: MouseEvent) {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:class="[
'menu__item',
depthClass(item.depth),
{ 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note },
]"
:role="item.role ?? 'menuitem'"
:aria-label="item.ariaLabel"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:aria-disabled="item.disabled ? 'true' : undefined"
@@ -154,6 +168,19 @@ function select(item: EditorMenuItem, event: MouseEvent) {
background: var(--color-surface-muted);
}
/* 들여 쓴 항목 — 메일함 패널과 같은 왼쪽 안쪽 (MailboxPanelRow) */
.menu__item--depth-1 {
padding-left: var(--space-28);
}
.menu__item--depth-2 {
padding-left: var(--space-44);
}
.menu__item--depth-3 {
padding-left: var(--space-56);
}
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import EditorMenu from './EditorMenu.vue'
// 15단계 — 메뉴 자리의 좌우 바꾸기 (디자인 원본 Reader15 2-4): 바라는 끝에 맞춰 그린 메뉴가 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다.
// EditorPopup 을 쓰는 메뉴 · 팝오버 모두에 걸린다 — 여기서는 EditorMenu 로 본다
function rect(left: number, width: number, top = 0, height = 40): DOMRect {
return { left, right: left + width, width, top, bottom: top + height, height, x: left, y: top, toJSON: () => ({}) } as DOMRect
}
/** 단추와 메뉴를 감싼 칸 — 실제 쓰는 곳(MailBulkMoveButton 등)과 같은 모양 */
function host(align: 'start' | 'end') {
return defineComponent({
setup() {
const trigger = ref<HTMLButtonElement | null>(null)
return () =>
h('div', { class: 'host' }, [
h('button', { ref: trigger, type: 'button', class: 'trigger' }, '이동'),
trigger.value
? h(EditorMenu, {
label: '옮길 메일함',
items: [
{ id: 'a', label: '보낸편지함' },
{ id: 'b', label: '스팸메일함' },
],
trigger: trigger.value,
align,
})
: null,
])
},
})
}
describe('EditorPopup — 좌우 자리', () => {
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
document.body.innerHTML = ''
})
/** 화면 폭 360 — 단추(40)는 triggerLeft, 메뉴 폭 200 */
function layout(triggerLeft: number) {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('.trigger')) return rect(triggerLeft, 40)
if (this.matches('[role="menu"]')) return rect(triggerLeft, 200, 44, 120)
return rect(0, 0)
})
}
async function open(align: 'start' | 'end') {
wrapper = mount(host(align), { attachTo: document.body })
// 첫 그리기에서 단추 ref 를 받고, 다음 그리기에서 메뉴를 그린다
await nextTick()
await nextTick()
return wrapper.get('[role="menu"]')
}
it('왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다', async () => {
layout(240)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--end')
expect(menu.classes()).not.toContain('editor-popup--start')
})
it('다 들어가면 바라는 끝 그대로', async () => {
layout(60)
const menu = await open('start')
expect(menu.classes()).toContain('editor-popup--start')
})
it('오른쪽 끝에 맞춘 메뉴(⋯)가 화면 왼쪽 밖으로 넘치면 단추 왼쪽 끝에 맞춘다', async () => {
layout(80)
const menu = await open('end')
expect(menu.classes()).toContain('editor-popup--start')
})
})
@@ -5,8 +5,13 @@
// - 메뉴(role=menu): 위·아래 화살표 · Home · End 로 항목을 옮기고, Tab 은 메뉴를 닫는다
// - 마우스로 열었으면(focusContent) 항목이 아니라 팝오버 자체에 초점을 둔다 — 고른 항목에 키보드 고리가 뜨지 않게.
// 화살표를 누르면 그때 항목으로 들어간다 (키보드로 열면 처음부터 항목에 초점)
// - 맞출 끝(align)은 바라는 쪽이다 — 그린 직후(초점을 주기 전) 재어, 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다
// (15단계 — 좁은 폭의 읽기 화면 [이동 ▾]처럼 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에. usePopupPlacement 의 measurePopupAlign)
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { measurePopupAlign } from '@/composables/usePopupPlacement'
import type { PopupAlign } from '@/shared/utils/popup-placement'
interface Props {
role: 'menu' | 'dialog'
label: string
@@ -32,6 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/** 실제로 맞춘 끝 — 바라는 끝(align)으로 그린 뒤 넘치면 반대 끝 */
const placedAlign = ref<PopupAlign>(props.align)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
@@ -105,8 +112,16 @@ function onFocusOut(event: FocusEvent) {
if (isOutside(event.relatedTarget)) emit('close', false)
}
/** 바라는 끝으로 그린 팝오버를 재어 넘치면 반대 끝으로 — 초점을 주기 전에 (넘친 자리에 초점이 가 화면이 옆으로 밀리지 않게) */
function placeSide() {
const root = rootRef.value
if (!root || !props.trigger) return
placedAlign.value = measurePopupAlign(props.trigger, root, props.align)
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
placeSide()
void nextTick(focusInitial)
})
@@ -120,7 +135,7 @@ onBeforeUnmount(() => {
ref="rootRef"
:role="role"
:aria-label="label"
:class="['editor-popup', `editor-popup--${align}`, `editor-popup--${role}`]"
:class="['editor-popup', `editor-popup--${placedAlign}`, `editor-popup--${role}`]"
tabindex="-1"
data-editor-popup
@keydown="onKeydown"
@@ -18,6 +18,12 @@ export interface EditorMenuEntry {
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/**
* 화면 낭독 이름 — 보이는 이름과 다르게 읽힐 때만 (15단계 [이동 ▾]의 들여 쓴 메일함 "거래처 안의 2025" — 같은 이름이 둘이어도 가르게)
*/
ariaLabel?: string
/** 들여쓰기 단계 — 메일함 패널처럼 왼쪽 안쪽 10 · 28 · 44 · 56 (3단계까지, 15단계 [이동 ▾]의 메일함 나무) */
depth?: number
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,29 @@
// [이동 ▾] 메뉴의 항목 — 목록 도구 줄(MailBulkMoveButton)과 읽기 화면(MessageMoveButton)이 같은 모양을 쓴다
// (15단계 — 디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때"). 옮길 곳을 고르는 규칙은 shared/utils/mail-move-targets.ts
// - 메일함 패널처럼 깊이만큼 들여 쓰고(10 · 28 · 44 · 56) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선
// - 들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025")
// - 메일이 든 메일함을 남긴 줄은 고를 수 없고(aria-disabled) 이름 아래에 까닭(hereNote)을 적는다
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import type { MailMoveTarget } from '@/shared/utils/mail-move-targets'
import { mailboxIcon } from '@/shared/utils/mailbox'
/** 기본 메일함과 내 메일함 사이의 선 — 메뉴 안에 하나뿐이다(두 묶음은 이어져 있다) */
const GROUP_SEPARATOR_ID = 'move-menu-group-separator'
export function moveMenuItems(rows: readonly MailMoveTarget[], hereNote: string): EditorMenuItem[] {
const items: EditorMenuItem[] = []
rows.forEach((row, index) => {
const previous = rows[index - 1]
if (previous && previous.group !== row.group) items.push(menuSeparator(GROUP_SEPARATOR_ID))
items.push({
id: row.mailbox.id,
label: row.mailbox.name,
ariaLabel: row.spokenName === row.mailbox.name ? undefined : row.spokenName,
icon: mailboxIcon(row.mailbox),
depth: row.depth,
disabled: row.disabled,
note: row.here ? hereNote : undefined,
})
})
return items
}
@@ -5,5 +5,7 @@ export type MailboxDialogRequest =
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
| { kind: 'create'; parent: Mailbox | null }
| { kind: 'rename'; mailbox: Mailbox }
/** 메일함 옮기기 (15단계) — 옮길 곳을 고르는 창 */
| { kind: 'move'; mailbox: Mailbox }
| { kind: 'delete'; mailbox: Mailbox }
| { kind: 'empty'; mailbox: Mailbox }
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AppTopBar from './AppTopBar.vue'
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
describe('AppTopBar — 계정 칸', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
})
afterEach(() => {
restoreApiAdapter()
})
async function mountBar() {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(AppTopBar, { props: { title: '메일' }, global: { plugins: [pinia, router] } })
}
it('차례는 그대로 — 모듈 이름 · 검색 칸 · 계정(머리글자 · 이름과 주소 · [로그아웃])', async () => {
const wrapper = await mountBar()
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
expect(children).toEqual(['top-bar__title', 'top-bar__search', 'top-bar__account'])
const account = [...wrapper.get('.top-bar__account').element.children].map((child) => child.className)
expect(account).toEqual(['top-bar__avatar', 'top-bar__who', 'top-bar__logout'])
expect(wrapper.get('.top-bar__logout').text()).toBe('로그아웃')
})
it('이름 · 주소가 줄어 "…"가 되어도 마우스를 올리면 다 보인다 — "김아라 (ara.kim@arasystem.it.kr)"', async () => {
const wrapper = await mountBar()
const who = wrapper.get('.top-bar__who')
expect(who.attributes('title')).toBe('김아라 (ara.kim@arasystem.it.kr)')
expect(who.get('.top-bar__name').text()).toBe('김아라')
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
})
})
+35 -1
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -23,6 +26,11 @@ const { logout } = useAuth()
const loggingOut = ref<boolean>(false)
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
/** 줄어든 이름 · 주소의 전체 — "김아라 (ara.kim@arasystem.it.kr)" */
const accountTitle = computed(() => {
const user = authStore.user
return user ? `${user.name} (${user.email})` : ''
})
async function onLogout() {
if (loggingOut.value) return
@@ -68,7 +76,10 @@ async function onLogout() {
class="top-bar__avatar"
aria-hidden="true"
>{{ initial }}</span>
<div class="top-bar__who">
<div
class="top-bar__who"
:title="accountTitle"
>
<span class="top-bar__name">{{ authStore.user.name }}</span>
<span class="top-bar__email">{{ authStore.user.email }}</span>
</div>
@@ -135,12 +146,16 @@ async function onLogout() {
.top-bar__account {
margin-left: auto;
/* 줄이 모자라면 이름 · 주소 칸이 준다 (줄을 넘지 않는다) */
min-width: 0;
max-width: 100%;
display: flex;
align-items: center;
gap: var(--space-10);
}
.top-bar__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -153,11 +168,20 @@ async function onLogout() {
}
.top-bar__who {
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
/* 넘치면 끝을 "…"로 — 전체는 칸의 title */
.top-bar__name,
.top-bar__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-bar__name {
font-weight: 600;
}
@@ -167,7 +191,10 @@ async function onLogout() {
color: var(--color-text-muted);
}
/* [로그아웃]은 줄지도 꺾이지도 않는다 */
.top-bar__logout {
flex: 0 0 auto;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: var(--space-6);
@@ -183,4 +210,11 @@ async function onLogout() {
.top-bar__logout:hover {
background: var(--color-surface-muted);
}
/* 화면 폭 480 이하 — 주소를 감추고 이름만 (tokens.css 화면 폭 기준) */
@media (max-width: 30rem) {
.top-bar__email {
display: none;
}
}
</style>
+58 -17
View File
@@ -2,10 +2,12 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { notifyApiError } from './useApi'
import type { MailDragPayload } from './useMailDrag'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import type { Mailbox } from '@/shared/types/mail'
import { listLocationOf } from '@/shared/utils/mail-list-source'
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { hereMailboxIds, junkMarkForMove, mailMoveTargets, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
@@ -16,6 +18,10 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
// - 15단계: [이동 ▾]의 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-targets.ts — 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 줄로 남긴다).
// 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove).
// 목록 줄을 메일함 패널에 끌어다 놓은 것(moveDragged — 휴지통에 놓으면 [삭제])도 같은 길 · 같은 알림이다.
// 알림의 메일함 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" (useMailMoveFeedback)
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -40,18 +46,28 @@ export function useBulkMailActions() {
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
*/
const conversationFrom = computed<string | null>(() =>
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
)
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
.map((node) => node.mailbox)
.filter(
(mailbox) =>
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
),
)
const conversationFrom = computed<string | null>(() => {
const mailbox = mailStore.isConversationList ? mailStore.currentMailbox : null
return mailbox ? feedback.placeName(mailbox) : null
})
/**
* 이동 메뉴 — 메일함 패널 차례 · 깊이. 고른 메일이 든 메일함(목록이 가리키는 메일함 · 고른 메일이 모두 든 곳) · 임시보관함 · 휴지통은
* 빼고, 그중 하위 메일함이 있는 것은 고를 수 없는 줄로 남긴다 (mail-move-targets.ts)
*/
const moveTargets = computed<MailMoveTarget[]>(() => {
const scopeId = scopeMailboxIdOf(mailStore.listSource)
const here = mailStore.isConversationList
? hereMailboxIds([], scopeId)
: hereMailboxIds(mailStore.checkedMessages, scopeId)
return mailMoveTargets(mailStore.mailboxes, here)
})
/** 옮기며 함께 보낼 스팸 표시 — 부르는 쪽이 정했으면(스팸 신고 · 스팸 아님) 그대로, 아니면 옮길 곳과 지금 메일함으로 (junkMarkForMove) */
function junkOptionsFor(mailbox: Mailbox, options: MoveOptions): MoveOptions {
if (options.isJunk !== undefined) return options
const isJunk = junkMarkForMove(mailbox, inJunk.value)
return isJunk === undefined ? {} : { isJunk }
}
async function markRead(isRead: boolean): Promise<void> {
await mailStore.patchChecked({ isRead })
@@ -71,15 +87,39 @@ export function useBulkMailActions() {
await router.replace(listLocationOf(source))
}
/** 옮긴 뒤 — 열어 둔 메일이 빠졌으면 목록으로, 알림(+ 되돌리기) */
async function settleMove(result: BulkMoveResult, mailbox: Mailbox, from: string | null): Promise<void> {
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox, { from })
}
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
const from = conversationFrom.value
const moveOptions = junkOptionsFor(mailbox, options)
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id, options)
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox.name, { from })
const result = await mailStore.moveChecked(mailbox.id, moveOptions)
await settleMove(result, mailbox, from)
} finally {
busy.value = false
}
}
/**
* 목록 줄을 메일함 패널에 끌어다 놓았다 (15단계 — useMailDrag) — 놓은 메일함이 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같다.
* 고른 것을 끌었으면 옮긴 뒤 고른 것을 푼다(하나라도 옮겼을 때 — 도구 줄과 같다). 고르지 않은 줄을 끌었으면 고른 것은 그대로 둔다
*/
async function moveDragged(drag: MailDragPayload, mailbox: Mailbox): Promise<void> {
if (busy.value || drag.targets.length === 0) return
const from = drag.kind === 'conversations' ? conversationFrom.value : null
const moveOptions = junkOptionsFor(mailbox, {})
busy.value = true
try {
const result = await mailStore.moveMessages(drag.targets, mailbox.id, moveOptions)
if (drag.fromSelection && !result.stale && result.doneIds.length > 0) mailStore.clearChecked()
await settleMove(result, mailbox, from)
} finally {
busy.value = false
}
@@ -134,6 +174,7 @@ export function useBulkMailActions() {
markRead,
toggleFlagged,
moveTo,
moveDragged,
moveToTrash,
moveSpam,
purge,
@@ -0,0 +1,71 @@
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
// 같은 대화 칸의 펼친 안([data-conversation-panel]) 가운데 화면에 들어온 것을 알린다
// (15단계 — 디자인 원본 Reader15 "6": [모두 펼치기]의 본문은 화면에 들어오는 줄부터 하나씩 받는다 — 받는 차례는 스토어의 noteConversationPanelVisible).
// - IntersectionObserver 하나로 칸 안의 펼친 안을 모두 본다. 칸을 다시 그릴 때마다(watchKey) 새로 생긴 안은 보기 시작하고, 사라진 안은 나간 것으로 알린다
// - 보이는 곳은 화면이다 — 읽기 칸처럼 스크롤되는 조상이 잘라 낸 곳은 보이지 않는 것으로 친다(IntersectionObserver 의 기본 뿌리가 그렇게 잰다)
// - IntersectionObserver 가 없는 브라우저 · 시험 환경은 펼친 안이 생기면 들어온 것으로 본다(받는 것은 그래도 하나씩이다)
// - 칸이 사라지면(읽기 화면을 떠남) 보던 안을 모두 나간 것으로 알린다
/** 펼친 안의 표시 — 값은 그 줄의 메일 id (ConversationMessagePanel) */
const PANEL_SELECTOR = '[data-conversation-panel]'
export interface ConversationPanelVisibilityOptions {
/** 칸 — 그 안의 펼친 안을 본다 (칸이 없으면 null) */
root: Ref<HTMLElement | null>
/** 다시 그릴 때 바뀌는 값 — 바뀌면 펼친 안을 다시 센다 */
watchKey: () => unknown
onChange: (id: string, visible: boolean) => void
}
export function useConversationPanelVisibility(options: ConversationPanelVisibilityOptions): void {
/** 보고 있는 안 — 요소마다 그 줄의 메일 id */
const observed = new Map<Element, string>()
let observer: IntersectionObserver | null = null
function report(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
const id = observed.get(entry.target)
if (id !== undefined) options.onChange(id, entry.isIntersecting)
}
}
function forget(element: Element, id: string) {
observed.delete(element)
observer?.unobserve(element)
options.onChange(id, false)
}
/** 지금 칸 안의 펼친 안과 맞춘다 — 사라진 안은 나간 것으로, 새 안은 보기 시작한다 */
function sync() {
const panels = new Map<Element, string>()
for (const element of options.root.value?.querySelectorAll<HTMLElement>(PANEL_SELECTOR) ?? []) {
const id = element.dataset.conversationPanel
if (id) panels.set(element, id)
}
for (const [element, id] of [...observed]) {
if (panels.get(element) !== id) forget(element, id)
}
for (const [element, id] of panels) {
if (observed.has(element)) continue
observed.set(element, id)
if (observer) observer.observe(element)
else options.onChange(id, true)
}
}
onMounted(() => {
if (typeof IntersectionObserver !== 'undefined') observer = new IntersectionObserver(report)
sync()
})
watch(options.watchKey, sync, { flush: 'post' })
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
const left = [...observed.values()]
observed.clear()
for (const id of left) options.onChange(id, false)
})
}
+69
View File
@@ -192,3 +192,72 @@ describe('useMail 이미 받은 메일에 적용 — POST /mail-filter-runs (계
expect(requests[0]?.config.silent).toEqual(['REQ_001'])
})
})
describe('useMail — 15단계 메일 · 메일함 다루기 (계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('메일함 옮기기는 PATCH /mailboxes/{id} 에 parentId 만 — 맨 위는 null 을 그대로 싣는다', async () => {
const requests = installMockApi(() => ok({ id: 'c25' }))
await useMail().moveMailbox('c/25', { parentId: 'p' }, { silent: ['MAIL_009'] })
await useMail().moveMailbox('c25', { parentId: null })
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['PATCH /mailboxes/c%2F25', 'PATCH /mailboxes/c25'])
expect(requests.map((item) => item.data)).toEqual([{ parentId: 'p' }, { parentId: null }])
expect(requests[0]?.config.silent).toEqual(['MAIL_009'])
})
it('하위까지 지우기는 ?withChildren=true — 주지 않으면 쿼리 없이 (옛 화면처럼 하위가 있으면 서버가 막는다)', async () => {
const requests = installMockApi(() => ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
await useMail().deleteMailbox('c', { withChildren: true, silent: ['RES_001'] })
await useMail().deleteMailbox('c')
expect(requests.map((item) => [item.method, item.url, item.params])).toEqual([
['DELETE', '/mailboxes/c', { withChildren: true }],
['DELETE', '/mailboxes/c', {}],
])
expect(requests[0]?.config.silent).toEqual(['RES_001'])
// withChildren 은 요청 설정으로 새지 않는다
expect(Object.keys(requests[0]?.config ?? {})).not.toContain('withChildren')
})
it('차단 정리는 POST /mail-block-runs { sender, cursor } — 필터 적용과 같은 시간 한도', async () => {
const run = { scanned: 1200, matched: 12, remaining: 3600, cursor: 'b1' }
const requests = installMockApi(() => ok(run))
await expect(useMail().runSenderBlock({ sender: '@example.com', cursor: null }, { silent: true })).resolves.toEqual(run)
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /mail-block-runs'])
expect(requests[0]?.data).toEqual({ sender: '@example.com', cursor: null })
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
})
it.each([
['보내기 취소를 기다리는 중이면 그대로', { id: 's/1', pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, rejectedRecipients: [] }],
['바로 보냈으면 null', { id: 's/1', pendingSend: null, rejectedRecipients: [NOBODY] }],
])('지금 보내기는 POST /scheduled-messages/{id}/release (본문 없음) — %s', async (_label, reply) => {
const requests = installMockApi(() => ok(reply))
await expect(useMail().releaseScheduledMessage('s/1')).resolves.toEqual(reply)
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /scheduled-messages/s%2F1/release'])
expect(requests[0]?.data).toBeUndefined()
})
it.each([
['칸이 없는 응답', {}],
['모양이 틀린 칸', { id: 7, pendingSend: { kind: 'later', sendAt: '곧' }, rejectedRecipients: 'x' }],
['빈 응답', null],
])('지금 보내기 응답은 늘 값으로 맞춘다 — %s 은 빈 배열 · null · 요청한 id', async (_label, reply) => {
installMockApi(() => ok(reply))
await expect(useMail().releaseScheduledMessage('s1')).resolves.toEqual({ id: 's1', rejectedRecipients: [], pendingSend: null })
})
})
+71 -4
View File
@@ -10,6 +10,7 @@ import {
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
} from '@/shared/constants/mail'
import { MAIL_BLOCK_RUN_TIMEOUT_MS } from '@/shared/constants/mail-block'
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
@@ -19,6 +20,8 @@ import type {
Draft,
DraftPayload,
DraftSaved,
MailBlockRun,
MailBlockRunRequest,
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
@@ -32,6 +35,7 @@ import type {
MailboxBulkResult,
MailboxCreatePayload,
MailboxDeletion,
MailboxMovePayload,
MailboxRenamePayload,
MessageDeletionRequest,
MessageDeletionResult,
@@ -46,6 +50,7 @@ import type {
RecipientSuggestionQuery,
SaveSignaturePayload,
ScheduledMessageCancel,
ReleasedSend,
SearchIndexState,
SendMessagePayload,
SentMessage,
@@ -70,6 +75,11 @@ export interface GetMessageOptions extends MailRequestOptions {
highlight?: MessageHighlightQuery | null
}
/** 메일함 지우기의 옵션 — withChildren 이면 하위 메일함까지 (15단계 — `?withChildren=true`) */
export interface DeleteMailboxOptions extends MailRequestOptions {
withChildren?: boolean
}
export interface SendMessageOptions extends MailRequestOptions {
/**
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
@@ -87,6 +97,12 @@ type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'>
pendingSend?: unknown
}
/**
* POST /scheduled-messages/{id}/release 가 실제로 주는 모양 (15단계 — 계약의 ReleasedSend). 모양이 틀리면(응답을 바꾼 게이트웨이 등)
* releaseScheduledMessage 가 빈 배열 · null · 요청한 id 로 맞춘다
*/
type ScheduledReleaseReply = { id?: unknown; rejectedRecipients?: unknown; pendingSend?: unknown } | null
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
@@ -153,13 +169,25 @@ export function useMail() {
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
* 메일함 옮기기 (15단계) — 같은 PATCH 에 parentId 만 보낸다(null 은 맨 위). 하위 메일함과 안의 메일이 함께 간다.
* 새 상위 안에 같은 이름(대소문자 무시)이면 409 MAIL_009, 자기 자신 · 하위 아래 · 둘 수 없는 자리는 400 VAL_001, 없는 메일함 · 상위는 404 RES_001,
* 그 메일함의 오래 걸리는 처리가 진행 중이면 429 REQ_001
*/
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
function moveMailbox(id: string, payload: MailboxMovePayload, options?: MailRequestOptions): Promise<Mailbox> {
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, { parentId: payload.parentId }, options)
}
/**
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
* 하위 메일함이 있으면 withChildren(?withChildren=true — 15단계)일 때만 하위까지 깊은 것부터 지운다. 없으면 400 VAL_001
* (확인 창이 하위까지 지울지 물은 뒤에만 켠다 — 옛 화면이 모르고 하위를 지우지 않게)
*/
function deleteMailbox(id: string, options: DeleteMailboxOptions = {}): Promise<MailboxDeletion> {
const { withChildren, ...requestOptions } = options
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
...options,
...requestOptions,
...(withChildren ? { params: { withChildren: true } } : {}),
})
}
@@ -229,6 +257,20 @@ export function useMail() {
})
}
/**
* 차단한 보낸 사람(주소 · 도메인)의 메일 가운데 받은편지함에 이미 있는 것을 스팸메일함으로 옮긴다 (15단계 — 차단 창의 새 칸).
* 필터 적용과 같은 규칙이다: 한 요청은 25초 안에 끝나고 cursor 를 돌려준다 — null 이 될 때까지 같은 sender 에 그 cursor 를 넣어 다시 보낸다
* (부르는 쪽 — mail-block-run.store). 같은 직원의 받은편지함 정리(필터 적용 · 이것)가 다른 곳에서 진행 중이면 429 REQ_001,
* 차단 목록에 없는 sender 는 404 RES_001, cursor 가 틀리면 400 VAL_001, 하나도 확인하지 못했으면 503 MAIL_001
*/
function runSenderBlock(request: MailBlockRunRequest, options?: MailRequestOptions): Promise<MailBlockRun> {
return api.post<MailBlockRun, MailBlockRun>(
'/mail-block-runs',
{ sender: request.sender, cursor: request.cursor },
{ timeout: MAIL_BLOCK_RUN_TIMEOUT_MS, ...options },
)
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -447,6 +489,28 @@ export function useMail() {
return api.delete<ScheduledMessageCancel, ScheduledMessageCancel>(`/scheduled-messages/${segment(id)}`, options)
}
/**
* 예약한 메일을 지금 보낸다 (15단계 — 읽기 화면 예약 안내 줄의 [지금 보내기]). id 는 보낸 사본의 메일 id(예약 취소와 같다), 본문 없음.
* 서버가 예약을 거두고 같은 메일을 다시 제출한다 — 보내기 취소 설정의 초만큼 붙잡았다 보내면 pendingSend { kind: 'undo' }, 바로 보냈으면 null.
* 예약 시각이 막 지나 이미 보낸 메일 · 이미 지금 보내기를 한 메일도 200 이다(오류가 아니다). 예약이 아닌 메일 · 없는 메일은 404 RES_001.
* 다시 제출하지 못하면 503 MAIL_001 — 엔진이 거절했으면 임시보관함으로 되돌렸고, 결과를 모르면 메일은 보낸편지함에 그대로이며 같은 요청을 다시 보내면
* 서버가 이어 낸다(어느 쪽인지는 그 메일을 다시 읽어 가른다 — stores/mail/scheduled.ts). 받는 사람이 모두 받지 않으면 400 MAIL_010 · 503 MAIL_011(임시보관함으로 되돌렸다),
* 같은 메일의 취소 · 지금 보내기가 진행 중이면 429 REQ_001. 발송처럼 한도는 MAIL_SEND_TIMEOUT_MS (서버는 54초 남짓 안에 답한다).
* 받는 사람별 결과(rejectedRecipients)와 pendingSend 는 발송과 같은 규칙으로 늘 값으로 맞춘다 — 없거나 모양이 틀리면 빈 배열 · null, id 가 없으면 요청한 id
*/
async function releaseScheduledMessage(id: string, options?: MailRequestOptions): Promise<ReleasedSend> {
const released = await api.post<ScheduledReleaseReply, ScheduledReleaseReply>(
`/scheduled-messages/${segment(id)}/release`,
undefined,
{ timeout: MAIL_SEND_TIMEOUT_MS, ...options },
)
return {
id: typeof released?.id === 'string' && released.id !== '' ? released.id : id,
rejectedRecipients: Array.isArray(released?.rejectedRecipients) ? released.rejectedRecipients : [],
pendingSend: pendingSendOf(released?.pendingSend),
}
}
/**
* 첨부 한 개 업로드 (multipart `file`).
* 형식·크기 오류(FILE_001·FILE_002)는 파일 목록에 직접 표시하므로 그 코드만 전역 알림을 끈다.
@@ -505,6 +569,7 @@ export function useMail() {
getQuota,
createMailbox,
renameMailbox,
moveMailbox,
deleteMailbox,
markMailboxRead,
emptyMailbox,
@@ -513,6 +578,7 @@ export function useMail() {
getFilters,
saveFilters,
runFilter,
runSenderBlock,
messageSourceUrl,
downloadMessageSource,
getMailState,
@@ -527,6 +593,7 @@ export function useMail() {
sendMessage,
listScheduledMessages,
cancelScheduledMessage,
releaseScheduledMessage,
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
+169
View File
@@ -0,0 +1,169 @@
import { computed, shallowRef } from 'vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { canDropMailOn, hereMailboxIds } from '@/shared/utils/mail-move-targets'
import { isHeldMessage } from '@/shared/utils/pending-send'
import { createUuid } from '@/shared/utils/uuid'
import { useMailStore } from '@/stores/mail.store'
// 목록 줄을 메일함 패널로 끌어다 놓기 (15단계 — 디자인 원본 ListDrag15 · 디자인 README "15단계")
// - 끄는 것: 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록은 그 대화에서 지금 메일함에 든 메일
// (붙잡힌 메일은 빼고 — [이동 ▾]와 같다). 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다
// - 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(MailDragChip — setDragImage). 끈 줄은 흐리게 하지 않는다
// - 놓을 곳은 메일함 패널의 줄(MailboxPanelRow) — [이동 ▾]의 옮길 곳 + 휴지통([삭제]와 같다). 메일이 이미 든 메일함 · 임시보관함 · 「예약됨」은 놓을 수 없다
// (브라우저의 금지 커서 — dragover 를 받지 않는다)
// - 끄는 것은 우리 끌기 자료(MAIL_DRAG_TYPE)뿐이다 — 줄의 링크가 끌어 가던 메일 주소(URL)는 지운다. 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다
// - 끄는 동안의 자료는 이 탭의 메모리에만 둔다(dragover 에서는 dataTransfer 의 값을 읽을 수 없다) — 놓을 때 dataTransfer 의 표와 견줘 같은 끌기인지 본다
// - 마우스로만 한다 — 키보드 · 터치는 [이동 ▾](옮길 곳 · 알림이 같다)
/** 우리 끌기 자료의 종류 — 값은 이 끌기의 표(token) */
export const MAIL_DRAG_TYPE = 'application/x-arasystem-mail'
/** 옮길 메일 하나 — id 와 되돌릴 메일함을 정할 메일함 (스토어의 moveMessages 와 같은 꼴) */
export interface MailDragTarget {
id: string
mailboxIds: string[]
}
/** 끄는 동안의 자료 */
export interface MailDragPayload {
/** 이 끌기의 표 — dataTransfer 에 실은 값과 같아야 놓기를 받는다 */
token: string
/** 끈 것 — 메일 줄 · 대화 줄 */
kind: 'messages' | 'conversations'
/** 표의 수 — 메일 수 또는 대화 수 */
count: number
targets: MailDragTarget[]
/** 놓을 수 없는 메일함 — 메일이 이미 든 곳 (mail-move-targets.ts 의 hereMailboxIds) */
hereIds: ReadonlySet<string>
/** 고른 것을 끌었다 — 옮기면 고른 것을 푼다 */
fromSelection: boolean
}
/** 지금 끄는 것 — 놓거나 그만두면 null */
const current = shallowRef<MailDragPayload | null>(null)
/** 마우스를 따라오는 표 — MailDragChip 이 그린다 (setDragImage 는 그려 둔 요소의 모습을 찍는다) */
let chipElement: HTMLElement | null = null
/** 메일을 끄는 중인가 — 끌기가 끝나면(놓음 · 그만둠) 메일함 줄이 켜 둔 점선을 끈다 (MailboxPanelRow) */
export const mailDragging = computed<boolean>(() => current.value !== null)
/** MailDragChip 이 표 요소를 알린다 (사라질 때 null) */
export function registerMailDragChip(element: HTMLElement | null): void {
chipElement = element
}
/** 지금 끄는 메일 — 우리 끌기이고 이 탭에서 시작했을 때만 (다른 탭 · 바깥에서 끌어 온 것은 null) */
export function activeMailDrag(event: DragEvent): MailDragPayload | null {
const types = event.dataTransfer?.types
if (!types || !Array.from(types).includes(MAIL_DRAG_TYPE)) return null
return current.value
}
/** 놓은 끌기 — 표가 같을 때만 자료를 돌려주고 끌기를 끝낸다 */
export function takeMailDrop(event: DragEvent): MailDragPayload | null {
const payload = activeMailDrag(event)
if (!payload || event.dataTransfer?.getData(MAIL_DRAG_TYPE) !== payload.token) return null
current.value = null
return payload
}
/** 끌기가 끝났다 — 놓았든 그만뒀든 (끈 줄의 dragend) */
export function endMailDrag(): void {
current.value = null
}
/** 그 메일함에 지금 끄는 메일을 놓을 수 있는가 */
export function canDropDragOn(payload: MailDragPayload, mailbox: Pick<Mailbox, 'id' | 'role'>): boolean {
return canDropMailOn(mailbox, payload.hereIds)
}
/** 표에 수 · 아이콘을 넣는다 — 끌기를 시작하는 그 순간에 그려져 있어야 해서 화면 갱신을 기다리지 않고 바로 고친다 */
function paintChip(chip: HTMLElement, payload: MailDragPayload) {
const count = formatCount(payload.count)
chip.dataset.kind = payload.kind
const label = chip.querySelector<HTMLElement>('[data-drag-chip-text]')
if (label) {
label.textContent =
payload.kind === 'conversations' ? MAIL_BULK_TEXT.dragConversations(count) : MAIL_BULK_TEXT.dragMessages(count)
}
}
/**
* 끌기를 시작한다 — 링크가 실은 주소를 지우고 우리 자료만 싣는다. 표가 있으면 마우스를 따라오게 한다
* (표 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 아래로 비켜 그려 둔다, MailDragChip)
*/
function beginMailDrag(event: DragEvent, payload: MailDragPayload) {
const transfer = event.dataTransfer
if (!transfer) return
transfer.clearData()
transfer.setData(MAIL_DRAG_TYPE, payload.token)
transfer.effectAllowed = 'move'
if (chipElement) {
paintChip(chipElement, payload)
transfer.setDragImage(chipElement, 0, 0)
}
current.value = payload
}
/**
* 목록 줄의 끌기 (MessageListItem · MessageTableRow · ConversationListItem · ConversationTableRow).
* 줄 요소에 :draggable="!held"(보내기를 기다리는 메일의 줄은 끌 수 없다) · @dragstart="drag.start($event, id)" · @dragend="drag.end" 를 단다.
* 끌 수 없는 줄에서 시작한 끌기(줄 안의 링크를 끈 것)는 dragstart 에서 막는다
*/
export function useMailRowDrag() {
const mailStore = useMailStore()
/** 끌 수 있는 줄인가 — 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 아니다 */
function canDrag(rowId: string): boolean {
if (mailStore.isConversationList) {
const row = mailStore.conversations.find((entry) => entry.id === rowId)
return Boolean(row && !isConversationHeld(row))
}
const message = mailStore.messages.find((entry) => entry.id === rowId)
return Boolean(message && !isHeldMessage(message))
}
/** 그 줄을 끌면 옮길 것 — 고른 줄이면 고른 것 모두 */
function payloadOf(rowId: string): MailDragPayload | null {
if (!canDrag(rowId)) return null
const fromSelection = mailStore.checkedIds.has(rowId)
const scopeId = scopeMailboxIdOf(mailStore.listSource)
let kind: MailDragPayload['kind']
let count: number
let targets: MailDragTarget[]
if (mailStore.isConversationList) {
const rows = fromSelection
? mailStore.checkedConversations
: mailStore.conversations.filter((entry) => entry.id === rowId)
kind = 'conversations'
count = rows.length
targets = conversationActionIdsOf(rows).map((id) => ({ id, mailboxIds: scopeId ? [scopeId] : [] }))
} else {
const messages = fromSelection
? mailStore.checkedMessages
: mailStore.messages.filter((entry) => entry.id === rowId && !isHeldMessage(entry))
kind = 'messages'
count = messages.length
targets = messages.map((message) => ({ id: message.id, mailboxIds: [...message.mailboxIds] }))
}
if (targets.length === 0) return null
return { token: createUuid(), kind, count, targets, hereIds: hereMailboxIds(targets, scopeId), fromSelection }
}
function start(event: DragEvent, rowId: string) {
const payload = payloadOf(rowId)
if (!payload) {
// 끌 수 없는 줄 — 링크의 주소 끌기도 막는다
event.preventDefault()
return
}
beginMailDrag(event, payload)
}
return { canDrag, start, end: endMailDrag }
}
@@ -7,6 +7,7 @@ import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
@@ -237,7 +238,7 @@ describe('useMailFilterRun', () => {
await flushPromises()
expect(useToastStore().toasts).toMatchObject([
{ kind: 'error', message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
{ kind: 'error', message: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
])
})
@@ -252,7 +253,7 @@ describe('useMailFilterRun', () => {
expect(useToastStore().toasts).toHaveLength(1)
expect(toast).toMatchObject({
kind: 'error',
message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
message: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
press(toast)
@@ -327,7 +328,8 @@ describe('useMailFilterRun', () => {
await flushPromises()
expect(runs()).toHaveLength(3)
const busy = '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
// 차단 정리와 함께 쓰는 공용 글 (15단계에 "다른 필터로 …"에서 바꿨다)
const busy = '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
expect(shown()).toEqual([
['progress', '‘둘’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인'],
['error', busy],
@@ -349,6 +351,27 @@ describe('useMailFilterRun', () => {
expect(shown()).toEqual([['success', '‘하나’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
})
it('이 탭에서 차단할 때 받은 메일을 옮기는 중(15단계)에도 시작하지 않고 공용 글로 알린다 — 정리가 끝난 뒤 [다시 시도]로 보낸다', async () => {
replies(step(8412, 3, 0, null))
const blockRun = useMailBlockRunStore()
blockRun.running = true
expect(useMailFilterRun().start(rule())).toBe(false)
await flushPromises()
expect(runs()).toEqual([])
const busy = withAction()
expect(busy).toMatchObject({
kind: 'error',
message: '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
blockRun.running = false
press(busy)
await flushPromises()
expect(runs()).toHaveLength(1)
})
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
it('끝나면 메일함 개수 · 사용량을 다시 받고, 받은편지함 목록을 보고 있으면 그 목록도 맞춘다', async () => {
viewing('inbox')
+11 -7
View File
@@ -10,6 +10,7 @@ import {
} from '@/shared/utils/mail-filter-run'
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterRunStore, type MailFilterRunOutcome } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import type { ToastAction } from '@/stores/toast.store'
@@ -28,8 +29,9 @@ import type { ToastAction } from '@/stores/toast.store'
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다(4단계와 같다).
// 단추는 띄운 세션에서만 움직인다 (useToast)
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다(4단계 settle 과 같은 결) — 실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게
// - 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 "다른 필터를 … 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](같은 자리 · 같은 수) —
// 앞 적용이 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
// - 이 탭에서 받은편지함을 정리하는 중이면(다른 필터 적용 · 차단 정리 — 15단계 inboxBusy) 시작하지 않고
// "받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."(공용 글) + 같은 [다시 시도](같은 자리 · 같은 수) —
// 앞 정리가 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌리고, 이 함수가 만든 단추도 화면이 사라진 뒤에 움직인다. 세션이 바뀌면 멈추고 알림을 닫는다
/** 시작할 자리 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
@@ -39,6 +41,8 @@ type FailedOutcome = Extract<MailFilterRunOutcome, { status: 'failed' }>
export function useMailFilterRun() {
const runStore = useMailFilterRunStore()
/** 이 탭에서 받은편지함을 정리하는 중인지 — 필터 적용 · 차단 정리 (inboxBusy) */
const inboxCleanup = useMailBlockRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
@@ -91,11 +95,11 @@ export function useMailFilterRun() {
}
/**
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 알린다 —
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 받은편지함을 정리하는 중이면(다른 필터 적용 · 차단 정리) 시작하지 않고 알린다 —
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게
*/
async function execute(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): Promise<void> {
if (runStore.running) {
if (inboxCleanup.inboxBusy) {
toast.error(MAIL_FILTER_RUN_TEXT.busy, retryAction(rule, request, from))
return
}
@@ -120,13 +124,13 @@ export function useMailFilterRun() {
/**
* 저장한 규칙(응답 목록의 그 규칙 — 앞뒤 공백을 지운 값)을 받은편지함에 적용한다. 진행 알림이 저장 알림을 대신하면 true.
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 다른 적용이 돌고 있으면
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 받은편지함을 정리하는 중이면
* 그렇게 알리기만 한다([다시 시도] 단추와 함께). 둘 다 false — 부르는 쪽이 "필터를 저장했습니다."를 띄운다
*/
function start(rule: MailFilterRule): boolean {
const request = filterRunRuleOf(rule, mailStore.mailboxes)
if (!request) return false
const started = !runStore.running
const started = !inboxCleanup.inboxBusy
void execute(rule, request, fromStart())
return started
}
@@ -1,5 +1,6 @@
import { useToast } from './useToast'
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
movedMessageText,
notSpamMovedText,
@@ -8,6 +9,7 @@ import {
type BulkOrigin,
type BulkTextOptions,
} from '@/shared/utils/mail-bulk'
import { mailboxDisplayName } from '@/shared/utils/mailbox'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
/**
@@ -21,16 +23,24 @@ import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOption
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
* 메일함 이름은 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다 — "메일 1통을 거래처 › 2025로 옮겼습니다." (15단계 — mailbox.ts 의 mailboxDisplayName,
* 목록 · 읽기 화면의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림이 모두 같다)
*/
export function useMailMoveFeedback() {
const mailStore = useMailStore()
const toast = useToast()
/** 알림에 쓰는 메일함 이름 — 같은 이름이 다른 곳에도 있으면 "거래처 › 2025" */
function placeName(mailbox: Pick<Mailbox, 'id' | 'name'>): string {
return mailboxDisplayName(mailStore.mailboxes, mailbox)
}
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
if (origins.length !== 1) return null
const mailboxId = origins[0]?.mailboxId
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
const mailbox = mailStore.mailboxes.find((candidate) => candidate.id === mailboxId)
return mailbox ? placeName(mailbox) : null
}
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
@@ -40,13 +50,17 @@ export function useMailMoveFeedback() {
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
}
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: BulkTextOptions = {}) {
/**
* target — 옮긴 메일함(알림의 이름은 placeName).
* textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …")
*/
function announceMove(result: BulkMoveResult, target: Pick<Mailbox, 'id' | 'name'>, textOptions: BulkTextOptions = {}) {
const done = result.doneIds.length
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 mailboxName = placeName(target)
const text =
result.isJunk === false
? notSpamMovedText(done, mailboxName, skipped, textOptions)
@@ -64,5 +78,5 @@ export function useMailMoveFeedback() {
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped, textOptions))
}
return { announceMove, announcePurge }
return { announceMove, announcePurge, placeName }
}
+75 -13
View File
@@ -8,13 +8,20 @@ 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'
import { descendantMailboxes } from '@/shared/utils/mailbox'
import {
initialOperationTotal,
mailboxMoveText,
mailboxOperationCopy,
type MailboxOperationKind,
} from '@/shared/utils/mailbox-operation'
import { useMailStore, type MailboxOperationOptions } from '@/stores/mail.store'
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
// - 옮기기(15단계): 같은 이름(MAIL_009)만 창이 칸 아래에 보인다. 옮기면 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기](원래 있던 곳으로)
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
@@ -25,6 +32,8 @@ import { useMailStore } from '@/stores/mail.store'
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
/** 옮기기 창이 칸 아래에 보이는 실패 — 그사이 옮길 곳에 같은 이름이 생겼다 (15단계 — 보통은 창이 먼저 막는다) */
const MOVE_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009']
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
@@ -86,6 +95,46 @@ export function useMailboxActions() {
)
}
/** 새 상위의 메일함 — 맨 위(null)면 null. 목록에 없으면(그사이 지워짐) null 로 보고 이름 없이 알린다 */
function parentOf(parentId: string | null): Mailbox | null {
return parentId === null ? null : (mailStore.mailboxes.find((mailbox) => mailbox.id === parentId) ?? null)
}
/**
* [되돌리기] — 원래 있던 곳으로 다시 옮긴다. 실패(그사이 원래 자리에 같은 이름이 생김 · 원래 상위가 지워짐 등)는 인터셉터가 알린다.
* 기다리는 사이 세션이 바뀌었으면 알리지 않는다 (단추는 띄운 세션에서만 움직인다 — useToast)
*/
async function undoMove(mailbox: Mailbox, originalParentId: string | null): Promise<void> {
const into = parentOf(originalParentId)
try {
const moved = await mailStore.moveMailbox(mailbox.id, originalParentId)
if (moved) toast.success(mailboxMoveText(moved.name, into, { restored: true }))
} catch {
// 인터셉터가 알렸다
}
}
/**
* 메일함 옮기기 (15단계 — 디자인 원본 Mailbox15). 같은 이름(MAIL_009)은 창이 칸 아래에 보이도록 알리지 않고 돌려준다 — 그 밖은 인터셉터가 알린다.
* 옮겼으면 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기](원래 있던 곳으로) — 메일함 패널에 바로 나타나고, 보던 메일함이면 그대로 본다(id 가 같다)
*/
async function move(mailbox: Mailbox, parentId: string | null): Promise<MailboxNameResult> {
const originalParentId = mailbox.parentId
const into = parentOf(parentId)
try {
const moved = await mailStore.moveMailbox(mailbox.id, parentId, { silent: MOVE_INLINE_CODES })
if (!moved) return { status: 'canceled' }
toast.success(mailboxMoveText(moved.name, into), {
label: MAILBOX_RESULT_TEXT.undo,
run: () => void undoMove(moved, originalParentId),
})
return { status: 'saved', mailbox: moved }
} catch (error) {
const apiError = toApiError(error)
return { status: 'failed', error: apiError, inline: MOVE_INLINE_CODES.includes(apiError.code) }
}
}
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
function isViewing(mailboxId: string): boolean {
const route = router.currentRoute.value
@@ -94,8 +143,9 @@ export function useMailboxActions() {
}
/** 끝난 뒤의 이동 (위 규칙) */
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
if (!isViewing(mailboxId)) return
async function moveAway(kind: MailboxOperationKind, mailboxIds: readonly string[]): Promise<void> {
// 하위까지 지웠으면 하위 메일함을 보고 있어도 받은편지함으로 (15단계)
if (!mailboxIds.some(isViewing)) return
if (kind === 'delete') {
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
return
@@ -113,12 +163,21 @@ export function useMailboxActions() {
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
}
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
async function runOperation(
kind: MailboxOperationKind,
mailbox: Mailbox,
options: MailboxOperationOptions = {},
): Promise<void> {
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
const copy = mailboxOperationCopy(kind, mailbox.name)
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
progress.update(copy.progress(step.processed, step.total)),
// 하위까지 지우면 첫 응답 전의 전체 수도 나무 전체의 메일 수다 (15단계)
const descendants = options.withChildren ? descendantMailboxes(mailStore.mailboxes, mailbox.id) : []
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox, descendants)))
const outcome = await mailStore.runMailboxOperation(
kind,
mailbox,
(step) => progress.update(copy.progress(step.processed, step.total)),
options,
)
if (outcome.status === 'canceled') {
progress.dismiss()
@@ -132,7 +191,7 @@ export function useMailboxActions() {
}
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
await moveAway(kind, mailbox.id)
await moveAway(kind, [mailbox.id, ...descendants.map((child) => child.id)])
if (kind !== 'read') await recoverFocus()
}
@@ -146,10 +205,13 @@ export function useMailboxActions() {
return runOperation('empty', mailbox)
}
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
function remove(mailbox: Mailbox): Promise<void> {
return runOperation('delete', mailbox)
/**
* 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 확인 창이 하위까지 지울지 묻고
* withChildren 으로 부른다 (15단계 — 진행 · 결과 알림은 지금 지우기 그대로, 수는 나무 전체)
*/
function remove(mailbox: Mailbox, options: MailboxOperationOptions = {}): Promise<void> {
return runOperation('delete', mailbox, options)
}
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
return { isRunning, isBlocked, create, rename, move, markAllRead, empty, remove }
}
+19 -4
View File
@@ -3,8 +3,9 @@ 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 type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { listLocationOf, messageLocationOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
import { junkMarkForMove } from '@/shared/utils/mail-move-targets'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
@@ -17,6 +18,8 @@ import { useMailStore } from '@/stores/mail.store'
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
// - 「이동 ▾」(15단계 — 디자인 원본 Reader15): 고른 메일함으로 옮긴다 — "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기].
// 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove)
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
@@ -69,7 +72,19 @@ export function useMessageRemoval() {
}
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.moveMessages([message], trash.mailbox.id)
feedback.announceMove(result, trash.mailbox.name)
feedback.announceMove(result, trash.mailbox)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
/**
* 「이동 ▾」로 고른 메일함으로 옮긴다 — 스팸메일함으로 · 스팸메일함에서 꺼내면 스팸 표시도 함께(위 규칙).
* 알림은 [삭제]와 같은 길이다 (되돌리기는 원래 메일함 · 원래 표시로)
*/
async function moveTo(message: MessageSummary, mailbox: Mailbox): Promise<MessageRemovalOutcome> {
const isJunk = junkMarkForMove(mailbox, isInJunk(message))
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.moveMessages([message], mailbox.id, isJunk === undefined ? {} : { isJunk })
feedback.announceMove(result, mailbox)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
@@ -91,7 +106,7 @@ export function useMessageRemoval() {
}
const nextId = mailStore.neighborAfterRemoval(message.id)
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
feedback.announceMove(result, target.mailbox.name)
feedback.announceMove(result, target.mailbox)
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
@@ -122,5 +137,5 @@ export function useMessageRemoval() {
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
}
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, stillShowing, leaveRemoved }
return { deletesPermanently, isInJunk, moveToTrash, moveTo, purge, moveSpam, stillShowing, leaveRemoved }
}
+50 -9
View File
@@ -1,27 +1,68 @@
import { ref, watch, type Ref } from 'vue'
import { opensUpward } from '@/shared/utils/popup-placement'
import { opensUpward, popupAlignFor, type PopupAlign } from '@/shared/utils/popup-placement'
// 펼침 메뉴를 아래로 열지 위로 열지 — 메뉴를 그린 직후(초점을 주기 전) 아래로 그린 메뉴를 재어, 보이는 영역 아래가 모자라면 위로 연다
// (판단은 순수 함수 popup-placement.ts 의 opensUpward, 여기서는 재기만 한다).
// 쓰는 곳: 메일함 · 주소록 그룹 줄의 ⋯ (MailboxMoreButton — 스크롤되는 패널 안), 쓰기 화면의 [보내기 ▾] (ComposeFooter — 화면 맨 아래 단추 줄.
// 아래로 열면 메뉴가 화면 밖에 생기고 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 메뉴는 EditorPopup 이 연 뒤 다음 틱에 초점을 준다 — 그 전에 위아래가 정해진다 (같은 그리기 차례 안의 post 감시)
// 좌우(15단계): 메뉴를 단추의 왼쪽 끝에 맞춰 열었는데 보이는 영역 오른쪽 밖으로 넘치면 단추의 오른쪽 끝에 맞춘다 —
// EditorPopup 이 그린 직후 스스로 잰다(measurePopupAlign — 같은 부품을 쓰는 메뉴 · 팝오버 모두). 판단은 popup-placement.ts 의 popupAlignFor
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
/** 요소가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분 */
function visibleArea(element: Element): { top: number; bottom: number } {
let top = 0
let bottom = window.innerHeight
interface VisibleArea {
top: number
bottom: number
left: number
right: number
}
/** 화면 폭 — 세로 스크롤 막대는 뺀다(막대 밑의 메뉴는 보이지 않는다). 재지 못하면(시험 환경 등) 창 폭 */
function viewportWidth(): number {
return document.documentElement.clientWidth || window.innerWidth
}
/** 요소가 보일 수 있는 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분. 세로 · 가로를 따로 본다 */
function visibleArea(element: Element): VisibleArea {
const area: VisibleArea = { top: 0, bottom: window.innerHeight, left: 0, right: viewportWidth() }
for (let node = element.parentElement; node; node = node.parentElement) {
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
const style = getComputedStyle(node)
const clipsY = CLIPPING_OVERFLOW.has(style.overflowY)
const clipsX = CLIPPING_OVERFLOW.has(style.overflowX)
if (!clipsY && !clipsX) continue
const rect = node.getBoundingClientRect()
top = Math.max(top, rect.top)
bottom = Math.min(bottom, rect.bottom)
if (clipsY) {
area.top = Math.max(area.top, rect.top)
area.bottom = Math.min(area.bottom, rect.bottom)
}
if (clipsX) {
area.left = Math.max(area.left, rect.left)
area.right = Math.min(area.right, rect.right)
}
}
return { top, bottom }
return area
}
/**
* 메뉴를 단추의 어느 끝에 맞출지 잰다 — 바라는 끝(preferred)으로 그린 메뉴의 폭으로, 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝 (15단계).
* 메뉴는 단추를 감싼 칸(position: relative) 안에서 그 칸의 끝 — 곧 단추의 끝 — 에 맞춰 열린다 (EditorPopup)
*/
export function measurePopupAlign(trigger: HTMLElement, popup: HTMLElement, preferred: PopupAlign): PopupAlign {
const buttonRect = trigger.getBoundingClientRect()
const area = visibleArea(trigger)
return popupAlignFor(
{
triggerLeft: buttonRect.left,
triggerRight: buttonRect.right,
popupWidth: popup.getBoundingClientRect().width,
areaLeft: area.left,
areaRight: area.right,
},
preferred,
)
}
export interface PopupPlacementOptions {
@@ -3,10 +3,14 @@ import { isNavigationFailure, useRouter, type RouteLocationRaw } from 'vue-route
import { notifyApiError } from './useApi'
import { useMessageRemoval } from './useMessageRemoval'
import { useToast } from './useToast'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MessageSummary } from '@/shared/types/mail'
import { MAIL_PARTIAL_SEND_TEXT, MAIL_SENT_ACTION_LABEL, MAIL_SENT_MESSAGE } from '@/shared/constants/mail'
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS, UNDO_SEND_MAX_MS } from '@/shared/constants/mail-send-later'
import type { MessageSummary, ReleasedSend } from '@/shared/types/mail'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
import { undoToastDeadline } from '@/shared/utils/pending-send'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailStore, type ReleaseOutcome } from '@/stores/mail.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
/**
* 붙잡아 둔 발송을 다루는 일 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계") — 모두 DELETE /scheduled-messages/{id} 한 길이다.
@@ -17,6 +21,8 @@ import { useMailStore } from '@/stores/mail.store'
* 두 경우 모두 쓰기 화면으로 가지 못했으면(지금 화면의 나가기 확인에서 머물기로 함 등) 그 자리에 두고
* "…취소하고 임시보관함으로 옮겼습니다." + [고치기](그 메일로 쓰기 화면) — 취소는 이미 됐다
* - [예약 취소](예약 안내 줄): 임시보관함으로 + "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] — 삭제처럼 목록에서 빠진 뒤의 이동
* - [지금 보내기](예약 안내 줄 — 15단계): POST /scheduled-messages/{id}/release(취소가 아니다) → 쓰기 화면의 보낸 뒤 알림과 같은 알림 + [예약 취소]와 같은 이동.
* 보내지 못했거나 결과를 모르면(503 MAIL_001 · SYS_001) 스토어가 그 메일을 다시 받아 가른 결과대로 알린다 (sendNow)
* - [삭제](아직 보내지 않은 메일 — 보내는 중 · 예약, 확인 창 뒤): 발송을 취소한 뒤 휴지통으로 + "보내지 않고 휴지통으로 옮겼습니다."
* 같은 메일의 취소가 이미 진행 중이면(빠르게 두 번 누름 · 두 곳에서 누름) 두 번째는 아무것도 하지 않는다 (스토어도 요청을 하나만 보낸다).
* 세션은 일을 시작할 때 잡는다 — 기다리는 사이 바뀌었으면(로그아웃 · 다른 계정) 이동 · 알림을 하지 않는다(앞 사용자의 메일을 새 세션에 열지 않게).
@@ -26,6 +32,7 @@ export function useScheduledMessageActions() {
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const toast = useToast()
const removal = useMessageRemoval()
@@ -90,6 +97,90 @@ export function useScheduledMessageActions() {
return true
}
/**
* 지금 보낸 뒤의 알림 — 쓰기 화면의 보낸 뒤 알림과 같다 (디자인 원본 Reader15 "[지금 보내기]를 누른 뒤").
* 보내기 취소를 기다리는 중이면 "메일을 보냈습니다." + [보내기 취소](마감 알림 — 남은 시간 막대, 설정한 초로 센다),
* 바로 보냈거나 이미 보낸 메일이면 "메일을 보냈습니다." + [보낸편지함 보기]. 받는 사람 일부가 받지 못하면 그 문장(오류 알림 모양)으로
*/
function notifyReleased(messageId: string, released: ReleasedSend) {
const pending = released.pendingSend
const now = Date.now()
const expiresAt =
pending?.kind === 'undo'
? undoToastDeadline({
sendAt: pending.sendAt,
now,
undoSendSeconds: settingsStore.knownUndoSendSeconds,
marginMs: UNDO_SEND_DEADLINE_MARGIN_MS,
maxMs: UNDO_SEND_MAX_MS,
})
: Number.NaN
if (pending?.kind === 'undo' && expiresAt > now) {
const undo = { label: SEND_LATER_TEXT.undo, run: () => void undoSend(messageId) }
const partial = partialSendMessage(released.rejectedRecipients, { pendingKind: 'undo' })
if (partial) toast.error(partial, undo, { expiresAt })
else toast.success(MAIL_SENT_MESSAGE, undo, { expiresAt })
return
}
const sentMailbox = mailStore.mailboxByRole('sent')
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
const partial = partialSendMessage(released.rejectedRecipients)
if (partial) {
toast.error(partial, sentLocation ? { label: MAIL_PARTIAL_SEND_TEXT.action, to: sentLocation } : undefined)
return
}
toast.success(MAIL_SENT_MESSAGE, sentLocation ? { label: MAIL_SENT_ACTION_LABEL, to: sentLocation } : undefined)
}
/** 지금 보내지 못해 임시보관함으로 되돌아갔다 — "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] */
function notifyReverted() {
const drafts = mailStore.mailboxByRole('drafts')
const draftsLocation = drafts ? { name: 'mail-mailbox', params: { mailboxId: drafts.id } } : null
toast.error(
SEND_LATER_TEXT.sendNowReverted,
draftsLocation ? { label: SEND_LATER_TEXT.viewDrafts, to: draftsLocation } : undefined,
)
}
/**
* [지금 보내기](예약 안내 줄 — 15단계) — 예약을 거두고 같은 메일을 지금 보낸다([보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 붙잡았다 보낸다).
* 확인 창은 두지 않는다. 보낸 메일은 예약됨에서 빠지므로 분할 보기는 다음 예약 메일을, 목록 보기는 예약됨 목록을 연다([예약 취소]와 같다).
* 보낸편지함에서 연 메일이면 그 자리에 둔다(날짜 칸이 "보내는 중"이 된다).
* 보내지 못했거나 결과를 모르면(503 MAIL_001 · SYS_001 — 스토어가 그 메일을 다시 받아 가른다):
* - 임시보관함으로 되돌아갔다: "…임시보관함으로 옮겼습니다." + [임시보관함 보기] (예약됨을 떠났다 — 이동은 위와 같다)
* - 예약이 그대로다(확인했다): "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도](같은 요청 — 안내 줄은 그대로 둔다)
* - 예약만 풀렸다(보낸편지함에 그대로 · 기다리는 발송이 없다): "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도] —
* 이미 나갔을 수 있어(보내기 취소 0초 · 응답만 잃음) 못 보냈다고 단정하지 않는다. 예약이 아닌 것은 확인했으니 안내 줄이 사라지고 예약됨에서도 빠진다(이동은 위와 같다)
* - 다시 받지 못했다: 같은 "확인하지 못했습니다" 글 + [다시 시도] — 안내 줄은 마지막으로 안 그대로 둔다
* - 다시 낸 발송이 보내기 취소를 기다린다: 보낸 것과 같다
* [다시 시도]는 같은 요청이다 — 이미 보낸 메일이면 서버가 200 으로 답하므로 두 통이 가지 않는다.
* 안내 줄이 사라지면(보냄 · 되돌림 · 예약만 풀림) true — 그 밖의 실패(인터셉터가 알렸다) · 예약이 그대로 · 다시 받지 못함 · 그사이 세션이 바뀌었으면 false
*/
async function sendNow(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
if (mailStore.isReleasingPendingSend(message.id) || mailStore.isCancellingPendingSend(message.id)) return false
const session = authStore.generation
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
let outcome: ReleaseOutcome
try {
outcome = await mailStore.releasePendingSend(message.id)
} catch {
return false
}
if (authStore.generation !== session) return false
const retry = { label: SEND_LATER_TEXT.retry, run: () => void sendNow(message, layout) }
if (outcome.status === 'retryable' || outcome.status === 'unknown') {
toast.error(outcome.status === 'retryable' ? SEND_LATER_TEXT.sendNowFailed : SEND_LATER_TEXT.sendNowUnconfirmed, retry)
return false
}
if (outcome.status === 'unscheduled') toast.error(SEND_LATER_TEXT.sendNowUnconfirmed, retry)
else if (outcome.status === 'reverted') notifyReverted()
else notifyReleased(message.id, outcome.released)
const removed = removedFromList(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
return true
}
/**
* [삭제] — 아직 보내지 않은 메일(보내는 중 · 예약)의 확인 창에서 「삭제」를 누른 뒤에만 부른다.
* 발송을 취소한 뒤 휴지통으로 옮긴다 (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다)
@@ -114,5 +205,5 @@ export function useScheduledMessageActions() {
return true
}
return { undoSend, editScheduled, cancelScheduled, deletePendingSend }
return { undoSend, editScheduled, cancelScheduled, sendNow, deletePendingSend }
}
+18 -2
View File
@@ -1,4 +1,5 @@
import { notifyApiError } from './useApi'
import { useSenderBlockRun } from './useSenderBlockRun'
import { useToast } from './useToast'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { MessageSummary } from '@/shared/types/mail'
@@ -23,6 +24,7 @@ export interface SenderBlockOutcome {
* 보낸 사람 차단 · 풀기와 그 알림 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면 · 설정 › 차단한 주소").
* - 차단(읽기 화면의 확인 창): POST /mail-blocks → 「이 메일도 스팸메일함으로 옮기기」를 켰으면 그 메일을 스팸메일함으로(isJunk: true — 스팸 신고와 같은 길) →
* "‘주소’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 받은편지함에서 막았으면 받은편지함)
* 「받은편지함에 있는 이 보낸 사람의 메일도 …」(15단계)를 켰으면 그 알림 대신 받은편지함 정리의 진행 알림 하나 (useSenderBlockRun — [되돌리기] 없음)
* - 풀기(⋯ 「보낸 사람 차단 풀기」 · 스팸메일함의 [차단 풀기] · 설정의 [차단 풀기]): DELETE → "‘주소’ 차단을 풀었습니다." + [되돌리기](다시 차단).
* 이미 받은 메일은 옮기지 않는다. 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 (스토어가 가른다)
* 요청 실패는 인터셉터가 알린다 — 여기서는 알리지 않는다. 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast).
@@ -33,6 +35,7 @@ export function useSenderBlock() {
const blockStore = useMailBlockStore()
const mailStore = useMailStore()
const toast = useToast()
const blockRun = useSenderBlockRun()
/** 지금 세션에서 시작한 일이 그대로인지 — 시작할 때 잡아 둔 세대와 견준다 */
function sessionGuard(): () => boolean {
@@ -54,9 +57,16 @@ export function useSenderBlock() {
}
/**
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다)
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다).
* cleanInbox(15단계 — 차단 창의 새 칸)면 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일도 옮기기 시작한다 — 차단 알림 대신 진행 알림 하나
* (useSenderBlockRun — [되돌리기] 없음, 기다리지 않는다). 이 메일을 먼저 옮겼으면(받은편지함에 있었다) 그 한 통도 옮긴 수에 넣는다
*/
async function block(message: MessageSummary, target: string, moveToJunk: boolean): Promise<SenderBlockOutcome> {
async function block(
message: MessageSummary,
target: string,
moveToJunk: boolean,
cleanInbox = false,
): Promise<SenderBlockOutcome> {
const sameSession = sessionGuard()
let address: string
try {
@@ -68,6 +78,8 @@ export function useSenderBlock() {
let origins: BulkOrigin[] = []
let moved = false
let stale = false
const inboxId = mailStore.mailboxByRole('inbox')?.id
const wasInInbox = inboxId !== undefined && message.mailboxIds.includes(inboxId)
if (moveToJunk) {
const junk = await mailStore.resolveMailboxByRole('junk')
if ('error' in junk) {
@@ -80,6 +92,10 @@ export function useSenderBlock() {
}
}
if (!sameSession()) return { blocked: true, moved: false, stale }
if (cleanInbox) {
blockRun.start(address, { alreadyMoved: moved && wasInInbox ? 1 : 0 })
return { blocked: true, moved, stale }
}
const particle = objectParticle(address)
toast.success(
moved ? MAIL_BLOCK_TEXT.blockedAndMoved(address, particle) : MAIL_BLOCK_TEXT.blocked(address, particle),
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailBlockRun, MailBlockRunRequest, Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useSenderBlockRun } from './useSenderBlockRun'
// 차단할 때 받은편지함의 메일도 스팸메일함으로 — 알림 하나가 진행을 보이다 결과로 · [다시 시도] · 끝난 뒤 개수 · 목록 맞추기
// (디자인 원본 Mailbox15 "차단한 뒤 알림" · 계약 2절 "보낸 사람 차단" — 15단계)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const SENDER = 'news@example.com'
function mailbox(id: string, name: string, role: Mailbox['role'] = null, totalCount = 0): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
}
const MAILBOXES: Mailbox[] = [mailbox('inbox', '받은편지함', 'inbox', 4800), mailbox('junk', '스팸메일함', 'junk', 5)]
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
const run: MailBlockRun = { scanned, matched, remaining, cursor }
return ok(run)
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('useSenderBlockRun', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
useMailStore().mailboxes = MAILBOXES.map((item) => ({ ...item }))
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
function replies(...results: (MockResult | Promise<MockResult>)[]) {
let next = 0
requests = installMockApi((request) => {
if (request.url === '/mail-block-runs') {
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
next += 1
return result
}
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok(null)
})
}
const runs = () => requests.filter((request) => request.url === '/mail-block-runs')
const bodies = () => runs().map((request) => request.data as MailBlockRunRequest)
const shown = () => useToastStore().toasts.map((toast) => [toast.kind, toast.message])
const withAction = () => useToastStore().toasts.find((toast) => toast.action !== null)
const listRequests = () => requests.filter((request) => request.method === 'GET' && request.url === '/messages')
function press(toast: Toast | undefined) {
if (toast?.action && 'run' in toast.action) toast.action.run()
}
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…차단하고 받은편지함의 메일 37통을 …옮겼습니다." + [스팸메일함 보기] · [되돌리기] 없음', async () => {
const second = deferred()
replies(step(1200, 10, 3600, 'b1'), second.promise)
useSenderBlockRun().start(SENDER)
expect(shown()).toEqual([
['progress', '‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 0 / 4,800통 확인'],
])
await flushPromises()
expect(shown()).toEqual([
['progress', '‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인'],
])
second.finish(step(3600, 27, 0, null))
await flushPromises()
expect(bodies()).toEqual([
{ sender: SENDER, cursor: null },
{ sender: SENDER, cursor: 'b1' },
])
const [toast] = useToastStore().toasts
expect(useToastStore().toasts).toHaveLength(1)
expect(toast).toMatchObject({
kind: 'success',
message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.',
action: { label: '스팸메일함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'junk' } } },
})
})
it('먼저 옮긴 이 메일(받은편지함)도 옮긴 수에 넣는다 · 옮길 메일이 없으면 "…옮길 메일이 없습니다."(단추 없음)', async () => {
replies(step(4800, 0, 0, null))
useSenderBlockRun().start(SENDER, { alreadyMoved: 1 })
await flushPromises()
expect(useToastStore().toasts).toMatchObject([
{ kind: 'success', message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 1통을 스팸메일함으로 옮겼습니다.' },
])
useToastStore().clear()
useSenderBlockRun().start('@example.com')
await flushPromises()
expect(useToastStore().toasts).toMatchObject([
{ kind: 'success', message: '‘@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.', action: null },
])
})
it('도중에 멈추면 멈춤 문구 + [다시 시도] — 그 cursor 로 이어서 수를 이어 센다 (까닭은 사전 문구 알림)', async () => {
replies(step(1200, 12, 3600, 'b1'), fail(503, 'MAIL_001'), step(3600, 25, 0, null))
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(shown()).toContainEqual(['error', ERROR_CODE_MESSAGES.MAIL_001])
const stopped = withAction()
expect(stopped).toMatchObject({
kind: 'error',
message:
'‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
useToastStore().clear()
press(stopped)
await flushPromises()
expect(bodies().map((body) => body.cursor)).toEqual([null, 'b1', 'b1'])
expect(useToastStore().toasts).toMatchObject([
{ message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.' },
])
})
it('처음 요청부터 실패하면 "…차단했지만 받은편지함의 메일은 옮기지 못했습니다." + [다시 시도] — 처음부터 (먼저 옮긴 수는 그대로)', async () => {
replies(fail(500, 'SYS_001'), step(4800, 3, 0, null))
useSenderBlockRun().start(SENDER, { alreadyMoved: 1 })
await flushPromises()
const failed = withAction()
expect(failed).toMatchObject({
kind: 'error',
message: '‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
useToastStore().clear()
press(failed)
await flushPromises()
expect(bodies().map((body) => body.cursor)).toEqual([null, null])
expect(useToastStore().toasts).toMatchObject([
{ message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.' },
])
})
it('다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중이면(429) 그 알림 하나 — 처음 요청이어도 [다시 시도]를 둔다 (차단한 뒤에는 다시 고를 길이 없다)', async () => {
replies(fail(429, 'REQ_001'), step(4800, 2, 0, null))
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(useToastStore().toasts).toHaveLength(1)
const busy = withAction()
expect(busy).toMatchObject({
kind: 'error',
message: '‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
press(busy)
await flushPromises()
expect(bodies().map((body) => body.cursor)).toEqual([null, null])
})
it('이 탭에서 필터를 적용하는 중이면 시작하지 않고 알린다 — 그 알림의 [다시 시도]로 앞 정리가 끝난 뒤 다시 한다', async () => {
replies(step(4800, 1, 0, null))
const filterRun = useMailFilterRunStore()
filterRun.running = true
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(runs()).toEqual([])
const busy = withAction()
expect(busy).toMatchObject({
kind: 'error',
message: '‘news@example.com’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
filterRun.running = false
press(busy)
await flushPromises()
expect(runs()).toHaveLength(1)
})
it('이 탭에서 스팸메일함을 비우는 중이면 시작하지 않고 그 까닭으로 알린다 — 비우기가 끝난 뒤 그 알림의 [다시 시도]로 한다', async () => {
const emptying = deferred()
requests = installMockApi((request) => {
if (request.method === 'DELETE' && request.url === '/mailboxes/junk/messages') return emptying.promise
if (request.url === '/mail-block-runs') return step(4800, 1, 0, null)
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
})
const mail = useMailStore()
const junk = mail.mailboxByRole('junk')
if (!junk) throw new Error('스팸메일함이 없다')
const empty = mail.runMailboxOperation('empty', junk, () => undefined)
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(runs()).toEqual([])
const busy = withAction()
expect(busy).toMatchObject({
kind: 'error',
message: '‘news@example.com’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
emptying.finish(ok({ processed: 5, remaining: 0 }))
await empty
press(busy)
await flushPromises()
expect(runs()).toHaveLength(1)
})
it('그사이 차단을 풀었으면(404 RES_001) 사전 문구만 — 진행 알림은 닫는다', async () => {
replies(fail(404, 'RES_001'))
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(shown()).toEqual([['error', ERROR_CODE_MESSAGES.RES_001]])
})
it('끝나면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다 — 다른 메일함이면 개수만', async () => {
replies(step(4800, 2, 0, null))
const mailStore = useMailStore()
mailStore.listSource = { kind: 'mailbox', mailboxId: 'junk' }
mailStore.listStatus = 'ready'
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(requests.some((request) => request.url === '/mailboxes')).toBe(true)
expect(listRequests().map((request) => request.params.mailboxId)).toEqual(['junk'])
mailStore.listSource = { kind: 'mailbox', mailboxId: 'other' }
useSenderBlockRun().start(SENDER)
await flushPromises()
expect(listRequests()).toHaveLength(1)
})
it('세션이 바뀌면 진행 알림을 닫고 다음 요청을 보내지 않는다', async () => {
const first = deferred()
replies(first.promise, step(10, 0, 0, null))
useSenderBlockRun().start(SENDER)
useAuthStore().clearAuth()
await nextTick()
first.finish(step(1200, 12, 3600, 'b1'))
await flushPromises()
expect(runs()).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
})
})
@@ -0,0 +1,125 @@
import { useToast, type ProgressToast } from './useToast'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { mailBlockRunCopy, type MailBlockRunCopy, type MailBlockRunCounts } from '@/shared/utils/mail-block'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore, type MailBlockRunOutcome } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import type { ToastAction } from '@/stores/toast.store'
// 차단할 때 받은편지함에 이미 있는 그 보낸 사람의 메일도 스팸메일함으로 — 진행 · 결과 알림
// (디자인 README "15단계 › 보낸 사람 차단 창" · 디자인 원본 Mailbox15 "차단한 뒤 알림" — 7단계 필터 적용 useMailFilterRun 과 같은 결)
// - 차단 창에서 새 칸을 켜고 [차단]하면 차단 알림을 따로 띄우지 않는다. 알림 하나가 "‘…’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 ·
// 1,200 / 4,800통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)으로 남았다가 끝나면 그 자리에서 결과로 바뀐다. [되돌리기]는 없다
// - 끝: "‘…’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다." + [스팸메일함 보기] · 옮긴 메일이 없으면 "…옮길 메일이 없습니다."(단추 없음)
// - 멈춤(이어 할 자리가 있다): "…4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요." + [다시 시도](그 cursor 부터 — 수는 이어 센다)
// - 처음 요청부터 실패: "…차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
// - 429 REQ_001(다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중 — 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다):
// "…차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. …" + [다시 시도] —
// 차단한 뒤에는 ⋯ 메뉴가 「차단 풀기」로 바뀌어 다시 고를 길이 이것뿐이라 처음 요청이어도 단추를 둔다(필터 적용과 다르다)
// - 그사이 차단을 풀었으면(404 RES_001 — 사전 문구로 알렸다) 진행 알림만 닫는다
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다. 단추는 띄운 세션에서만 움직인다 (useToast)
// - 이 메일을 「이 메일도 …」 칸으로 먼저 옮겼으면(받은편지함의 메일) 그 한 통도 옮긴 수에 넣는다 (alreadyMoved — 서버는 그 뒤에 받은편지함을 본다)
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록(받은편지함 · 스팸메일함을 보고 있으면)을 맞춘다
// - 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리)이면 시작하지 않고 "…받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." +
// 같은 [다시 시도](같은 자리 · 같은 수), 스팸메일함을 비우는 중이면 "…스팸메일함을 비우는 중입니다. …" + 같은 [다시 시도] (스토어의 startBlock) —
// 차단 창은 그동안 새 칸을 끄므로 이 길은 앞 알림의 [다시 시도]에서 온다
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌린다. 세션이 바뀌면 멈추고 알림을 닫는다
/** 시작할 자리 — 처음부터(cursor null · 수 0 — 먼저 옮긴 이 메일은 센다) 또는 앞 실패의 자리 */
type RunFrom = MailBlockRunCounts & { cursor: string | null }
type FailedOutcome = Extract<MailBlockRunOutcome, { status: 'failed' }>
export interface SenderBlockRunOptions {
/** 차단 창의 「이 메일도 …」로 먼저 옮긴 받은편지함의 메일 수 (0 · 1) — 옮긴 수에 더한다 */
alreadyMoved?: number
}
export function useSenderBlockRun() {
const runStore = useMailBlockRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
/** 처음부터 — 진행 알림의 전체는 받은편지함의 메일 수 (메일함을 아직 받지 않았으면 0 — 수를 뺀다) */
function fromStart(alreadyMoved: number): RunFrom {
const inboxTotal = Math.max(0, mailStore.mailboxByRole('inbox')?.totalCount ?? 0)
return { cursor: null, scanned: 0, matched: Math.max(0, alreadyMoved), total: inboxTotal }
}
/** 끝 알림의 [스팸메일함 보기] — 옮긴 메일이 있고 스팸메일함을 알 때만 */
function viewJunkAction(matched: number): ToastAction | undefined {
const junk = matched > 0 ? mailStore.mailboxByRole('junk') : null
if (!junk) return undefined
return { label: MAIL_BLOCK_TEXT.viewJunk, to: listLocationOf({ kind: 'mailbox', mailboxId: junk.id }) }
}
function retryAction(sender: string, from: RunFrom): ToastAction {
return { label: MAIL_BLOCK_TEXT.runRetry, run: () => void execute(sender, from) }
}
/**
* 옮긴 뒤 개수 · 목록을 맞춘다 (필터 적용의 syncAfterRun 과 같은 결) — 메일함 개수 · 사용량을 다시 받고,
* 보이는 목록이 받은편지함 · 스팸메일함(그 안의 검색 포함)이면 그 목록도 맞춘다. 전체 메일함 검색은 건드리지 않는다
*/
function syncAfterRun() {
mailStore.syncCounts()
const scope = scopeMailboxIdOf(mailStore.listSource)
if (scope === null) return
const touched = [mailStore.mailboxByRole('inbox')?.id, mailStore.mailboxByRole('junk')?.id]
if (touched.includes(scope)) void mailStore.syncVisibleList({ includeSearch: true })
}
/** 멈췄다 — 이어 할 자리가 있으면 그 자리에서, 없으면 처음부터 다시 하는 단추 (다른 창에서 하는 중일 때도 단추를 둔다) */
function settleFailure(
sender: string,
copy: MailBlockRunCopy,
from: RunFrom,
outcome: FailedOutcome,
progress: ProgressToast,
) {
const { cursor, scanned, matched, total } = outcome
const resume = cursor !== null ? retryAction(sender, { cursor, scanned, matched, total }) : null
if (outcome.error.code === 'RES_001') progress.dismiss()
else if (outcome.error.code === 'REQ_001') progress.fail(copy.otherWindow, resume ?? retryAction(sender, from))
else if (resume) progress.fail(copy.stopped(outcome), resume)
else progress.fail(copy.notMoved, retryAction(sender, from))
}
/** 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중이면 시작하지 않고 알린다 */
async function execute(sender: string, from: RunFrom): Promise<void> {
const copy = mailBlockRunCopy(sender)
const block = runStore.startBlock
if (block !== null) {
toast.error(block === 'junk' ? copy.junkBusy : copy.busy, retryAction(sender, from))
return
}
const session = authStore.generation
const progress = toast.progress(copy.progress(from))
const outcome = await runStore.run({ ...from, sender }, (tally) => progress.update(copy.progress(tally)))
// 세션이 바뀌었다(진행 알림은 이미 닫혔다 — 다음 사람의 목록을 맞추지 않는다) · 이 탭의 다른 정리와 겹쳤다(위에서 막는다)
if (outcome.status === 'canceled' || outcome.status === 'busy' || authStore.generation !== session) {
progress.dismiss()
return
}
if (outcome.status === 'done') {
progress.succeed(copy.done(outcome.matched), viewJunkAction(outcome.matched))
syncAfterRun()
return
}
settleFailure(sender, copy, from, outcome, progress)
// 이번에 나아간 것이 있으면 — 거기까지 바뀐 개수 · 목록을 맞춘다
if (outcome.scanned > from.scanned) syncAfterRun()
}
/**
* 차단(그 값 그대로 — 소문자 주소 또는 "@도메인")한 뒤 받은편지함을 정리하기 시작한다 — 기다리지 않는다(알림이 진행 · 결과를 보인다).
* 차단 창이 새 칸을 켰을 때만 부른다
*/
function start(sender: string, options: SenderBlockRunOptions = {}): void {
void execute(sender, fromStart(options.alreadyMoved ?? 0))
}
return { start }
}
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, type PropType } from 'vue'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { useIgnoredWindowFileDrop, useWindowFileDrop } from './useWindowFileDrop'
// 15단계 — 메일 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 메일 화면(MailLayout)은 파일 놓기를 무시하고,
// 쓰기 화면(useWindowFileDrop — 같은 메일 화면 안)이 떠 있으면 그 화면이 지금처럼 첨부로 받는다
/** 메일 화면(파일 놓기 무시) — receive 를 주면 쓰기 화면(파일을 첨부로 받는다) */
const Screen = defineComponent({
props: {
receive: { type: Function as PropType<((files: File[]) => void) | null>, default: null },
},
setup(props) {
if (props.receive) useWindowFileDrop(props.receive)
else useIgnoredWindowFileDrop()
return () => h('div')
},
})
describe('useIgnoredWindowFileDrop — 메일 화면의 파일 놓기 무시', () => {
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountScreen(receive: ((files: File[]) => void) | null = null) {
const wrapper = mount(Screen, { props: { receive }, attachTo: document.body })
mounted.push(wrapper)
return wrapper
}
it('파일을 끌어 오면 놓을 수 없는 모양(dropEffect none)이고, 놓아도 브라우저가 열지 않는다(기본 동작을 막는다)', () => {
mountScreen()
const transfer = fakeDataTransfer([new File(['x'], '보고서.pdf')])
const over = dragEvent('dragover', transfer)
document.body.dispatchEvent(over)
expect(over.defaultPrevented).toBe(true)
expect(transfer.dropEffect).toBe('none')
const drop = dragEvent('drop', transfer)
document.body.dispatchEvent(drop)
expect(drop.defaultPrevented).toBe(true)
})
it('파일이 아닌 끌기(글 · 메일 끌기)는 건드리지 않는다', () => {
mountScreen()
const transfer = fakeDataTransfer()
transfer.setData('text/plain', '글')
const over = dragEvent('dragover', transfer)
document.body.dispatchEvent(over)
expect(over.defaultPrevented).toBe(false)
const drop = dragEvent('drop', transfer)
document.body.dispatchEvent(drop)
expect(drop.defaultPrevented).toBe(false)
})
it('쓰기 화면이 떠 있으면(나중에 걸려도 먼저 걸려도) 쓰기 화면이 파일을 첨부로 받는다', () => {
// 메일 화면이 먼저 뜨고 쓰기 화면이 뒤에 뜬다 — 창 처리기는 메일 화면 것이 먼저 돈다
mountScreen()
const onFiles = vi.fn()
const compose = mountScreen(onFiles)
const report = new File(['x'], '보고서.pdf')
const transfer = fakeDataTransfer([report])
document.body.dispatchEvent(dragEvent('dragover', transfer))
expect(transfer.dropEffect).toBe('copy')
document.body.dispatchEvent(dragEvent('drop', transfer))
expect(onFiles).toHaveBeenCalledWith([report])
// 쓰기 화면을 떠나면 다시 무시한다
compose.unmount()
mounted.splice(mounted.indexOf(compose), 1)
const later = fakeDataTransfer([report])
const over = dragEvent('dragover', later)
document.body.dispatchEvent(over)
expect(over.defaultPrevented).toBe(true)
expect(later.dropEffect).toBe('none')
expect(onFiles).toHaveBeenCalledTimes(1)
})
})
+47 -2
View File
@@ -2,6 +2,13 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { isFileDrag } from '@/shared/utils/editor-files'
/**
* 지금 떠 있는 파일 받는 곳의 수 (쓰기 화면 · 서명 설정 — useWindowFileDrop).
* 메일 화면의 "파일 놓기 무시"(useIgnoredWindowFileDrop)는 받는 곳이 없을 때만 막는다 — 쓰기 화면도 메일 화면 안이라
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면의 첨부 놓기가 이기게 (15단계)
*/
let activeReceivers = 0
/**
* 화면 어디에 놓아도 파일을 받는다 — 이 화면이 떠 있는 동안 window 에서 파일 끌기를 받는다
* (디자인 README "본문 이미지 → 첨부 파일": 쓰기 화면 어디에 놓아도 첨부로 올린다).
@@ -46,6 +53,46 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
['drop', onDrop],
] as const
onMounted(() => {
activeReceivers += 1
for (const [type, listener] of listeners) window.addEventListener(type, listener)
})
onBeforeUnmount(() => {
activeReceivers = Math.max(0, activeReceivers - 1)
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
})
return { dragging }
}
/**
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 메일 화면 · MailLayout).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 메일 목록 · 읽기 화면에 파일을 잘못 놓아도 아무 일 없게.
* - 놓는 동안 마우스 커서는 놓을 수 없는 모양이다(dropEffect none — 메일함 패널도 파일은 받지 않는다)
* - 파일을 받는 화면(쓰기 화면 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면의 첨부 놓기에 맡긴다)
* - 글 · 메일 끌기(목록 줄을 메일함 패널로 — useMailDrag)는 건드리지 않는다
*/
export function useIgnoredWindowFileDrop() {
function ignores(event: DragEvent): boolean {
return activeReceivers === 0 && !event.defaultPrevented && isFileDrag(event)
}
function onDragOver(event: DragEvent) {
if (!ignores(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'
}
function onDrop(event: DragEvent) {
if (ignores(event)) event.preventDefault()
}
const listeners = [
['dragover', onDragOver],
['drop', onDrop],
] as const
onMounted(() => {
for (const [type, listener] of listeners) window.addEventListener(type, listener)
})
@@ -53,6 +100,4 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
onBeforeUnmount(() => {
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
})
return { dragging }
}
+8
View File
@@ -11,10 +11,13 @@
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
// 메일 화면에 파일을 잘못 놓아도 아무 일 없게 한다(15단계 — 브라우저가 파일을 열어 웹메일을 떠나지 않게). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다
// (useIgnoredWindowFileDrop — 받는 화면이 떠 있으면 그쪽에 맡긴다)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import MailDragChip from '@/components/mail/MailDragChip.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
@@ -24,6 +27,7 @@ import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -68,6 +72,8 @@ useMailStatePolling({
useMailTabTitle()
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
usePendingSendRefresh({ clock })
// 목록 · 읽기 화면에 놓은 파일은 받지 않는다 (쓰기 화면이 떠 있으면 그 화면이 첨부로 받는다)
useIgnoredWindowFileDrop()
</script>
<template>
@@ -82,4 +88,6 @@ usePendingSendRefresh({ clock })
<RouterView />
</AppShell>
<MailContactDialogHost />
<!-- 목록 줄을 메일함 패널로 끄는 동안 마우스를 따라오는 표 (15단계 — 화면 밖에 그려 둔다) -->
<MailDragChip />
</template>
@@ -17,6 +17,7 @@ import type {
MessageDetail,
} from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
@@ -953,7 +954,8 @@ describe('MailFilterPage', () => {
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
// 차단 창의 새 칸과 같은 글 (15단계에 "다른 필터로 …"에서 바꿨다)
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
await click(wrapper.get('#mail-filter-cancel'))
// 다른 화면으로 가도 이어 한다
@@ -969,6 +971,21 @@ describe('MailFilterPage', () => {
expect(applyBox(again).attributes('disabled')).toBeUndefined()
})
it('이 탭에서 차단할 때 받은 메일을 옮기는 동안(15단계)도 칸을 끄고 같은 까닭 — 끝나면 다시 고를 수 있다', async () => {
const { wrapper } = await mountPage()
const blockRun = useMailBlockRunStore()
blockRun.running = true
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
blockRun.running = false
await flushPromises()
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyHelp(wrapper)).toBe(MAIL_FILTER_APPLY_TEXT.help)
})
it('「적용」을 고르고 저장하는 중에 창을 닫거나 화면을 떠나도 — 저장이 끝나면 적용한다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise, run: () => step(8412, 37, 0, null) })
@@ -13,7 +13,8 @@
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
// 이 탭에서 받은편지함을 정리하는 중이면(필터 적용 · 차단할 때 받은 메일 옮기기 — 15단계) 창의 그 칸을 끈다
// (mail-block-run.store 의 inboxBusy — 차단 창의 새 칸과 같은 규칙, 화면을 떠나도 정리는 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -32,12 +33,13 @@ import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mai
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const filterRunStore = useMailFilterRunStore()
/** 이 탭에서 받은편지함을 정리하는 중인지 — 필터 적용 · 차단 정리 (inboxBusy) */
const inboxCleanup = useMailBlockRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
@@ -282,7 +284,7 @@ watch(
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
:apply-busy="filterRunStore.running"
:apply-busy="inboxCleanup.inboxBusy"
@save="(rule, apply) => settleDialog(saveDialog(rule, { apply }))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"
@@ -1,8 +1,19 @@
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면" · "설정 · 차단한 주소", 원본 MailReadTools · SettingsSendRead)
// 15단계 — 차단할 때 받은편지함에 이미 있는 그 보낸 사람의 메일도 옮기기 (디자인 README "15단계", 원본 Mailbox15 "보낸 사람 차단 창" · "차단한 뒤 알림")
import { MAIL_FILTER_RUN_TIMEOUT_MS } from '@/shared/constants/mail'
import { INBOX_CLEANUP_TEXT, MAIL_FILTER_RUN_MAX_STALLS } from '@/shared/constants/mail-filter'
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 서버도 400 VAL_001 */
export const MAIL_BLOCK_MAX = 500
/**
* 받은 메일 옮기기(POST /mail-block-runs)의 요청 하나 — 서버가 필터 적용과 같은 규칙(한 요청 25초 안 · cursor)으로 하니 같은 한도를 쓴다
*/
export const MAIL_BLOCK_RUN_TIMEOUT_MS = MAIL_FILTER_RUN_TIMEOUT_MS
/** 한 번도 나아가지 못한 응답이 이만큼 이어지면 멈춘다 — 필터 적용과 같은 안전장치 */
export const MAIL_BLOCK_RUN_MAX_STALLS = MAIL_FILTER_RUN_MAX_STALLS
/** 도메인 차단의 앞 글자 — "@spam.example" 은 그 도메인에서 오는 메일 모두 */
export const MAIL_BLOCK_DOMAIN_PREFIX = '@'
@@ -65,6 +76,19 @@ export const MAIL_BLOCK_TEXT = {
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
/** 새 칸을 켰고 이 메일이 받은편지함에 있을 때 — 첫 칸을 켠 채 잠그고 그 아래 (15단계) */
moveToJunkLocked: '받은편지함의 메일을 옮길 때 함께 옮깁니다.',
/** 15단계 새 칸 — 처음 꺼짐. 「이 도메인 전체」를 고르면 도메인 글로 바뀐다 */
cleanInboxSender: '받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
cleanInboxDomain: '받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
cleanInboxNote: '옮긴 메일은 한꺼번에 되돌릴 수 없습니다.',
/** 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리) — 새 칸을 끄고 칸 아래 글을 까닭으로 (필터 고치기 창의 칸과 같은 글) */
cleanInboxBusy: INBOX_CLEANUP_TEXT.busy,
/**
* 이 탭에서 스팸메일함을 비우는 중 — 새 칸을 끄고 칸 아래 글을 까닭으로 (보드에 없다 — 15단계 리뷰, 위 공용 글과 같은 결).
* 옮겨 넣은 메일이 들어오자마자 영구 삭제되지 않게
*/
cleanJunkBusy: '스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.',
settingsHint: '차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.',
confirm: '차단',
cancel: '취소',
@@ -81,6 +105,39 @@ export const MAIL_BLOCK_TEXT = {
unblockRestored: (address: string, particle: string) => `‘${address}’${particle} 다시 차단했습니다.`,
undo: '되돌리기',
// ── 새 칸을 켠 차단 — 알림 하나가 진행을 보이다가 그 자리에서 결과로 (15단계, [되돌리기] 없음) ──
// particle 은 objectParticle, 수는 formatCount 로 만든 글자. 확인 = 지금까지 확인한 수, 전체 = 확인 + 남은 수(첫 응답 전에는 받은편지함의 메일 수)
runProgress: (address: string, particle: string) =>
`‘${address}’${particle} 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중`,
/** 진행 문구 뒤 — 전체를 아직 모르면 뺀다 */
runProgressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
runDone: (address: string, particle: string, matched: string) =>
`‘${address}’${particle} 차단하고 받은편지함의 메일 ${matched}통을 스팸메일함으로 옮겼습니다.`,
/** 옮길 메일이 없었다 */
runNone: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.`,
/** 도중에 멈췄다 — [다시 시도]는 멈춘 자리부터 */
runStopped: (address: string, particle: string, total: string, scanned: string, matched: string) =>
`‘${address}’${particle} 차단했습니다. 받은편지함은 ${total}통 가운데 ${scanned}통까지 확인해 ${matched}통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.`,
/** 처음 요청부터 실패했다 — [다시 시도]는 처음부터 */
runNotMoved: (address: string, particle: string) =>
`‘${address}’${particle} 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.`,
/**
* 429 REQ_001 — 같은 직원의 받은편지함 정리(필터 적용 · 다른 차단 정리)나 스팸메일함 비우기가 다른 창 · PC 에서 진행 중 (15단계 리뷰 —
* 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다. 필터 적용의 429 글은 받은편지함만 말한다 — INBOX_CLEANUP_TEXT.otherWindow)
*/
runOtherWindow: (address: string, particle: string) =>
`‘${address}’${particle} 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.`,
/**
* 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리)에 [다시 시도]를 눌렀다 (보드에 없다 — 필터 적용의 busy 와 같은 공용 글).
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게 한다
*/
runBusy: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다. ${INBOX_CLEANUP_TEXT.tabBusy}`,
/** 이 탭에서 스팸메일함을 비우는 중에 [다시 시도]를 눌렀다 (보드에 없다 — runBusy 와 같은 결, 같은 [다시 시도]를 붙인다) */
runJunkBusy: (address: string, particle: string) =>
`‘${address}’${particle} 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.`,
runRetry: '다시 시도',
viewJunk: '스팸메일함 보기',
// ── 스팸메일함에서 읽을 때 ──
senderNotice: '차단한 보낸 사람의 메일입니다.',
unblock: '차단 풀기',
@@ -54,6 +54,12 @@ export const MAIL_CONVERSATION_TEXT = {
bodyTitle: (who: string, time: string) => `메일 본문: ${who}, ${time}`,
more: '더 보기',
moreBusy: '불러오는 중…',
/**
* 칸 머리 오른쪽의 글자 단추 (15단계 — 디자인 원본 Reader15 "6. 같은 대화") — 하나라도 접혀 있으면 [모두 펼치기], 모두 펼쳐져 있으면(하나씩 다 펼쳐도) [모두 접기].
* 펼친 줄 머리의 [답장]은 위 reply 와 같은 글이다
*/
expandAll: '모두 펼치기',
collapseAll: '모두 접기',
loadFailed: '같은 대화를 불러오지 못했습니다.',
retry: '다시 시도',
traceCode: (code: string) => `문의 코드 ${code}`,
+25 -7
View File
@@ -166,10 +166,24 @@ export const MAIL_FILTER_DIALOG_TEXT = {
actionsEmpty: '할 일을 하나 이상 골라 주세요.',
} as const
/**
* 받은편지함 정리 — 이미 받은 메일에 필터 적용(7단계)과 차단할 때 받은 메일 옮기기(15단계)가 함께 쓰는 글.
* 같은 직원의 받은편지함 정리는 한 번에 하나다 — 다른 창 · PC 에서 하는 중이면 서버가 429 REQ_001 (디자인 원본 Mailbox15 "차단한 뒤 알림").
* otherWindow 는 필터 적용의 429 글이다 — 차단 정리의 429 는 스팸메일함 비우기도 말한다(MAIL_BLOCK_TEXT.runOtherWindow — 15단계 리뷰).
* 이 탭에서 하나라도 도는 동안은 필터 고치기 창의 칸과 차단 창의 새 칸을 끄고 그 까닭(busy)을 칸 아래에 쓴다 (디자인 원본 Mailbox15 "보낸 사람 차단 창") —
* 차단 창의 새 칸은 이 탭에서 스팸메일함을 비우는 동안에도 끈다(MAIL_BLOCK_TEXT.cleanJunkBusy)
*/
export const INBOX_CLEANUP_TEXT = {
otherWindow: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
busy: '받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.',
/** 이 탭에서 정리하는 중에 [다시 시도]를 눌렀다 — 필터 적용의 알림 · 차단 정리의 알림("…차단했습니다. " 뒤)이 함께 쓴다 */
tabBusy: '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
} as const
/**
* 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
* 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 차단 정리 — 15단계) → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
*/
export const MAIL_FILTER_APPLY_TEXT = {
label: '받은편지함에 이미 있는 메일도 정리하기',
@@ -181,7 +195,8 @@ export const MAIL_FILTER_APPLY_TEXT = {
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.',
/** 받은편지함으로 옮기기만 — 이미 받은편지함에 있는 메일이다 (7단계 리뷰로 더한 경우) */
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
/** 이 탭에서 받은편지함을 정리하는 중 — 차단 창의 새 칸과 같은 글 (15단계에 "다른 필터로 …"에서 바꿨다) */
busy: INBOX_CLEANUP_TEXT.busy,
/** 상자 아래 한 줄 */
spam: '스팸 메일은 필터를 거치지 않습니다.',
} as const
@@ -204,13 +219,16 @@ export const MAIL_FILTER_RUN_TEXT = {
`${quoted} 필터로 ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다. 그동안 ${matched}통을 정리했습니다. 다시 시도해 주세요.`,
/** 처음 요청부터 실패했다 — 필터는 저장되어 있다(새로 오는 메일에는 적용된다) */
notApplied: '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.',
/** 429 REQ_001 — 같은 직원의 적용이 다른 창 · PC 에서 진행 중 */
otherWindow: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
/**
* 이 탭에서 다른 필터를 적용하는 중에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함).
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게 한다
* 429 REQ_001 — 같은 직원의 받은편지함 정리(필터 적용 · 차단할 때 받은 메일 옮기기)가 다른 창 · PC 에서 진행 중 (INBOX_CLEANUP_TEXT).
* 차단 정리의 429 글은 스팸메일함 비우기도 말해 따로다 (MAIL_BLOCK_TEXT.runOtherWindow — 15단계 리뷰)
*/
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
otherWindow: INBOX_CLEANUP_TEXT.otherWindow,
/**
* 이 탭에서 받은편지함을 정리하는 중(다른 필터 적용 · 차단 정리)에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함,
* 15단계에 차단 정리와 함께 쓰는 글로 바꿨다 — INBOX_CLEANUP_TEXT). 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게 한다
*/
busy: INBOX_CLEANUP_TEXT.tabBusy,
retry: '다시 시도',
} as const
@@ -1,5 +1,7 @@
// 보내기 취소 · 예약 발송 · 예약됨 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
/** 예약할 수 있는 가장 이른 때 — 지금부터 5분 뒤 (서버도 같은 한도로 400 VAL_001) */
export const SCHEDULE_MIN_LEAD_MS = 5 * 60_000
/** 예약할 수 있는 가장 늦은 때 — 7일 안 (엔진 한도 — 계약 2절) */
@@ -76,6 +78,24 @@ export const SEND_LATER_TEXT = {
sendAtColumn: '보낼 시각',
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00에 보낼 메일입니다." */
notice: (time: string) => `${time}에 보낼 메일입니다.`,
/**
* 청록 줄 맨 앞 단추 (15단계 — 디자인 원본 Reader15 "지금 보내기"). [보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 붙잡았다 보낸다.
* 확인 창은 두지 않는다(보내기 취소로 되돌릴 수 있다). 알림은 쓰기 화면의 보낸 뒤 알림과 같다
*/
sendNow: '지금 보내기',
/**
* 지금 보내지 못했거나 결과를 모른다(503 MAIL_001 · SYS_001 — 15단계 리뷰). 그 메일을 다시 받아
* 임시보관함이면 sendNowReverted + [임시보관함 보기],
* 예약이 그대로면(확인했다 — 예약한 시각에 나간다) sendNowFailed + [다시 시도](같은 요청 — 안내 줄은 그대로 둔다),
* 예약만 풀렸거나(기다리는 발송이 없다 — 안내 줄을 내린다) 다시 받지 못했으면(안내 줄은 마지막으로 안 그대로) sendNowUnconfirmed + [다시 시도].
* 이 두 갈래는 이미 나갔을 수 있다(보내기 취소 0초로 바로 나감 · 응답만 잃음 · 그사이 예약 시각이 됨) — 보내지 못했다고 단정하지 않는다
* (그 글을 믿고 손으로 다시 보내면 두 통이 간다). [다시 시도]는 안전하다 — 이미 보낸 메일이면 서버가 200 으로 답한다
*/
sendNowReverted: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
viewDrafts: '임시보관함 보기',
sendNowUnconfirmed: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.',
sendNowFailed: '지금 보내지 못했습니다. 다시 시도해 주세요.',
retry: '다시 시도',
edit: '고치기',
cancelSchedule: '예약 취소',
canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
@@ -111,3 +131,8 @@ export const PENDING_RELEASE_MAX_RECHECKS = 3
* 마감이 서버보다 늦으면 늦게 누른 [보내기 취소]가 "이미 보낸 메일"(MAIL_014)이 된다
*/
export const UNDO_SEND_DEADLINE_MARGIN_MS = 1_000
/**
* 설정을 모를 때 [보내기 취소] 알림이 남는 가장 긴 시간 — 설정의 가장 긴 값(30초). 쓰기 화면(ComposePage)과 같은 값이다
* (15단계 예약 [지금 보내기]의 알림이 함께 쓴다 — pending-send.ts 의 undoToastDeadline)
*/
export const UNDO_SEND_MAX_MS = Math.max(...UNDO_SEND_OPTIONS) * 1000
+9
View File
@@ -332,6 +332,15 @@ export const MAIL_BULK_TEXT = {
unflag: '중요 해제',
move: '이동',
moveMenu: '옮길 메일함',
/**
* [이동 ▾] 메뉴에 고를 수 없게 남긴 줄의 까닭 (15단계 — 디자인 원본 Reader15) — 메일이 든 메일함에 하위 메일함이 있으면 나무 모양을 지키려고 남긴다.
* 읽기 화면은 그 메일, 목록 도구 줄은 고른 메일
*/
moveHere: '이 메일이 있는 메일함',
moveHereChecked: '고른 메일이 있는 메일함',
/** 목록 줄을 메일함 패널로 끄는 동안 마우스를 따라오는 표 (15단계 — 디자인 원본 ListDrag15) */
dragMessages: (count: string) => `메일 ${count}통`,
dragConversations: (count: string) => `대화 ${count}개`,
delete: '삭제',
purge: '영구 삭제',
clear: '선택 해제',
@@ -12,6 +12,16 @@ export const MAILBOX_NAME_FORBIDDEN_CHAR = '/'
*/
export const MAILBOX_OPERATION_MAX_STALLS = 3
/**
* 들여 쓴 메일함의 이름 (15단계 — 디자인 원본 Reader15 · Mailbox15). 위 메일함부터 잇는다.
* spokenJoin: 화면 낭독 이름 "거래처 안의 2025" · displayJoin: 같은 이름이 다른 곳에도 있을 때 알림에 쓰는 "거래처 › 2025"
* (shared/utils/mailbox.ts 의 mailboxSpokenName · mailboxDisplayName)
*/
export const MAILBOX_PATH_TEXT = {
spokenJoin: ' 안의 ',
displayJoin: ' › ',
} as const
export const MAILBOX_PANEL_TEXT = {
/** 기본 메일함 아래 묶음 이름 (12px 굵게) */
myMailboxes: '내 메일함',
@@ -27,6 +37,8 @@ export const MAILBOX_MENU_TEXT = {
markRead: '모두 읽음으로 표시',
createChild: '하위 메일함 만들기',
rename: '이름 바꾸기',
/** 15단계 — 내 메일함만. 창을 여는 다른 항목처럼 이름 뒤에 …를 붙이지 않는다 (디자인 원본 Mailbox15) */
move: '옮기기',
delete: '지우기',
emptyTrash: '휴지통 비우기',
emptyJunk: '스팸메일함 비우기',
@@ -46,19 +58,55 @@ export const MAILBOX_DIALOG_TEXT = {
deleteMessage: (count: string) => `안의 메일 ${count}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.`,
deleteEmptyMessage: '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
deleteConfirm: '지우기',
blockedTitle: (name: string) => `‘${name}’ 메일함을 지울 수 없습니다`,
blockedMessage: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.',
blockedConfirm: '확인',
/**
* 하위 메일함이 있는 메일함 지우기 (15단계 — 디자인 원본 Mailbox15 "하위 메일함까지 지우기"). children 은 모든 깊이의 하위 메일함 수,
* count 는 하위 메일함까지 더한 메일 수 (formatCount 로 만든 글자)
*/
deleteTreeTitle: (name: string, children: string) => `‘${name}’ 메일함과 하위 메일함 ${children}개를 지울까요?`,
deleteTreeMessage: (count: string) =>
`하위 메일함에 든 메일까지 모두 ${count}통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.`,
deleteTreeEmptyMessage: '하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
/** "휴지통의 메일 21통을 모두 영구 삭제할까요?" */
emptyTitle: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 모두 영구 삭제할까요?`,
emptyMessage: '지운 메일은 되돌릴 수 없습니다.',
emptyConfirm: '영구 삭제',
} as const
/** 만들기 · 이름 바꾸기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구") */
/**
* 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때") — 모달 400(medium).
* 줄의 까닭 글(지금 있는 곳 · 같은 이름)은 서버에 묻기 전에 화면이 하는 검사라 화면 상수 글이다. 묶음 이름 "내 메일함"은 MAILBOX_PANEL_TEXT
*/
export const MAILBOX_MOVE_TEXT = {
title: (name: string) => `‘${name}’ 메일함을 어디로 옮길까요?`,
/** 하위 메일함이 있을 때 · 없을 때 */
messageWithChildren: '하위 메일함과 안의 메일도 함께 옮깁니다.',
message: '안의 메일도 함께 옮깁니다.',
/** 목록(radiogroup)의 이름 */
listLabel: '옮길 곳',
top: '맨 위',
topNote: '다른 메일함 안에 두지 않습니다',
current: '지금 있는 곳',
sameName: '같은 이름의 메일함이 있습니다',
confirm: '옮기기',
cancel: '취소',
} as const
/** 만들기 · 이름 바꾸기 · 옮기기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구" · Mailbox15 "옮긴 뒤 알림") */
export const MAILBOX_RESULT_TEXT = {
created: (name: string) => `‘${name}’ 메일함을 만들었습니다.`,
renamed: '메일함 이름을 바꿨습니다.',
/**
* 옮긴 곳의 이름 — 내 메일함은 따옴표로 묶고, 받은편지함 같은 기본 메일함은 묶지 않는다
* ("‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." · "…받은편지함 안으로 옮겼습니다.")
*/
quoted: (name: string) => `‘${name}’`,
movedInto: (name: string, into: string) => `‘${name}’ 메일함을 ${into} 안으로 옮겼습니다.`,
movedToTop: (name: string) => `‘${name}’ 메일함을 다른 메일함 밖으로 옮겼습니다.`,
/** [되돌리기] 뒤 — 원래 있던 곳으로 */
restoredInto: (name: string, into: string) => `‘${name}’ 메일함을 ${into} 안으로 되돌렸습니다.`,
/** 맨 위에서 옮긴 것을 되돌렸을 때 (보드에 없다 — 맨 위로 옮긴 글과 같은 결) */
restoredToTop: (name: string) => `‘${name}’ 메일함을 다른 메일함 밖으로 되돌렸습니다.`,
undo: '되돌리기',
} as const
/**
+41 -1
View File
@@ -364,6 +364,18 @@ export interface ScheduledMessageCancel {
draftId: string
}
/**
* POST /scheduled-messages/{id}/release 응답 — 예약을 거두고 같은 메일을 지금 보냈다 (15단계 "지금 보내기" — 계약 2절의 ReleasedSend).
* 발송 응답(SentMessage)과 같은 칸이다: pendingSend 는 보내기 취소를 기다리는 동안 { kind: 'undo', sendAt }, 바로 보냈으면 null
* (예약 시각이 막 지나 이미 보낸 메일도 null — 오류가 아니다). useMail().releaseScheduledMessage 가 칸을 늘 값으로 맞춘다
*/
export interface ReleasedSend {
/** 같은 메일 id (보낸 사본) — 없으면 요청한 id */
id: string
rejectedRecipients: RejectedRecipient[]
pendingSend: PendingSend | null
}
// ── 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) ─────────────
/** 보내기 취소 시간(초) — 0 이면 바로 보낸다 */
@@ -451,7 +463,19 @@ export interface MailboxRenamePayload {
name: string
}
/** DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다) */
/**
* PATCH /mailboxes/{id} 본문 — 메일함 옮기기 (15단계). parentId: null 은 맨 위.
* 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 내 메일함 — 그 밖 400 VAL_001), 자기 자신 · 하위 아래는 400 VAL_001,
* 새 상위 안에 같은 이름(대소문자 무시)은 409 MAIL_009, 없는 상위는 404 RES_001. 하위 메일함과 안의 메일이 함께 간다
*/
export interface MailboxMovePayload {
parentId: string | null
}
/**
* DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다).
* 하위 메일함까지 지우면(?withChildren=true — 15단계) 수는 모두 나무 전체(그 메일함 + 모든 하위)의 것이고 deleted 는 나무를 다 지웠는가다
*/
export interface MailboxDeletion {
deleted: boolean
/**
@@ -581,6 +605,22 @@ export interface MailFilterRunRequest extends MailFilterRunRule {
cursor: string | null
}
/**
* POST /mail-block-runs 본문 — 차단할 때 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일을 스팸메일함으로 (15단계).
* sender 는 차단 목록의 값 그대로(소문자 주소 또는 "@도메인") — 차단한 뒤에만 부른다(목록에 없으면 404 RES_001)
*/
export interface MailBlockRunRequest {
sender: string
/** 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 (다른 sender 의 cursor · 깨진 cursor 는 400 VAL_001) */
cursor: string | null
}
/**
* POST /mail-block-runs 응답 — 필터 적용(MailFilterRun)과 같은 꼴. matched 는 스팸메일함으로 옮긴 수.
* 같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나 — 다른 곳에서 하는 중이면 429 REQ_001
*/
export type MailBlockRun = MailFilterRun
/** POST /mail-filter-runs 응답 — 한 요청은 25초 안에 끝난다 */
export interface MailFilterRun {
/** 이번 요청에서 확인한 받은편지함 메일 수 */
@@ -10,6 +10,7 @@ import {
isPublicMailDomain,
isUnblockableSender,
mailBlockInputOf,
mailBlockRunCopy,
} from './mail-block'
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단") — 차단 값 · 검사 · 찾기
@@ -134,3 +135,40 @@ describe('mailBlockInputOf — 설정 › 차단한 주소의 칸 (서버보다
expect(mailBlockInputOf('new@spam.example', OWN, full)).toEqual({ problem: 'full' })
})
})
describe('차단할 때 받은 메일 옮기기 — 알림 문구 (15단계 — 디자인 원본 Mailbox15 "차단한 뒤 알림")', () => {
const copy = mailBlockRunCopy('news@example.com')
it('하는 중 — "…차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인" · 전체를 모르면 수를 뺀다', () => {
expect(copy.progress({ scanned: 1200, total: 4800 })).toBe(
'‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인',
)
expect(copy.progress({ scanned: 0, total: 0 })).toBe(
'‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중',
)
})
it('끝 — 옮긴 수 · 옮길 메일이 없으면 그렇게', () => {
expect(copy.done(37)).toBe('‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.')
expect(copy.done(0)).toBe('‘news@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.')
})
it('멈춤 · 처음부터 실패 · 다른 창에서 하는 중 · 이 탭에서 하는 중 — 모두 "차단했다"로 시작한다', () => {
expect(copy.stopped({ scanned: 1200, matched: 12, total: 4800 })).toBe(
'‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.',
)
expect(copy.notMoved).toBe('‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.')
// 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다 — 필터 적용의 429 글(받은편지함만)과 다르다 (15단계 리뷰)
expect(copy.otherWindow).toBe(
'‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
)
expect(copy.busy).toBe('‘news@example.com’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.')
expect(copy.junkBusy).toBe('‘news@example.com’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.')
})
it('도메인 차단도 같은 글 — 받침에 맞춘 조사는 그 값의 끝 글자로', () => {
expect(mailBlockRunCopy('@example.com').done(2)).toBe(
'‘@example.com’을(를) 차단하고 받은편지함의 메일 2통을 스팸메일함으로 옮겼습니다.',
)
})
})
+55 -1
View File
@@ -3,8 +3,15 @@
// 내 주소와 사내 도메인(내 주소의 도메인과 그 하위 — 서버의 MAIL_DOMAIN)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다.
// 검사는 서버보다 좁게 본다(isStrictEmail) — 화면이 통과시킨 값은 서버도 받는다.
import { MAIL_BLOCK_DOMAIN_PREFIX, MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
import {
MAIL_BLOCK_DOMAIN_PREFIX,
MAIL_BLOCK_MAX,
MAIL_BLOCK_TEXT,
PUBLIC_MAIL_DOMAINS,
} from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { objectParticle } from '@/shared/utils/korean'
import { isStrictEmail, normalizeEmail } from '@/shared/utils/mail-address'
/** 칸에 쓴 값의 문제 — 형식 · 이미 차단함 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 500개 */
@@ -80,3 +87,50 @@ export function mailBlockInputOf(
if (blocks.length >= MAIL_BLOCK_MAX) return { problem: 'full' }
return { address: value }
}
// ── 차단할 때 받은 메일 옮기기 — 알림 문구 (15단계 — 디자인 원본 Mailbox15 "차단한 뒤 알림") ─────────────
/** 지금까지 — 확인한 수 · 옮긴 수 · 전체(확인 + 남은 수, 첫 응답 전에는 받은편지함의 메일 수 — 모르면 0) */
export interface MailBlockRunCounts {
scanned: number
matched: number
total: number
}
export interface MailBlockRunCopy {
/** 하는 중 — "‘…’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인" (전체를 모르면 수를 뺀다) */
progress: (counts: Pick<MailBlockRunCounts, 'scanned' | 'total'>) => string
/** 끝남 — 옮긴 수. 0 이면 "…받은편지함에는 옮길 메일이 없습니다." */
done: (matched: number) => string
/** 도중에 멈춤 — 거기까지의 수 */
stopped: (counts: MailBlockRunCounts) => string
/** 처음 요청부터 실패 */
notMoved: string
/** 다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중 (429) */
otherWindow: string
/** 이 탭에서 받은편지함을 정리하는 중에 [다시 시도] */
busy: string
/** 이 탭에서 스팸메일함을 비우는 중에 [다시 시도] (15단계 리뷰) */
junkBusy: string
}
/** 그 차단(주소 · "@도메인")의 알림 문구 — 모두 "차단했다"는 말로 시작한다(차단은 이미 끝났고 옮기기만 멈춘 것이다) */
export function mailBlockRunCopy(address: string): MailBlockRunCopy {
const particle = objectParticle(address)
return {
progress: ({ scanned, total }) =>
total > 0
? `${MAIL_BLOCK_TEXT.runProgress(address, particle)}${MAIL_BLOCK_TEXT.runProgressCount(formatCount(scanned), formatCount(total))}`
: MAIL_BLOCK_TEXT.runProgress(address, particle),
done: (matched) =>
matched > 0
? MAIL_BLOCK_TEXT.runDone(address, particle, formatCount(matched))
: MAIL_BLOCK_TEXT.runNone(address, particle),
stopped: ({ scanned, matched, total }) =>
MAIL_BLOCK_TEXT.runStopped(address, particle, formatCount(total), formatCount(scanned), formatCount(matched)),
notMoved: MAIL_BLOCK_TEXT.runNotMoved(address, particle),
otherWindow: MAIL_BLOCK_TEXT.runOtherWindow(address, particle),
busy: MAIL_BLOCK_TEXT.runBusy(address, particle),
junkBusy: MAIL_BLOCK_TEXT.runJunkBusy(address, particle),
}
}
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox } from '@/shared/types/mail'
import {
canDropMailOn,
hasMoveTarget,
hereMailboxIds,
junkMarkForMove,
mailMoveTargets,
type MailMoveTarget,
} from './mail-move-targets'
// 15단계 — 메일을 옮길 곳 (디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때" · ListDrag15)
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
}
const MAILBOXES: Mailbox[] = [
box('inbox', 'inbox', '받은편지함'),
box('sent', 'sent', '보낸편지함'),
box('drafts', 'drafts', '임시보관함'),
box('junk', 'junk', '스팸메일함'),
box('trash', 'trash', '휴지통'),
box('clients', null, '거래처'),
box('clients-2025', null, '2025', { parentId: 'clients' }),
box('quotes', null, '견적', { parentId: 'clients' }),
box('project', null, '프로젝트', { sortOrder: 1 }),
box('project-2025', null, '2025', { parentId: 'project' }),
]
/** 줄을 "이름 깊이 [고를 수 없음] [여기]" 로 — 차례 · 들여쓰기를 한눈에 */
function shape(rows: readonly MailMoveTarget[]): string[] {
return rows.map(
(row) => `${row.mailbox.name} ${row.depth}${row.disabled ? ' off' : ''}${row.here ? ' here' : ''} ${row.group}`,
)
}
describe('mailMoveTargets — [이동 ▾]의 옮길 곳', () => {
it('메일함 패널 차례 · 깊이로, 메일이 든 메일함 · 임시보관함 · 휴지통은 뺀다 (받은편지함의 메일)', () => {
const rows = mailMoveTargets(MAILBOXES, new Set(['inbox']))
expect(shape(rows)).toEqual([
'보낸편지함 0 system',
'스팸메일함 0 system',
'거래처 0 mine',
'2025 1 mine',
'견적 1 mine',
'프로젝트 0 mine',
'2025 1 mine',
])
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
expect(rows.map((row) => row.spokenName)).toEqual([
'보낸편지함',
'스팸메일함',
'거래처',
'거래처 안의 2025',
'거래처 안의 견적',
'프로젝트',
'프로젝트 안의 2025',
])
})
it('메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 줄로 남긴다 (거래처의 메일)', () => {
const rows = mailMoveTargets(MAILBOXES, new Set(['clients']))
expect(shape(rows)).toEqual([
'받은편지함 0 system',
'보낸편지함 0 system',
'스팸메일함 0 system',
'거래처 0 off here mine',
'2025 1 mine',
'견적 1 mine',
'프로젝트 0 mine',
'2025 1 mine',
])
})
it('하위 메일함이 있는 휴지통(다른 메일 프로그램이 지운 폴더를 넣어 둠)은 까닭 없이 고를 수 없는 줄 — 하위 메일함은 고를 수 있다', () => {
const rows = mailMoveTargets([...MAILBOXES, box('old', null, '옛 폴더', { parentId: 'trash' })], new Set(['inbox']))
expect(shape(rows).slice(0, 4)).toEqual(['보낸편지함 0 system', '스팸메일함 0 system', '휴지통 0 off system', '옛 폴더 1 system'])
})
it('남긴 줄 아래에 보일 하위 메일함이 없으면 그 줄도 뺀다', () => {
// 거래처의 하위가 모두 메일이 든 곳이고 하위가 없다 — 거래처도 남길 까닭이 없다
const list = [box('inbox', 'inbox', '받은편지함'), box('a', null, '거래처'), box('b', null, '2025', { parentId: 'a' })]
expect(shape(mailMoveTargets(list, new Set(['a', 'b'])))).toEqual(['받은편지함 0 system'])
})
it('고를 수 있는 곳이 없으면 hasMoveTarget 이 거짓', () => {
expect(hasMoveTarget(mailMoveTargets([box('inbox', 'inbox', '받은편지함')], new Set(['inbox'])))).toBe(false)
expect(hasMoveTarget(mailMoveTargets(MAILBOXES, new Set(['inbox'])))).toBe(true)
})
})
describe('canDropMailOn — 끌어다 놓을 수 있는 메일함', () => {
it('[이동 ▾]의 옮길 곳 + 휴지통 — 메일이 든 메일함 · 임시보관함에는 놓을 수 없다', () => {
const here = new Set(['inbox'])
expect(MAILBOXES.filter((mailbox) => canDropMailOn(mailbox, here)).map((mailbox) => mailbox.id)).toEqual([
'sent',
'junk',
'trash',
'clients',
'clients-2025',
'quotes',
'project',
'project-2025',
])
})
})
describe('hereMailboxIds — 옮기는 메일이 이미 든 메일함', () => {
it('목록이 가리키는 메일함과, 옮기는 메일이 모두 든 메일함', () => {
expect([...hereMailboxIds([{ mailboxIds: ['inbox'] }, { mailboxIds: ['inbox', 'clients'] }], 'inbox')]).toEqual(['inbox'])
// 전체 메일함 검색 — 목록이 가리키는 메일함이 없다. 메일마다 다르면 모두가 든 곳만
expect([...hereMailboxIds([{ mailboxIds: ['inbox'] }, { mailboxIds: ['clients'] }], null)]).toEqual([])
expect([...hereMailboxIds([{ mailboxIds: ['clients', 'project'] }], null)]).toEqual(['clients', 'project'])
})
})
describe('junkMarkForMove — 스팸메일함으로 옮기는 세 길 같게', () => {
it('스팸메일함으로 옮기면 스팸 표시([스팸 신고]와 같다)', () => {
expect(junkMarkForMove({ role: 'junk' }, false)).toBe(true)
})
it('스팸메일함에서 휴지통 밖으로 꺼내면 스팸 아님 표시([스팸 아님]과 같다) — 휴지통으로는 표시를 바꾸지 않는다', () => {
expect(junkMarkForMove({ role: null }, true)).toBe(false)
expect(junkMarkForMove({ role: 'inbox' }, true)).toBe(false)
expect(junkMarkForMove({ role: 'trash' }, true)).toBeUndefined()
})
it('그 밖의 옮기기는 표시를 바꾸지 않는다', () => {
expect(junkMarkForMove({ role: null }, false)).toBeUndefined()
expect(junkMarkForMove({ role: 'trash' }, false)).toBeUndefined()
expect(junkMarkForMove({ role: 'junk' }, true)).toBeUndefined()
})
})
@@ -0,0 +1,97 @@
// 메일을 옮길 곳 — [이동 ▾](목록 · 읽기 화면)의 메뉴와 끌어다 놓기가 함께 쓰는 규칙 (15단계 — 디자인 원본 Reader15 · ListDrag15, 순수 함수만 둔다)
// - 옮길 곳은 메일함 패널과 같은 차례 · 깊이다(mailbox.ts 의 mailboxTree) — 같은 이름이 다른 상위 아래에 있어도 가를 수 있게 들여 쓴다
// - 메일이 이미 든 메일함(here) · 임시보관함 · 휴지통은 옮길 곳이 아니다(휴지통으로는 [삭제]로 — 끌어다 놓기는 휴지통에도 놓을 수 있다).
// 그 메일함에 하위 메일함이 있으면 빼지 않고 고를 수 없는 줄로 남긴다 — 하위 메일함이 누구 밑에 있는지 보이게
// - 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove)
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { mailboxSpokenName, mailboxTree, type MailboxGroup } from '@/shared/utils/mailbox'
/** 옮길 곳 한 줄 */
export interface MailMoveTarget {
mailbox: Mailbox
/** 들여쓰기 단계 — 뿌리 0 (패널처럼 3단계까지만 들여 쓴다) */
depth: number
/** 화면 낭독 이름 — 위 메일함부터 "거래처 안의 2025" (뿌리면 이름 그대로) */
spokenName: string
/** 고를 수 없다 — 나무 모양을 지키려고 남긴 줄 (메일이 든 메일함 · 임시보관함 · 휴지통에 하위 메일함이 있을 때) */
disabled: boolean
/** 메일이 이미 든 메일함 — 고를 수 없는 까닭을 이름 아래에 적는다 */
here: boolean
/** 기본 메일함 묶음 · 내 메일함 묶음 — 메뉴는 둘 사이에 선을 긋는다 */
group: MailboxGroup
}
/** 메일 옮기기의 옮길 곳이 아닌 메일함 — 임시보관함(임시 저장본만) · 휴지통(옮기기가 아니라 [삭제]) */
function isNeverMoveTarget(mailbox: Pick<Mailbox, 'role'>): boolean {
return mailbox.role === 'drafts' || mailbox.role === 'trash'
}
/**
* [이동 ▾] 메뉴의 옮길 곳 — 메일함 패널 차례. 메일이 든 메일함(hereIds) · 임시보관함 · 휴지통은 빼고, 그중 하위 메일함이 있는 것은
* 고를 수 없는 줄로 남긴다. 남긴 줄 아래에 보일 하위 메일함이 하나도 없으면 그 줄도 뺀다
*/
export function mailMoveTargets(list: readonly Mailbox[], hereIds: ReadonlySet<string>): MailMoveTarget[] {
const rows: MailMoveTarget[] = []
for (const entry of mailboxTree(list)) {
const here = hereIds.has(entry.mailbox.id)
const blocked = here || isNeverMoveTarget(entry.mailbox)
if (blocked && !entry.hasChildren) continue
rows.push({
mailbox: entry.mailbox,
depth: entry.depth,
spokenName: mailboxSpokenName(entry),
disabled: blocked,
here,
group: entry.group,
})
}
// 고를 수 없는 줄 가운데 바로 뒤에 더 깊은 줄(보일 하위 메일함)이 없는 것을 뒤에서부터 뺀다 — 위 줄도 함께 비면 그 줄도
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index]
if (!row?.disabled) continue
if ((rows[index + 1]?.depth ?? -1) <= row.depth) rows.splice(index, 1)
}
return rows
}
/** 고를 수 있는 옮길 곳이 있는가 — 없으면 [이동 ▾] 메뉴를 열지 않는다 */
export function hasMoveTarget(rows: readonly MailMoveTarget[]): boolean {
return rows.some((row) => !row.disabled)
}
/**
* 끌어다 놓을 수 있는 메일함인가 (메일함 패널의 줄) — [이동 ▾]의 옮길 곳에 휴지통을 더한 곳(= [삭제]).
* 메일이 이미 든 메일함 · 임시보관함에는 놓을 수 없다 (「예약됨」은 메일함이 아니라 놓을 곳이 없다)
*/
export function canDropMailOn(mailbox: Pick<Mailbox, 'id' | 'role'>, hereIds: ReadonlySet<string>): boolean {
return mailbox.role !== 'drafts' && !hereIds.has(mailbox.id)
}
/**
* 옮기는 메일이 이미 든 메일함 — 목록이 가리키는 메일함(scopeId)과, 옮기는 메일이 모두 들어 있는 메일함.
* 그곳으로 옮겨도 달라지는 것이 없다 (전체 메일함 검색에서 여러 메일함의 메일을 골랐으면 모두가 든 곳만)
*/
export function hereMailboxIds(
targets: readonly Pick<MessageSummary, 'mailboxIds'>[],
scopeId: string | null,
): Set<string> {
const here = new Set<string>()
if (scopeId) here.add(scopeId)
const [first, ...rest] = targets
if (!first) return here
for (const id of first.mailboxIds) {
if (rest.every((target) => target.mailboxIds.includes(id))) here.add(id)
}
return here
}
/**
* 옮기며 함께 보낼 스팸 표시 — 스팸메일함으로 옮기면 스팸(true, [스팸 신고]와 같다), 스팸메일함에서 휴지통 밖으로 꺼내면 스팸 아님(false, [스팸 아님]과 같다).
* 휴지통으로 옮기는 것은 표시를 바꾸지 않는다(undefined — 그냥 옮기기). 되돌리기는 반대 값을 보낸다 (useMailMoveFeedback)
*/
export function junkMarkForMove(target: Pick<Mailbox, 'role'>, fromJunk: boolean): boolean | undefined {
if (target.role === 'junk') return fromJunk ? undefined : true
if (fromJunk && target.role !== 'trash') return false
return undefined
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
import { initialOperationTotal, mailboxMoveText, mailboxOperationCopy } from './mailbox-operation'
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
@@ -42,3 +42,27 @@ describe('mailboxOperationCopy', () => {
expect(initialOperationTotal('delete', mailbox)).toBe(148)
})
})
describe('하위 메일함까지 지우기 · 메일함 옮기기 (15단계 — 디자인 원본 Mailbox15)', () => {
it('하위까지 지우면 시작할 때의 전체 수에 모든 깊이의 하위 메일함 메일을 더한다 — 모두 읽음은 그대로 안 읽은 수', () => {
const mailbox = { unreadCount: 3, totalCount: 12 }
const descendants = [{ totalCount: 4 }, { totalCount: 24 }, { totalCount: -1 }]
expect(initialOperationTotal('delete', mailbox, descendants)).toBe(40)
expect(initialOperationTotal('read', mailbox, descendants)).toBe(3)
expect(initialOperationTotal('delete', mailbox)).toBe(12)
})
it('옮긴 뒤 알림 — 내 메일함은 따옴표 · 받은편지함은 따옴표 없이 · 맨 위는 "다른 메일함 밖으로", 되돌린 뒤는 "되돌렸습니다"', () => {
const project = { name: '프로젝트', role: null }
const inbox = { name: '받은편지함', role: 'inbox' as const }
expect(mailboxMoveText('2025', project)).toBe('‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.')
expect(mailboxMoveText('2025', inbox)).toBe('‘2025’ 메일함을 받은편지함 안으로 옮겼습니다.')
expect(mailboxMoveText('2025', null)).toBe('‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다.')
expect(mailboxMoveText('2025', { name: '거래처', role: null }, { restored: true })).toBe(
'‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.',
)
expect(mailboxMoveText('2025', null, { restored: true })).toBe('‘2025’ 메일함을 다른 메일함 밖으로 되돌렸습니다.')
})
})
+26 -4
View File
@@ -1,7 +1,7 @@
// 오래 걸리는 메일함 처리 — 모두 읽음 · 비우기 · 메일함 지우기의 알림 문구 (계약 2절 "메일함 관리" · 디자인 원본 MailActions4 — 순수 함수만 둔다)
// 서버는 한 요청에 500통씩(25초 안) 처리하고 남은 수를 돌려준다 — 화면은 0 이 될 때까지 다시 보내며 "…하는 중 · 600 / 1,234통"을 보인다.
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import { MAILBOX_OPERATION_TEXT, MAILBOX_RESULT_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'
@@ -21,9 +21,17 @@ export interface MailboxOperationCopy {
failed: (done: number, total: number) => string
}
/** 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다) */
export function initialOperationTotal(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>): number {
return Math.max(0, kind === 'read' ? mailbox.unreadCount : mailbox.totalCount)
/**
* 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다).
* 하위 메일함까지 지우면(15단계) 하위 메일함(descendants — 모든 깊이)의 메일 수도 더한다
*/
export function initialOperationTotal(
kind: MailboxOperationKind,
mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>,
descendants: readonly Pick<Mailbox, 'totalCount'>[] = [],
): number {
if (kind === 'read') return Math.max(0, mailbox.unreadCount)
return [mailbox, ...descendants].reduce((sum, item) => sum + Math.max(0, item.totalCount), 0)
}
export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: string): MailboxOperationCopy {
@@ -61,3 +69,17 @@ export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: st
failed,
}
}
/**
* 메일함 옮기기 알림 (15단계 — 디자인 원본 Mailbox15 "옮긴 뒤 알림"). into 는 새 상위 — null 이면 맨 위("다른 메일함 밖으로").
* 내 메일함은 따옴표로 묶고 받은편지함 같은 기본 메일함은 묶지 않는다. restored 면 [되돌리기] 뒤의 글("…안으로 되돌렸습니다.")
*/
export function mailboxMoveText(
name: string,
into: Pick<Mailbox, 'name' | 'role'> | null,
options: { restored?: boolean } = {},
): string {
if (!into) return options.restored ? MAILBOX_RESULT_TEXT.restoredToTop(name) : MAILBOX_RESULT_TEXT.movedToTop(name)
const place = into.role === null ? MAILBOX_RESULT_TEXT.quoted(into.name) : into.name
return options.restored ? MAILBOX_RESULT_TEXT.restoredInto(name, place) : MAILBOX_RESULT_TEXT.movedInto(name, place)
}
+185 -3
View File
@@ -3,12 +3,19 @@ import { describe, expect, it } from 'vitest'
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
canHoldChildMailbox,
descendantMailboxes,
emptyMailboxText,
hasChildMailbox,
mailboxActions,
mailboxDisplayName,
mailboxIcon,
mailboxMoveTargets,
mailboxNameProblem,
mailboxSpokenName,
mailboxTree,
orderMailboxes,
sameMailboxName,
splitMailboxGroups,
} from './mailbox'
@@ -121,11 +128,13 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
])
})
it('⋯ 메뉴 — 안 읽은 메일이 있으면 모두 읽음 · 받은편지함과 내 메일함은 하위 메일함 · 내 메일함만 이름 바꾸기 · 지우기 · 휴지통 / 스팸메일함은 비우기', () => {
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'])
// 옮기기는 이름 바꾸기 다음 · 지우기 앞 (15단계 — 디자인 원본 Mailbox15). 받은편지함 아래의 내 메일함도 옮길 수 있다
expect(mailboxActions({ ...project, unreadCount: 1 })).toEqual(['markRead', 'createChild', 'rename', 'move', 'delete'])
expect(mailboxActions(inboxChild)).toEqual(['createChild', 'rename', 'move', 'delete'])
expect(mailboxActions({ ...trash, totalCount: 21 })).toEqual(['empty'])
expect(mailboxActions(mailbox({ id: 'j', name: '스팸메일함', role: 'junk', totalCount: 5, unreadCount: 1 }))).toEqual([
'markRead',
@@ -135,7 +144,7 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
expect(mailboxActions(trash)).toEqual([])
})
it('하위 메일함이 있는가 — 지우기를 막는다', () => {
it('하위 메일함이 있는가 — 지우기 확인 창이 하위까지 지울지 묻는다', () => {
expect(hasChildMailbox([project, quote], 'p')).toBe(true)
expect(hasChildMailbox([project, quote], 'q')).toBe(false)
})
@@ -150,3 +159,176 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
expect(mailboxNameProblem('견적\u00852026')).toBe('invalid')
})
})
describe('메일함 나무 (15단계 — 디자인 원본 Mailbox15 · Reader15)', () => {
// 보드의 예: 거래처 안에 2025(그 안에 정산) · 견적, 보관 안에도 2025, 프로젝트 안에 견적 — 받은편지함 아래 협력사
const inbox = mailbox({ id: 'i', name: '받은편지함', role: 'inbox' })
const sent = mailbox({ id: 's', name: '보낸편지함', role: 'sent' })
const sentFolder = mailbox({ id: 'sf', name: '보낸 정리', parentId: 's' })
const junk = mailbox({ id: 'j', name: '스팸메일함', role: 'junk' })
const trash = mailbox({ id: 't', name: '휴지통', role: 'trash' })
const partner = mailbox({ id: 'ip', name: '협력사', parentId: 'i' })
const client = mailbox({ id: 'c', name: '거래처' })
const client2025 = mailbox({ id: 'c25', name: '2025', parentId: 'c' })
const settle = mailbox({ id: 'c25s', name: '정산', parentId: 'c25' })
const clientQuote = mailbox({ id: 'cq', name: '견적', parentId: 'c' })
const keep = mailbox({ id: 'b', name: '보관' })
const keep2025 = mailbox({ id: 'b25', name: '2025', parentId: 'b' })
const project = mailbox({ id: 'p', name: '프로젝트' })
const projectQuote = mailbox({ id: 'pq', name: '견적', parentId: 'p' })
const LIST = [
project,
projectQuote,
keep2025,
keep,
settle,
clientQuote,
client2025,
client,
partner,
trash,
junk,
sentFolder,
sent,
inbox,
]
it('mailboxTree — 패널 차례 · 깊이에 위 메일함 · 하위가 있는지 · 묶음', () => {
const entries = mailboxTree(LIST)
expect(entries.map((entry) => [entry.mailbox.id, entry.depth, entry.group, entry.hasChildren])).toEqual([
['i', 0, 'system', true],
['ip', 1, 'system', false],
['s', 0, 'system', true],
['sf', 1, 'system', false],
['j', 0, 'system', false],
['t', 0, 'system', false],
['c', 0, 'mine', true],
['c25', 1, 'mine', true],
['c25s', 2, 'mine', false],
['cq', 1, 'mine', false],
['b', 0, 'mine', true],
['b25', 1, 'mine', false],
['p', 0, 'mine', true],
['pq', 1, 'mine', false],
])
expect(entries.find((entry) => entry.mailbox.id === 'c25s')?.ancestors.map((item) => item.id)).toEqual(['c', 'c25'])
expect(entries.find((entry) => entry.mailbox.id === 'c')?.ancestors).toEqual([])
// 차례와 깊이는 패널(orderMailboxes)과 같다
expect(entries.map((entry) => [entry.mailbox.id, entry.depth])).toEqual(
orderMailboxes(LIST).map((node) => [node.mailbox.id, node.depth]),
)
})
it('mailboxTree — 부모를 찾을 수 없거나 순환이어도 빠뜨리지 않고 위 메일함은 나무 그대로', () => {
const entries = mailboxTree([
mailbox({ id: 'orphan', name: '고아', parentId: 'missing' }),
mailbox({ id: 'a', name: '가', parentId: 'b' }),
mailbox({ id: 'b', name: '나', parentId: 'a' }),
])
expect(entries.map((entry) => entry.mailbox.id).sort()).toEqual(['a', 'b', 'orphan'])
for (const entry of entries) expect(entry.ancestors).toHaveLength(entry.depth)
})
it('mailboxSpokenName — 위 메일함부터 "거래처 안의 2025"', () => {
const entries = mailboxTree(LIST)
const spoken = (id: string) => {
const entry = entries.find((item) => item.mailbox.id === id)
return entry ? mailboxSpokenName(entry) : ''
}
expect(spoken('c25')).toBe('거래처 안의 2025')
expect(spoken('c25s')).toBe('거래처 안의 2025 안의 정산')
expect(spoken('ip')).toBe('받은편지함 안의 협력사')
expect(spoken('c')).toBe('거래처')
})
it('mailboxDisplayName — 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025", 하나뿐이면 이름 그대로', () => {
expect(mailboxDisplayName(LIST, client2025)).toBe('거래처 › 2025')
expect(mailboxDisplayName(LIST, keep2025)).toBe('보관 › 2025')
expect(mailboxDisplayName(LIST, projectQuote)).toBe('프로젝트 › 견적')
expect(mailboxDisplayName(LIST, settle)).toBe('정산')
expect(mailboxDisplayName(LIST, client)).toBe('거래처')
expect(mailboxDisplayName(LIST, inbox)).toBe('받은편지함')
})
it('mailboxDisplayName — 위 메일함 이름까지 같으면 가를 수 있을 때까지 올라간다 · 맨 위의 메일함은 이름 그대로', () => {
const a = mailbox({ id: 'a', name: 'A' })
const aYear = mailbox({ id: 'ay', name: '2025', parentId: 'a' })
const aMonth = mailbox({ id: 'am', name: '11월', parentId: 'ay' })
const b = mailbox({ id: 'b', name: 'B' })
const bYear = mailbox({ id: 'by', name: '2025', parentId: 'b' })
const bMonth = mailbox({ id: 'bm', name: '11월', parentId: 'by' })
const rootMonth = mailbox({ id: 'rm', name: '11월' })
const list = [a, aYear, aMonth, b, bYear, bMonth, rootMonth]
expect(mailboxDisplayName(list, aMonth)).toBe('A › 2025 › 11월')
expect(mailboxDisplayName(list, bMonth)).toBe('B › 2025 › 11월')
expect(mailboxDisplayName(list, rootMonth)).toBe('11월')
// 목록에 없는 메일함(그사이 지워짐)은 이름 그대로
expect(mailboxDisplayName(list, { id: 'gone', name: '11월' })).toBe('11월')
})
it('mailboxDisplayName — 글자 모양이 같으면(NFC · NFD) 같은 이름으로 본다', () => {
const a = mailbox({ id: 'a', name: 'A' })
const b = mailbox({ id: 'b', name: 'B' })
const composed = mailbox({ id: 'q', name: '견적', parentId: 'a' })
const decomposed = mailbox({ id: 'n', name: '견적'.normalize('NFD'), parentId: 'b' })
expect(mailboxDisplayName([a, b, composed, decomposed], composed)).toBe('A › 견적')
})
it('canHoldChildMailbox · sameMailboxName — 하위 메일함은 받은편지함 · 내 메일함만, 이름은 대소문자를 가리지 않는다', () => {
expect([inbox, sent, junk, trash, client].map(canHoldChildMailbox)).toEqual([true, false, false, false, true])
expect(canHoldChildMailbox(mailbox({ id: 'a', name: '보관함', role: 'archive' }))).toBe(false)
expect(sameMailboxName('Report', 'report')).toBe(true)
expect(sameMailboxName('p15-자식', 'P15-자식')).toBe(true)
expect(sameMailboxName('2025', '2026')).toBe(false)
})
it('mailboxMoveTargets — 맨 위 · 받은편지함(과 그 아래) · 내 메일함, 고를 수 없는 줄은 까닭과 함께 남긴다', () => {
const targets = mailboxMoveTargets(LIST, client2025)
expect(targets.map((target) => [target.parentId, target.group, target.block])).toEqual([
[null, 'top', null],
['i', 'system', null],
['ip', 'system', null],
// 지금 있는 곳 · 옮기는 메일함과 그 하위 · 바로 아래에 같은 이름(보관 › 2025)이 있는 곳
['c', 'mine', 'current'],
['c25', 'mine', 'self'],
['c25s', 'mine', 'self'],
['cq', 'mine', null],
['b', 'mine', 'sameName'],
['b25', 'mine', null],
['p', 'mine', null],
['pq', 'mine', null],
])
// 보낸편지함 · 스팸메일함 · 휴지통과 그 아래(보낸 정리)는 옮길 곳이 아니다
expect(targets.some((target) => ['s', 'sf', 'j', 't'].includes(target.parentId ?? ''))).toBe(false)
expect(targets[3]?.entry?.mailbox.id).toBe('c')
expect(targets[0]?.entry).toBeNull()
})
it('mailboxMoveTargets — 맨 위의 메일함은 맨 위 줄이 지금 있는 곳, 같은 이름은 대소문자를 가리지 않고 본다', () => {
const report = mailbox({ id: 'r', name: 'Report', sortOrder: 1 })
const lowerReport = mailbox({ id: 'pr', name: 'report', parentId: 'p' })
const targets = mailboxMoveTargets([inbox, project, report, lowerReport], report)
expect(targets.map((target) => [target.parentId, target.block])).toEqual([
[null, 'current'],
['i', null],
['p', 'sameName'],
['pr', null],
['r', 'self'],
])
// 맨 위에 같은 이름(대소문자만 다름)이 있으면 맨 위 줄도 고를 수 없다
const nested = mailbox({ id: 'n', name: 'REPORT', parentId: 'p' })
expect(mailboxMoveTargets([inbox, project, report, nested], nested)[0]?.block).toBe('sameName')
})
it('descendantMailboxes — 모든 깊이의 하위 메일함', () => {
expect(descendantMailboxes(LIST, 'c').map((item) => item.id)).toEqual(['c25', 'c25s', 'cq'])
expect(descendantMailboxes(LIST, 'c25s')).toEqual([])
})
})
+144 -7
View File
@@ -7,7 +7,11 @@ 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 {
MAILBOX_NAME_FORBIDDEN_CHAR,
MAILBOX_NAME_MAX_LENGTH,
MAILBOX_PATH_TEXT,
} 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'
@@ -89,7 +93,140 @@ export function splitMailboxGroups(nodes: readonly MailboxNode[]): { system: Mai
return { system, mine }
}
/** 하위 메일함이 있는가 — 있으면 지울 수 없다 (서버도 400 VAL_001, 화면이 먼저 막는다) */
// ── 메일함 나무 (15단계 — [이동 ▾] 들여쓰기 · 옮기기 창 · 알림의 "상위 › 이름") ─────────────
/** 메일함 패널의 두 묶음 — 기본 메일함(역할이 있는 뿌리와 그 아래) · 내 메일함(역할 없는 뿌리와 그 아래) */
export type MailboxGroup = 'system' | 'mine'
/** 메일함 나무의 한 줄 — 패널과 같은 차례 · 깊이(orderMailboxes)에 위 메일함 · 하위가 있는지 · 묶음을 더했다 */
export interface MailboxTreeEntry extends MailboxNode {
/** 뿌리부터 바로 위 메일함까지 — 뿌리면 빈 배열 */
ancestors: readonly Mailbox[]
/** 하위 메일함이 있다 */
hasChildren: boolean
group: MailboxGroup
}
/**
* 메일함 목록 → 패널 차례로 편 나무. 차례 · 깊이는 orderMailboxes 그대로(부모를 찾을 수 없거나 순환이면 맨 위로 — 빠뜨리지 않는다),
* 묶음은 splitMailboxGroups 와 같다(뿌리의 역할을 하위가 따라간다)
*/
export function mailboxTree(list: readonly Mailbox[]): MailboxTreeEntry[] {
const nodes = orderMailboxes(list)
const entries: MailboxTreeEntry[] = []
/** 지금 줄 위의 메일함들 — 깊이 d 인 줄 앞에는 d 개가 남는다 (orderMailboxes 는 부모 바로 뒤에 하위를 둔다) */
const path: Mailbox[] = []
let group: MailboxGroup = 'mine'
nodes.forEach((node, index) => {
path.length = Math.min(path.length, node.depth)
if (node.depth === 0) group = node.mailbox.role !== null ? 'system' : 'mine'
entries.push({
mailbox: node.mailbox,
depth: node.depth,
ancestors: [...path],
hasChildren: (nodes[index + 1]?.depth ?? -1) > node.depth,
group,
})
path.push(node.mailbox)
})
return entries
}
/** 들여 쓴 메일함의 화면 낭독 이름 — 위 메일함부터 "거래처 안의 2025" (뿌리면 이름 그대로) */
export function mailboxSpokenName(entry: Pick<MailboxTreeEntry, 'mailbox' | 'ancestors'>): string {
return [...entry.ancestors.map((mailbox) => mailbox.name), entry.mailbox.name].join(MAILBOX_PATH_TEXT.spokenJoin)
}
/** 보기에 같은 이름인가 — 알림에서 가려야 할 이름 (글자 모양이 같으면 같다 — NFC) */
function sameDisplayedName(a: string, b: string): boolean {
return a.normalize('NFC') === b.normalize('NFC')
}
/**
* 알림에 쓰는 메일함 이름 — 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다 ("거래처 › 2025").
* 위 메일함까지 같으면 한 단계씩 더 올라가 가를 수 있을 때까지 붙인다("거래처 › 2025 › 정산"). 맨 위의 메일함 · 목록에 없는 메일함은 이름 그대로
*/
export function mailboxDisplayName(list: readonly Mailbox[], mailbox: Pick<Mailbox, 'id' | 'name'>): string {
if (list.filter((other) => sameDisplayedName(other.name, mailbox.name)).length < 2) return mailbox.name
const entries = mailboxTree(list)
const own = entries.find((entry) => entry.mailbox.id === mailbox.id)
if (!own || own.ancestors.length === 0) return mailbox.name
const others = entries.filter(
(entry) => entry.mailbox.id !== mailbox.id && sameDisplayedName(entry.mailbox.name, mailbox.name),
)
const ownPath = own.ancestors.map((ancestor) => ancestor.name)
for (let take = 1; take <= ownPath.length; take += 1) {
const tail = ownPath.slice(-take)
const clash = others.some((other) => {
const otherPath = other.ancestors.map((ancestor) => ancestor.name)
if (otherPath.length < take) return false
return otherPath.slice(-take).every((name, index) => sameDisplayedName(name, tail[index] ?? ''))
})
if (!clash) return [...tail, mailbox.name].join(MAILBOX_PATH_TEXT.displayJoin)
}
return [...ownPath, mailbox.name].join(MAILBOX_PATH_TEXT.displayJoin)
}
/** 하위 메일함을 둘 수 있는 메일함 — 받은편지함 · 내 메일함(역할 없음). ⋯ 「하위 메일함 만들기」와 옮기기 창의 옮길 곳이 같은 규칙이다 */
export function canHoldChildMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
return mailbox.role === 'inbox' || mailbox.role === null
}
/** 같은 자리에 둘 수 없는 이름인가 — 엔진은 대소문자를 가리지 않고 같은 이름으로 본다 (Report 와 report — 15단계 엔진 실측 E1) */
export function sameMailboxName(a: string, b: string): boolean {
return a.normalize('NFC').toLowerCase() === b.normalize('NFC').toLowerCase()
}
/** 옮기기 창에서 고를 수 없는 까닭 — 옮기는 메일함 자신이나 그 하위 · 지금 있는 곳 · 같은 이름의 메일함이 이미 있는 곳 */
export type MailboxMoveBlock = 'self' | 'current' | 'sameName'
/** 옮기기 창의 한 줄 (디자인 원본 Mailbox15 "옮기기 창") */
export interface MailboxMoveTarget {
/** 옮기면 새 상위 — 맨 위 줄이면 null */
parentId: string | null
/** 그 메일함 — 맨 위 줄이면 null */
entry: MailboxTreeEntry | null
/** 맨 위 줄 · 기본 메일함(받은편지함과 그 아래) · 내 메일함 */
group: 'top' | MailboxGroup
/** 고를 수 없으면 그 까닭 */
block: MailboxMoveBlock | null
}
/**
* 메일함을 옮길 곳 — 맨 위 · 받은편지함(과 그 아래) · 내 메일함을 패널 차례로 (디자인 원본 Mailbox15 "옮기기 창").
* 하위 메일함을 둘 수 없는 메일함(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 · 보관함)과 그 아래는 뺀다 — 하위 메일함을 만들 수 있는 곳과 같다.
* 고를 수 없는 줄도 남긴다: 옮기는 메일함 자신과 그 하위(self) → 지금 있는 곳(current) → 바로 아래에 같은 이름(대소문자 무시)이 있는 곳(sameName) 차례로 본다
*/
export function mailboxMoveTargets(
list: readonly Mailbox[],
moving: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
): MailboxMoveTarget[] {
const blockOf = (parentId: string | null, inside: boolean): MailboxMoveBlock | null => {
if (inside) return 'self'
if (moving.parentId === parentId) return 'current'
const taken = list.some(
(mailbox) => mailbox.id !== moving.id && mailbox.parentId === parentId && sameMailboxName(mailbox.name, moving.name),
)
return taken ? 'sameName' : null
}
const targets: MailboxMoveTarget[] = [{ parentId: null, entry: null, group: 'top', block: blockOf(null, false) }]
for (const entry of mailboxTree(list)) {
// 그 메일함과 위 메일함이 모두 하위 메일함을 둘 수 있는 곳이어야 한다 (보낸편지함 아래의 메일함 등은 뺀다)
if (!canHoldChildMailbox(entry.mailbox) || !entry.ancestors.every(canHoldChildMailbox)) continue
const inside = entry.mailbox.id === moving.id || entry.ancestors.some((ancestor) => ancestor.id === moving.id)
targets.push({ parentId: entry.mailbox.id, entry, group: entry.group, block: blockOf(entry.mailbox.id, inside) })
}
return targets
}
/** 그 메일함 아래의 모든 하위 메일함 — 모든 깊이 (하위까지 지우기 확인 창의 "하위 메일함 2개" · 메일 수 — 15단계) */
export function descendantMailboxes(list: readonly Mailbox[], id: string): Mailbox[] {
return mailboxTree(list)
.filter((entry) => entry.ancestors.some((ancestor) => ancestor.id === id))
.map((entry) => entry.mailbox)
}
/** 하위 메일함이 있는가 — 지우기 확인 창이 하위까지 지울지 묻는다 (15단계 — 서버는 withChildren=true 일 때만 지운다) */
export function hasChildMailbox(list: readonly Mailbox[], id: string): boolean {
return list.some((mailbox) => mailbox.parentId === id)
}
@@ -100,18 +237,18 @@ export function isEmptiableMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
}
/** ⋯ 메뉴가 할 수 있는 일 */
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'delete' | 'empty'
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'move' | 'delete' | 'empty'
/**
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리"):
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(내 메일함만) ·
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리" · "15단계 › 메일함 옮기기"):
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 옮기기 · 지우기(내 메일함만) ·
* 휴지통 / 스팸메일함 비우기(그 메일함에서만 — 빈 메일함이면 뺀다). 할 일이 없으면 빈 목록 — ⋯ 단추를 그리지 않는다
*/
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 (canHoldChildMailbox(mailbox)) actions.push('createChild')
if (mailbox.role === null) actions.push('rename', 'move', 'delete')
if (isEmptiableMailbox(mailbox) && mailbox.totalCount > 0) actions.push('empty')
return actions
}
@@ -9,6 +9,7 @@ import {
pendingWatchesOf,
samePendingSend,
undoToastDeadline,
unsentReleaseState,
} from './pending-send'
// 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수
@@ -88,3 +89,29 @@ describe('보낸 뒤 알림의 [보내기 취소] 마감 (리뷰 #11)', () => {
expect(undoToastDeadline({ ...base, sendAt: at(20_000), undoSendSeconds: 0 })).toBe(NOW + 20_000)
})
})
describe('지금 보내기가 결과를 모른 채 끝난 뒤 다시 받은 메일 (15단계 리뷰)', () => {
const scheduled = { kind: 'scheduled' as const, sendAt: at(3_600_000) }
const undo = { kind: 'undo' as const, sendAt: at(10_000) }
it('임시보관함에 있으면 되돌린 것 — 붙잡은 발송보다 먼저 본다', () => {
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: null }, 'drafts')).toBe('reverted')
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: scheduled }, 'drafts')).toBe('reverted')
})
it('보낸편지함 — 붙잡은 발송이 예약이면 그대로 · 보내기 취소를 기다리면 보낸 것 · 없으면 예약만 풀린 것', () => {
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: scheduled }, 'drafts')).toBe('scheduled')
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: undo }, 'drafts')).toBe('undo')
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: null }, 'drafts')).toBe('unscheduled')
// 모양이 틀린 붙잡은 발송은 없는 것으로 읽는다
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: { kind: 'later', sendAt: 'x' } as never }, 'drafts')).toBe(
'unscheduled',
)
})
it('다시 받지 못했으면 모른다 · 임시보관함을 모르면 붙잡은 발송이 없는 메일을 예약만 풀린 것으로', () => {
expect(unsentReleaseState(null, 'drafts')).toBe('unknown')
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: null }, null)).toBe('unscheduled')
expect(unsentReleaseState({ mailboxIds: undefined as never, pendingSend: scheduled }, 'drafts')).toBe('scheduled')
})
})
+29
View File
@@ -126,3 +126,32 @@ export function undoToastDeadline(input: UndoDeadlineInput): number {
if (seconds !== null && seconds > 0) return input.now + seconds * 1000 - input.marginMs
return Math.min(Date.parse(input.sendAt), input.now + input.maxMs)
}
// ── 지금 보내기가 결과를 모른 채 끝난 뒤 (15단계 — 계약 2절 "지금 보내기") ─────────────
/**
* 지금 보내기가 503 MAIL_001 · 결과를 모르는 실패(SYS_001 — 앞단 502 · 504 · 시간 초과 · 연결 끊김)로 끝난 뒤 다시 받은 그 메일의 상태.
* - reverted: 임시보관함에 있다 — 엔진이 거절해 되돌렸다(보내지 않았다)
* - undo: 다시 낸 발송이 보내기 취소를 기다린다 — 보낸 것이다(같은 요청을 다시 보내도 서버가 이 상태로 200 을 준다)
* - scheduled: 예약이 그대로다 — 예약한 시각에 나간다
* - unscheduled: 예약은 풀렸고 기다리는 발송이 없다 — 예약한 시각에 따로 나가지는 않는다. 다시 낸 발송이 이미 나갔는지는 모른다
* (보내기 취소 0초 · 응답만 잃음). 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 이어 낸다
* - unknown: 다시 받지 못했다
*/
export type UnsentReleaseState = 'reverted' | 'undo' | 'scheduled' | 'unscheduled' | 'unknown'
/**
* 다시 받은 메일(없으면 null — 받지 못했다) → 상태. 임시보관함을 먼저 본다(되돌린 메일은 붙잡은 발송이 없다).
* 임시보관함 id 를 모르면 붙잡은 발송이 없는 메일을 unscheduled 로 둔다 — 되돌렸든 아니든 예약은 풀렸다
*/
export function unsentReleaseState(
fresh: Pick<MessageSummary, 'mailboxIds' | 'pendingSend'> | null,
draftsId: string | null,
): UnsentReleaseState {
if (fresh === null) return 'unknown'
const mailboxIds: unknown = fresh.mailboxIds
if (draftsId !== null && Array.isArray(mailboxIds) && mailboxIds.includes(draftsId)) return 'reverted'
const pending = messagePendingSend(fresh)
if (pending === null) return 'unscheduled'
return pending.kind === 'undo' ? 'undo' : 'scheduled'
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { opensUpward } from './popup-placement'
import { opensUpward, popupAlignFor } from './popup-placement'
// 메일함 패널의 ⋯ 메뉴 — 아래로 열면 잘릴 때 위로 연다
@@ -20,3 +20,42 @@ describe('opensUpward', () => {
expect(opensUpward({ ...base, triggerTop: 220, triggerBottom: 260, areaTop: 0, areaBottom: 320 })).toBe(true)
})
})
// 15단계 — 폭 360 의 읽기 화면 [이동 ▾](디자인 원본 Reader15 2-4): 단추 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다
describe('popupAlignFor', () => {
/** 폭 360 화면 · 레일 72 뒤의 읽기 칸 — [이동 ▾](40) 이 x 240~280 */
const narrow = { triggerLeft: 240, triggerRight: 280, popupWidth: 200, areaLeft: 0, areaRight: 360 }
it('왼쪽 끝에 맞춰 다 들어가면 그대로', () => {
expect(popupAlignFor({ ...narrow, triggerLeft: 100, triggerRight: 140 }, 'start')).toBe('start')
})
it('왼쪽 끝에 맞추면 오른쪽 밖으로 넘치고 오른쪽 끝에 맞추면 들어가면 — 단추 오른쪽 끝에 맞춘다', () => {
expect(popupAlignFor(narrow, 'start')).toBe('end')
})
it('오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘치면 — 단추 왼쪽 끝에 맞춘다(⋯ 가 둘째 줄 맨 앞으로 내려간 폭 360)', () => {
expect(popupAlignFor({ ...narrow, triggerLeft: 94, triggerRight: 134 }, 'end')).toBe('start')
// 다 들어가면 바라는 끝 그대로
expect(popupAlignFor(narrow, 'end')).toBe('end')
})
it('양쪽 다 넘치면 덜 넘치는 쪽 — 똑같이 넘치면 바라는 끝', () => {
const wide = { triggerLeft: 120, triggerRight: 160, popupWidth: 300, areaLeft: 0, areaRight: 360 }
// 왼쪽 끝에 맞추면 오른쪽으로 60 · 오른쪽 끝에 맞추면 왼쪽으로 140 넘친다
expect(popupAlignFor(wide, 'start')).toBe('start')
expect(popupAlignFor(wide, 'end')).toBe('start')
const even = { triggerLeft: 130, triggerRight: 230, popupWidth: 400, areaLeft: 0, areaRight: 360 }
expect(popupAlignFor(even, 'start')).toBe('start')
expect(popupAlignFor(even, 'end')).toBe('end')
})
it('보이는 영역은 화면이 아니라 잘라 내는 칸일 수 있다 — 칸의 오른쪽 끝을 넘으면 넘친 것이다', () => {
expect(popupAlignFor({ ...narrow, triggerLeft: 500, triggerRight: 540, areaLeft: 300, areaRight: 680 }, 'start')).toBe(
'end',
)
expect(popupAlignFor({ ...narrow, triggerLeft: 400, triggerRight: 440, areaLeft: 300, areaRight: 680 }, 'start')).toBe(
'start',
)
})
})
+34 -2
View File
@@ -1,5 +1,6 @@
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴, 순수 함수만 둔다)
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다.
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴),
// 단추의 왼쪽 끝에 맞춰 열면 오른쪽 밖으로 넘칠 때 단추의 오른쪽 끝에 맞춘다 (15단계 — 좁은 폭의 읽기 화면 [이동 ▾], 순수 함수만 둔다)
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다 (composables/usePopupPlacement.ts).
export interface PopupFitInput {
/** 연 단추의 위 · 아래 (화면 좌표) */
@@ -20,3 +21,34 @@ export function opensUpward(input: PopupFitInput): boolean {
const above = input.triggerTop - input.areaTop - input.gap
return input.popupHeight > below && above > below
}
/** 메뉴를 단추의 어느 끝에 맞출지 — start: 메뉴 왼쪽 끝을 단추 왼쪽 끝에 · end: 메뉴 오른쪽 끝을 단추 오른쪽 끝에 */
export type PopupAlign = 'start' | 'end'
export interface PopupSideInput {
/** 연 단추의 왼쪽 · 오른쪽 (화면 좌표) */
triggerLeft: number
triggerRight: number
/** 메뉴 폭 */
popupWidth: number
/** 보이는 영역의 왼쪽 · 오른쪽 (화면 좌표) */
areaLeft: number
areaRight: number
}
/** 그 끝에 맞췄을 때 보이는 영역 밖으로 넘치는 폭 — start 는 오른쪽 밖, end 는 왼쪽 밖 */
function overflowOf(input: PopupSideInput, align: PopupAlign): number {
if (align === 'start') return Math.max(0, input.triggerLeft + input.popupWidth - input.areaRight)
return Math.max(0, input.areaLeft - (input.triggerRight - input.popupWidth))
}
/**
* 메뉴를 맞출 끝 — 바라는 끝(preferred)에 맞춰 다 보이면 그대로, 넘치면 덜 넘치는 반대 끝으로
* (반대 끝에도 다 들지 않으면 덜 넘치는 쪽 — 위아래의 opensUpward 처럼 넓은 쪽으로. 똑같이 넘치면 바라는 끝)
*/
export function popupAlignFor(input: PopupSideInput, preferred: PopupAlign): PopupAlign {
const wanted = overflowOf(input, preferred)
if (wanted === 0) return preferred
const other: PopupAlign = preferred === 'start' ? 'end' : 'start'
return overflowOf(input, other) < wanted ? other : preferred
}
+85
View File
@@ -0,0 +1,85 @@
// 받은편지함 정리의 되풀이 — 이미 받은 메일에 필터 적용(7단계 — mail-filter-run.store)과 차단할 때 받은 메일 옮기기(15단계 — mail-block-run.store)가 함께 쓴다
// (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 같은 규칙: 한 요청 25초 안 · cursor)
// - 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 일에 그 cursor 를 넣어 다시 보낸다.
// 다시 보낼 때마다 onProgress (확인 = scanned 합, 전체 = 확인 + remaining). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
// - 요청은 알림을 끄고 보낸다(send 가 silent) — 실패를 알릴지는 응답이 온 때의 세션으로 정한다: 세션이 바뀐 뒤 늦게 온 실패는 알리지 않는다.
// 화면이 따로 알리는 거절(screenNotified — 다른 창 · PC 에서 하는 중 429 REQ_001)은 알리지 않고, 그 밖은 사전 문구로 알린다
// - 한 번도 나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 maxStalls 번 이어지면 멈추고 BIZ_001 — 같은 요청을 끝없이 보내지 않게 (서버는 그럴 때 503 을 준다)
// - 세션이 바뀌면(sameSession 이 false) 다음 요청을 보내지 않고 멈춘다(canceled). 늦게 온 응답도 쓰지 않는다
// "한 탭에서 한 번에 하나"는 부르는 스토어가 정한다(running) — 이 함수는 되풀이만 한다
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
/** 지금까지 — 확인한 수 · 할 일을 한 수 · 전체(확인 + 남은 수 — 어림. 첫 응답 전에는 받은편지함의 메일 수, 모르면 0) */
export interface InboxCleanupTally {
scanned: number
matched: number
total: number
}
/** 한 요청의 응답 — POST /mail-filter-runs · /mail-block-runs 가 같은 꼴이다 */
export interface InboxCleanupStep {
scanned: number
matched: number
remaining: number
/** null = 끝 */
cursor: string | null
}
export type InboxCleanupOutcome =
| ({ status: 'done' } & InboxCleanupTally)
/** 도중에 멈췄다 — cursor 는 이어 할 자리(마지막으로 받은 cursor — 처음 요청부터 실패했으면 시작한 cursor), 수는 거기까지 */
| ({ status: 'failed'; cursor: string | null; error: ApiError } & InboxCleanupTally)
/** 세션이 바뀌었다 — 아무것도 알리지 않는다 */
| { status: 'canceled' }
export interface InboxCleanupLoop {
/** 시작할 자리와 수 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
start: InboxCleanupTally & { cursor: string | null }
/** 한 요청을 보낸다 — 알림을 끄고(silent) */
send: (cursor: string | null) => Promise<InboxCleanupStep>
onProgress: (tally: InboxCleanupTally) => void
/** 시작한 세션이 그대로인가 (authStore.generation) */
sameSession: () => boolean
/** 화면이 따로 알리는 거절 — 그 밖의 실패는 여기서 사전 문구로 알린다 */
screenNotified: readonly ErrorCode[]
/** 나아가지 못한 응답이 이만큼 이어지면 멈춘다 */
maxStalls: number
}
const CANCELED = { status: 'canceled' } as const
/** 서버가 준 수 — 음수는 0 으로 */
function countOf(value: number): number {
return Math.max(0, value)
}
/** 끝날 때까지(cursor 가 null) 보낸다 — 세션이 바뀌면 멈춘다 */
export async function runInboxCleanup(loop: InboxCleanupLoop): Promise<InboxCleanupOutcome> {
let { cursor, scanned, matched, total } = loop.start
let stalls = 0
for (;;) {
let step: InboxCleanupStep
try {
step = await loop.send(cursor)
} catch (caught) {
if (!loop.sameSession()) return CANCELED
const error = toApiError(caught)
if (!loop.screenNotified.includes(error.code)) notifyApiError(error)
return { status: 'failed', cursor, scanned, matched, total, error }
}
if (!loop.sameSession()) return CANCELED
const stepScanned = countOf(step.scanned)
scanned += stepScanned
matched += countOf(step.matched)
total = scanned + countOf(step.remaining)
if (step.cursor === null) return { status: 'done', scanned, matched, total }
cursor = step.cursor
stalls = stepScanned > 0 ? 0 : stalls + 1
if (stalls >= loop.maxStalls) {
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
return { status: 'failed', cursor, scanned, matched, total, error }
}
loop.onProgress({ scanned, matched, total })
}
}
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MailBlockRun, MailBlockRunRequest } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import type { InboxCleanupTally } from '@/stores/inbox-cleanup-run'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailBlockRunStore, type MailBlockRunStart } from './mail-block-run.store'
// 차단할 때 받은 메일 옮기기 — cursor 가 null 이 될 때까지 · 수를 이어 센다 · 이 탭의 받은편지함 정리는 한 번에 하나 · 세션이 바뀌면 멈춘다
// (계약 2절 "보낸 사람 차단" — 15단계 POST /mail-block-runs)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const FROM_START: MailBlockRunStart = { sender: 'news@example.com', cursor: null, scanned: 0, matched: 0, total: 4800 }
const JUNK: Mailbox = { id: 'junk', name: '스팸메일함', role: 'junk', parentId: null, sortOrder: 0, totalCount: 40, unreadCount: 0 }
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
const run: MailBlockRun = { scanned, matched, remaining, cursor }
return ok(run)
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('mail-block-run.store', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
function replies(...results: (MockResult | Promise<MockResult>)[]) {
let next = 0
requests = installMockApi(() => {
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
next += 1
return result
})
}
const bodies = () => requests.map((request) => request.data as MailBlockRunRequest)
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
it('cursor 가 null 이 될 때까지 같은 sender 에 cursor 를 넣어 다시 보내고, 확인 · 옮긴 수를 이어 센다 — 요청은 알림을 끄고 보낸다', async () => {
replies(step(1200, 10, 3600, 'b1'), step(3600, 27, 0, null))
const store = useMailBlockRunStore()
const progress: InboxCleanupTally[] = []
const running = store.run(FROM_START, (tally) => progress.push(tally))
expect(store.running).toBe(true)
expect(store.inboxBusy).toBe(true)
const outcome = await running
expect(outcome).toEqual({ status: 'done', scanned: 4800, matched: 37, total: 4800 })
expect(bodies()).toEqual([
{ sender: 'news@example.com', cursor: null },
{ sender: 'news@example.com', cursor: 'b1' },
])
expect(requests.every((request) => request.config.silent === true)).toBe(true)
expect(progress).toEqual([{ scanned: 1200, matched: 10, total: 4800 }])
expect(store.running).toBe(false)
})
it('이 탭에서 받은편지함을 정리하는 중이면(필터 적용 · 다른 차단 정리) 보내지 않는다 — busy', async () => {
const first = deferred()
replies(first.promise, step(10, 0, 0, null))
const store = useMailBlockRunStore()
const running = store.run(FROM_START, () => undefined)
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
first.finish(step(10, 0, 0, null))
await running
// 필터 적용이 도는 동안도 — 같은 직원의 받은편지함 정리는 한 번에 하나
useMailFilterRunStore().running = true
expect(store.inboxBusy).toBe(true)
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
expect(requests).toHaveLength(1)
})
it('이 탭에서 스팸메일함을 비우는 동안은 보내지 않는다(startBlock junk) — 필터 적용은 막지 않는다(inboxBusy 는 그대로), 끝나면 다시 시작할 수 있다', async () => {
const emptying = deferred()
requests = installMockApi((request) =>
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : step(10, 0, 0, null),
)
const mail = useMailStore()
mail.mailboxes = [JUNK]
const store = useMailBlockRunStore()
expect(store.startBlock).toBeNull()
const empty = mail.runMailboxOperation('empty', JUNK, () => undefined)
expect(store.startBlock).toBe('junk')
expect(store.inboxBusy).toBe(false)
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
expect(requests.filter((request) => request.url === '/mail-block-runs')).toEqual([])
emptying.finish(ok({ processed: 40, remaining: 0 }))
expect((await empty).status).toBe('done')
expect(store.startBlock).toBeNull()
expect((await store.run(FROM_START, () => undefined)).status).toBe('done')
expect(requests.filter((request) => request.url === '/mail-block-runs')).toHaveLength(1)
})
it('받은편지함 정리가 먼저 보인다 — 필터 적용 중이면 스팸메일함을 비우는 중이어도 inbox', async () => {
const emptying = deferred()
requests = installMockApi(() => emptying.promise)
const mail = useMailStore()
mail.mailboxes = [JUNK]
const store = useMailBlockRunStore()
const empty = mail.runMailboxOperation('empty', JUNK, () => undefined)
useMailFilterRunStore().running = true
expect(store.startBlock).toBe('inbox')
useMailFilterRunStore().running = false
expect(store.startBlock).toBe('junk')
emptying.finish(ok({ processed: 40, remaining: 0 }))
await empty
})
it('도중에 실패하면 거기까지의 수와 이어 할 자리를 돌려준다 — 까닭은 사전 문구로, 429 REQ_001 은 알리지 않는다(화면이 "다른 창에서 …")', async () => {
replies(step(1200, 12, 3600, 'b1'), fail(503, 'MAIL_001'))
const stopped = await useMailBlockRunStore().run(FROM_START, () => undefined)
expect(stopped).toMatchObject({ status: 'failed', cursor: 'b1', scanned: 1200, matched: 12, total: 4800 })
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
useToastStore().clear()
replies(fail(429, 'REQ_001'))
const busyElsewhere = await useMailBlockRunStore().run(FROM_START, () => undefined)
expect(busyElsewhere).toMatchObject({ status: 'failed', cursor: null, error: { code: 'REQ_001' } })
expect(toasts()).toEqual([])
})
it('세션이 바뀌면 다음 요청을 보내지 않는다 — 늦게 온 응답도 쓰지 않고, 새 세션은 바로 시작할 수 있다', async () => {
const first = deferred()
replies(first.promise, step(10, 0, 0, null))
const store = useMailBlockRunStore()
const running = store.run(FROM_START, () => undefined)
useAuthStore().clearAuth()
await nextTick()
expect(store.running).toBe(false)
useAuthStore().startSession(USER)
await nextTick()
first.finish(step(1200, 12, 3600, 'b1'))
expect(await running).toEqual({ status: 'canceled' })
expect(requests).toHaveLength(1)
await flushPromises()
expect(store.running).toBe(false)
})
})
@@ -0,0 +1,96 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useMail } from '@/composables/useMail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_BLOCK_RUN_MAX_STALLS } from '@/shared/constants/mail-block'
import { useAuthStore } from '@/stores/auth.store'
import { runInboxCleanup, type InboxCleanupOutcome, type InboxCleanupTally } from '@/stores/inbox-cleanup-run'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
// 차단할 때 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일을 스팸메일함으로 — 계약 2절 "보낸 사람 차단" (15단계 — POST /api/mail-block-runs)
// - 필터 적용(7단계 — mail-filter-run.store)과 같은 결이다: 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지
// 같은 sender 에 그 cursor 를 넣어 다시 보낸다(되풀이는 inbox-cleanup-run.ts). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
// - 같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나다 — 다른 탭 · PC 에서 하는 중이면 서버가 429 REQ_001(화면이 "다른 창에서 …"로 알린다),
// 이 탭에서 필터 적용이나 다른 차단 정리가 도는 동안은 시작하지 않는다(inboxBusy — 차단 창의 새 칸을 끈다)
// - 이 탭에서 스팸메일함을 비우는 동안도 시작하지 않는다(15단계 리뷰 — 옮겨 넣은 메일이 들어오자마자 영구 삭제되지 않게, 반대 방향은
// mailbox-admin 의 isOperationBlocked). 서버도 요청마다 스팸메일함 처리 잠금을 함께 잡는다(다른 창에서 비우는 중이면 429 — 위와 같은 알림).
// 필터 적용은 스팸메일함에 넣지 않으므로 막지 않는다 — inboxBusy 는 그대로이고 차단 정리만 startBlock 을 본다
// - 그 밖의 실패 까닭은 사전 문구로 알린다 — 요청은 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 알린다
// - 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 — authStore.generation) 다음 요청을 보내지 않고 멈춘다(canceled)
// - 화면과 상관없이 앱이 떠 있는 동안 이어 한다(다른 화면으로 가도 된다). 탭을 닫으면 멈춘다 — 이어 하지 않는다
/** 처음부터(cursor null · 수 0 · 전체는 받은편지함의 메일 수) 또는 앞 실패의 자리에서 */
export interface MailBlockRunStart extends InboxCleanupTally {
/** 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" */
sender: string
cursor: string | null
}
export type MailBlockRunOutcome =
| InboxCleanupOutcome
/** 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이다 — 보내지 않았다 */
| { status: 'busy' }
/** 차단 정리를 지금 시작할 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk) */
export type MailBlockRunBlock = 'inbox' | 'junk'
/** 화면이 알리는 거절 — 같은 직원의 받은편지함 정리나 스팸메일함 비우기가 다른 곳에서 진행 중 */
const SCREEN_NOTIFIED: readonly ErrorCode[] = ['REQ_001']
export const useMailBlockRunStore = defineStore('mail-block-run', () => {
const mailApi = useMail()
const authStore = useAuthStore()
const filterRunStore = useMailFilterRunStore()
// 메일 스토어도 셋업에서 이 스토어를 부른다 — 서로 셋업에서는 읽지 않고 계산할 때만 읽는다 (Pinia 의 서로 쓰는 스토어)
const mailStore = useMailStore()
/** 이 탭에서 차단 정리를 하는 중 — 한 번에 하나 */
const running = ref<boolean>(false)
/** 이 탭에서 받은편지함을 정리하는 중 — 필터 적용 · 차단 정리 (같은 직원은 한 번에 하나 — 차단 창의 새 칸을 끈다) */
const inboxBusy = computed<boolean>(() => running.value || filterRunStore.running)
/** 이 탭에서 스팸메일함을 비우는 중 (mailbox-admin — 15단계 리뷰) — 차단 정리만 막는다 */
const junkEmptying = computed<boolean>(() => {
const junk = mailStore.mailboxByRole('junk')
return junk !== null && mailStore.isMailboxOperationRunning('empty', junk.id)
})
/** 차단 정리를 지금 시작할 수 없는 까닭 — 없으면 null (차단 창의 새 칸 · 시작 · [다시 시도]가 함께 본다) */
const startBlock = computed<MailBlockRunBlock | null>(() => {
if (inboxBusy.value) return 'inbox'
return junkEmptying.value ? 'junk' : null
})
/** 끝날 때까지(cursor 가 null) 보낸다 — 세션이 바뀌면 멈춘다 */
async function run(
start: MailBlockRunStart,
onProgress: (tally: InboxCleanupTally) => void,
): Promise<MailBlockRunOutcome> {
if (startBlock.value !== null) return { status: 'busy' }
const session = authStore.generation
running.value = true
try {
return await runInboxCleanup({
start,
send: (cursor) => mailApi.runSenderBlock({ sender: start.sender, cursor }, { silent: true }),
onProgress,
sameSession: () => authStore.generation === session,
screenNotified: SCREEN_NOTIFIED,
maxStalls: MAIL_BLOCK_RUN_MAX_STALLS,
})
} finally {
// 세션이 바뀐 뒤 끝난 옛 정리는 새 세션의 표시를 건드리지 않는다 (아래 watch 가 이미 내렸다)
if (authStore.generation === session) running.value = false
}
}
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 하던 정리는 다음 요청을 보내지 않는다(run 이 멈춘다). 새 세션은 바로 시작할 수 있다
watch(
() => authStore.generation,
() => {
running.value = false
},
)
return { running, inboxBusy, startBlock, run }
})
+15 -41
View File
@@ -1,23 +1,25 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
import { useMail } from '@/composables/useMail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_RUN_MAX_STALLS } from '@/shared/constants/mail-filter'
import type { MailFilterRun, MailFilterRunRule } from '@/shared/types/mail'
import type { MailFilterRunRule } from '@/shared/types/mail'
import type { MailFilterRunTally } from '@/shared/utils/mail-filter-run'
import { useAuthStore } from '@/stores/auth.store'
import { runInboxCleanup, type InboxCleanupOutcome } from '@/stores/inbox-cleanup-run'
// 이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용 (/api/mail-filter-runs)" (7단계)
// - 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 규칙에 그 cursor 를 넣어 다시 보낸다.
// 다시 보낼 때마다 onProgress (확인 = scanned 합, 전체 = 확인 + remaining). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
// (되풀이는 차단 정리와 함께 쓰는 inbox-cleanup-run.ts — 15단계에서 나눴다)
// - 한 탭에서 한 번에 하나(running — 고치기 창이 그동안 「…에도 적용」 칸을 끈다). 다른 탭 · PC 의 적용은 서버가 429 REQ_001 로 막는다 —
// 그 알림은 화면이 "다른 창에서 …"로 한다. 그 밖의 실패 까닭은 여기서 사전 문구로 알린다(4단계 오래 걸리는 처리의 인터셉터 알림과 같은 알림 —
// 그 알림은 화면이 "다른 창에서 …"로 한다. 그 밖의 실패 까닭은 사전 문구로 알린다(4단계 오래 걸리는 처리의 인터셉터 알림과 같은 알림 —
// 요청은 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 알린다: 세션이 바뀐 뒤 늦게 온 실패는 알리지 않는다)
// - 한 번도 나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 이어지면 멈추고 BIZ_001 — 같은 요청을 끝없이 보내지 않게 (서버는 그럴 때 503 을 준다)
// - 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 — authStore.generation) 다음 요청을 보내지 않고 멈춘다(canceled). 늦게 온 응답도 쓰지 않는다
// - 화면과 상관없이 앱이 떠 있는 동안 이어 한다(다른 화면으로 가도 된다). 탭을 닫으면 멈춘다 — 이어 하지 않는다
// - 차단할 때 받은 메일 옮기기(15단계 — mail-block-run.store)는 이 탭에서 이것이 도는 동안 시작하지 않는다(차단 창의 새 칸을 끈다)
/** 처음부터(cursor null · 수 0 · 전체는 받은편지함의 메일 수) 또는 앞 실패의 자리에서 */
export interface MailFilterRunStart extends MailFilterRunTally {
@@ -26,22 +28,12 @@ export interface MailFilterRunStart extends MailFilterRunTally {
}
export type MailFilterRunOutcome =
| ({ status: 'done' } & MailFilterRunTally)
/** 도중에 멈췄다 — cursor 는 이어 할 자리(마지막으로 받은 cursor — 처음 요청부터 실패했으면 null), 수는 거기까지 */
| ({ status: 'failed'; cursor: string | null; error: ApiError } & MailFilterRunTally)
| InboxCleanupOutcome
/** 이 탭에서 다른 적용이 돌고 있다 — 보내지 않았다 */
| { status: 'busy' }
/** 세션이 바뀌었다 — 아무것도 알리지 않는다 */
| { status: 'canceled' }
/** 화면이 알리는 거절 — 같은 직원의 적용이 다른 곳에서 진행 중 (그 밖의 실패는 여기서 알린다) */
const SCREEN_NOTIFIED: readonly ErrorCode[] = ['REQ_001']
const CANCELED = { status: 'canceled' } as const
/** 서버가 준 수 — 음수는 0 으로 */
function countOf(value: number): number {
return Math.max(0, value)
}
export const useMailFilterRunStore = defineStore('mail-filter-run', () => {
const mailApi = useMail()
@@ -58,34 +50,16 @@ export const useMailFilterRunStore = defineStore('mail-filter-run', () => {
if (running.value) return { status: 'busy' }
const session = authStore.generation
running.value = true
let { cursor, scanned, matched, total } = start
let stalls = 0
try {
for (;;) {
let step: MailFilterRun
try {
// 알림을 끄고 보낸다 — 실패를 알릴지는 응답이 온 때의 세션으로 정한다 (세션 만료 대화상자는 그대로 뜬다)
step = await mailApi.runFilter({ ...start.rule, cursor }, { silent: true })
} catch (caught) {
if (authStore.generation !== session) return CANCELED
const error = toApiError(caught)
if (!SCREEN_NOTIFIED.includes(error.code)) notifyApiError(error)
return { status: 'failed', cursor, scanned, matched, total, error }
}
if (authStore.generation !== session) return CANCELED
const stepScanned = countOf(step.scanned)
scanned += stepScanned
matched += countOf(step.matched)
total = scanned + countOf(step.remaining)
if (step.cursor === null) return { status: 'done', scanned, matched, total }
cursor = step.cursor
stalls = stepScanned > 0 ? 0 : stalls + 1
if (stalls >= MAIL_FILTER_RUN_MAX_STALLS) {
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
return { status: 'failed', cursor, scanned, matched, total, error }
}
onProgress({ scanned, matched, total })
}
return await runInboxCleanup({
start,
// 알림을 끄고 보낸다 — 실패를 알릴지는 응답이 온 때의 세션으로 정한다 (세션 만료 대화상자는 그대로 뜬다)
send: (cursor) => mailApi.runFilter({ ...start.rule, cursor }, { silent: true }),
onProgress,
sameSession: () => authStore.generation === session,
screenNotified: SCREEN_NOTIFIED,
maxStalls: MAIL_FILTER_RUN_MAX_STALLS,
})
} finally {
// 세션이 바뀐 뒤 끝난 옛 적용은 새 세션의 표시를 건드리지 않는다 (아래 watch 가 이미 내렸다)
if (authStore.generation === session) running.value = false
+24 -4
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { useMail } from '@/composables/useMail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { createBulkActions } from './mail/bulk'
import { createConversationState } from './mail/conversation'
import { createListState } from './mail/list'
@@ -14,15 +15,16 @@ import { createSelection } from './mail/selection'
export type { BulkResult } from '@/shared/utils/mail-bulk'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
export type { MailboxOperationOptions, MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
export type { ConversationDetailState } from './mail/conversation'
export type { ReleaseOutcome } from './mail/scheduled'
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 엔진이 풀었는지 다시 보기
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 옮기기(15단계) · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기 — 하위까지 지우기 포함)
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 지금 보내기(15단계) · 엔진이 풀었는지 다시 보기
// conversation.ts 읽기 화면의 같은 대화 · 펼친 줄의 본문 (14단계)
//
// 오류 처리 원칙:
@@ -43,7 +45,14 @@ 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, opened })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
// 차단할 때 받은 메일을 옮기는 동안(15단계)은 스팸메일함 비우기를 시작하지 않는다 (mailbox-admin.ts 의 isOperationBlocked)
const blockRunStore = useMailBlockRunStore()
const admin = createMailboxAdmin({
mailApi,
mailboxes: mailboxState,
list,
blockCleanupRunning: () => blockRunStore.running,
})
const scheduled = createScheduledState({
mailApi,
mailboxes: mailboxState,
@@ -227,6 +236,9 @@ export const useMailStore = defineStore('mail', () => {
loadScheduledCount: scheduled.loadScheduledCount,
cancelPendingSend: scheduled.cancelPendingSend,
isCancellingPendingSend: scheduled.isCancellingPendingSend,
// 예약 메일 지금 보내기 (15단계)
releasePendingSend: scheduled.releasePendingSend,
isReleasingPendingSend: scheduled.isReleasingPendingSend,
refreshPendingSends,
// 같은 대화 (14단계)
conversationMessageId: conversation.conversationMessageId,
@@ -250,6 +262,12 @@ export const useMailStore = defineStore('mail', () => {
loadMoreConversation: conversation.loadMoreConversation,
toggleConversationMessage: conversation.toggleConversationMessage,
retryConversationDetail: conversation.retryConversationDetail,
// 같은 대화 [모두 펼치기] · [모두 접기] (15단계)
conversationExpandableIds: conversation.conversationExpandableIds,
conversationAllExpanded: conversation.conversationAllExpanded,
expandAllConversation: conversation.expandAllConversation,
collapseAllConversation: conversation.collapseAllConversation,
noteConversationPanelVisible: conversation.noteConversationPanelVisible,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
toggleConversationFlag: bulk.toggleConversationFlag,
@@ -264,6 +282,8 @@ export const useMailStore = defineStore('mail', () => {
isMailboxOperationBlocked: admin.isOperationBlocked,
createMailbox: admin.createMailbox,
renameMailbox: admin.renameMailbox,
// 메일함 옮기기 · 하위까지 지우기 (15단계 — runMailboxOperation 의 withChildren)
moveMailbox: admin.moveMailbox,
runMailboxOperation: admin.runMailboxOperation,
// 쓰기 화면
lastBrowseLocation,
+128 -2
View File
@@ -16,6 +16,11 @@
// 같은 길(markRead — 목록 · 메일함 개수도 맞춘다)로 읽음. 이 탭이 아는 값(열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것)을 먼저 본다 —
// 받아 둔 대화가 옛 값이어도 같은 메일을 두 번 읽음으로 바꾸지 않게. 받아 둔 본문은 접힌 줄의 것을 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만 둔다.
// 그사이 지워진 메일(본문이 404 RES_001)은 대화를 다시 받아 그 줄을 뺀다([더 보기]로 더 받은 대화는 그 줄만 뺀다)
// 모두 펼치기 · 모두 접기 (15단계 — 디자인 원본 Reader15 "6. 같은 대화"): 모두 펼치기는 불러온 줄(지금 메일 빼고)을 모두 펼치되 본문은 바로 받지 않는다 —
// 칸이 알려 주는 대로(noteConversationPanelVisible — 펼친 안이 화면에 들어옴 · 나감) 화면에 들어온 줄부터 하나씩(detailQueue) 받는다.
// 100통을 한꺼번에 부르지 않는다 — 요청 수 한도(경로 · IP 마다 1분 100회, 사무실은 함께 센다)와 화면 무게. 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛴다.
// 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이 된다(펼쳤어도 화면 밖이면 그대로: 받았을 때 화면 밖이었으면 들어올 때 읽음).
// [더 보기]로 더 받은 줄은 접힌 채로 온다. 모두 접기는 펼친 줄을 모두 접는다(받아 둔 본문은 접힌 줄의 상한까지만 둔다)
// 이 탭이 바꾼 읽음 (2026-11-07 — 칸이 안 읽은 줄을 굵게 그린다): 목록 · 열린 메일에 읽음을 적을 때마다(list.ts 의 watchLocalReads — 펼친 줄 · 읽기 화면에서
// 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 실패해 되돌림 · 서버가 돌려준 요약 · 메일함 ⋯ › 모두 읽음) 칸의 줄에도 바로 적는다.
// 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다 — 받은 쪽은 그 바꿈이 서버에 닿기 전의 값일 수 있다.
@@ -132,6 +137,15 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
)
/** 대화를 묻는 중 — 끝나면 칸이 다시 받기의 간격을 그때부터 센다 (실제로 물었을 때만 켜진다) */
const conversationBusy = computed<boolean>(() => conversationInFlight.value > 0)
/** 펼칠 수 있는 줄 — 불러온 줄에서 지금 메일을 뺀 것 (15단계 — [모두 펼치기]는 이것이 있을 때만) */
const conversationExpandableIds = computed<string[]>(() =>
conversationItems.value.map((item) => item.id).filter((id) => id !== conversationMessageId.value),
)
/** 펼칠 수 있는 줄이 모두 펼쳐져 있다 — 단추가 [모두 접기]가 된다(하나씩 다 펼쳐도) */
const conversationAllExpanded = computed<boolean>(() => {
const ids = conversationExpandableIds.value
return ids.length > 0 && ids.every((id) => conversationExpandedIds.value.has(id))
})
let runSeq = 0
let conversationSeq = 0
@@ -147,6 +161,15 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
*/
const localReads = new Map<string, LocalRead>()
let localReadSeq = 0
/**
* 모두 펼치기 (머리말 — 15단계) — 화면에 그리지 않으므로 반응형이 아니다.
* lazyRows: 모두 펼치기로 펼쳤고 아직 읽음을 보지 않은 줄(본문이 화면에 보일 때 읽음) · visiblePanels: 펼친 안이 화면에 들어와 있는 줄 ·
* detailQueue: 화면에 들어온 차례대로 본문을 받을 줄 (하나씩 — pumpingDetails)
*/
const lazyRows = new Set<string>()
const visiblePanels = new Set<string>()
const detailQueue: string[] = []
let pumpingDetails = false
/** 받는 일 하나의 확인 — 그 뒤 다른 일이 시작됐거나 · 다른 대화 · 세션이 바뀌었으면 false */
function liveCheck(messageId: string): () => boolean {
@@ -295,7 +318,10 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
conversationPageInfo.value = gathered.pageInfo
const ids = new Set(gathered.items.map((item) => item.id))
const expanded = [...conversationExpandedIds.value]
if (expanded.some((id) => !ids.has(id))) conversationExpandedIds.value = new Set(expanded.filter((id) => ids.has(id)))
if (expanded.some((id) => !ids.has(id))) {
for (const id of expanded) if (!ids.has(id)) forgetLazy(id)
conversationExpandedIds.value = new Set(expanded.filter((id) => ids.has(id)))
}
}
/** 받아 둔 본문을 고친 뒤, 접힌 줄의 것이 상한을 넘으면 오래 보지 않은 것부터 버린다 (펼친 줄의 것은 늘 둔다) */
@@ -319,6 +345,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
function collapse(id: string) {
if (!conversationExpandedIds.value.has(id)) return
forgetLazy(id)
const next = new Set(conversationExpandedIds.value)
next.delete(id)
conversationExpandedIds.value = next
@@ -366,6 +393,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
conversationExpandedIds.value = new Set()
conversationDetails.value = new Map()
markedRead.clear()
clearLazy()
}
const live = liveCheck(messageId)
// 처음 받을 때는 쪽마다 보인다 — 두 통 넘게 모였거나 · 다른 메일이 있다는 수(total)를 받았거나 · 더 묻지 않으면 칸을 그리고 뒤에서 잇는다
@@ -546,7 +574,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
const detail = await mailApi.getMessage(id, { silent: true })
if (stale()) return
setDetail(id, { status: 'ready', detail })
if (conversationExpandedIds.value.has(id)) void readExpanded(id, detail)
readIfSeen(id, detail)
} catch (error) {
if (stale()) return
const apiError = toApiError(error)
@@ -577,6 +605,98 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
return conversationDetails.value.get(id)?.status === 'error' ? loadConversationDetail(id) : Promise.resolve()
}
// ── 모두 펼치기 · 모두 접기 (머리말 — 15단계) ─────────────────────
/**
* 받은 본문을 읽음으로 — 펼쳐 있을 때만. 모두 펼치기로 펼친 줄은 본문이 화면에 보일 때만(화면 밖이면 들어올 때 — noteConversationPanelVisible).
* 손으로 펼친 줄은 누른 그 자리라 받는 대로다 (지금 규칙 그대로)
*/
function readIfSeen(id: string, detail: MessageDetail) {
if (!conversationExpandedIds.value.has(id)) return
if (lazyRows.has(id)) {
if (!visiblePanels.has(id)) return
lazyRows.delete(id)
}
void readExpanded(id, detail)
}
/** 그 줄의 모두 펼치기 몫을 잊는다 — 접었다 · 대화에서 빠졌다 */
function forgetLazy(id: string) {
lazyRows.delete(id)
const index = detailQueue.indexOf(id)
if (index >= 0) detailQueue.splice(index, 1)
}
function clearLazy() {
lazyRows.clear()
visiblePanels.clear()
detailQueue.length = 0
}
/**
* 화면에 들어온 차례대로 본문을 하나씩 받는다 — 차례가 왔을 때 접혔거나 · 화면 밖으로 나갔거나 · 이미 받았으면(받는 중 포함) 건너뛴다.
* 대화 · 세션이 바뀌면 받은 것을 버린다(loadConversationDetail)
*/
async function pumpDetailQueue(): Promise<void> {
if (pumpingDetails) return
pumpingDetails = true
try {
for (let id = detailQueue.shift(); id !== undefined; id = detailQueue.shift()) {
if (!conversationExpandedIds.value.has(id) || !visiblePanels.has(id) || conversationDetails.value.has(id)) continue
await loadConversationDetail(id)
}
} finally {
pumpingDetails = false
}
}
/**
* [모두 펼치기] — 불러온 줄(지금 메일 빼고)을 모두 펼친다. 본문은 펼친 안이 화면에 들어올 때 받는다(noteConversationPanelVisible).
* 이미 펼친 줄은 그대로 둔다(손으로 펼친 줄은 받는 대로 읽음이다)
*/
function expandAllConversation(): void {
const next = new Set(conversationExpandedIds.value)
for (const id of conversationExpandableIds.value) {
if (next.has(id)) continue
next.add(id)
lazyRows.add(id)
}
if (next.size !== conversationExpandedIds.value.size) conversationExpandedIds.value = next
}
/** [모두 접기] — 펼친 줄을 모두 접는다. 받아 둔 본문은 접힌 줄의 상한까지만 남는다 */
function collapseAllConversation(): void {
if (conversationExpandedIds.value.size === 0) return
lazyRows.clear()
detailQueue.length = 0
conversationExpandedIds.value = new Set()
setDetails(() => undefined)
}
/**
* 펼친 줄의 안이 화면에 들어왔다 · 나갔다 (칸이 알린다 — MessageConversation). 들어왔는데 본문이 없으면 받을 차례에 넣고,
* 받아 둔 본문이 있는데 모두 펼치기로 펼친 줄이면 이제 보였으니 읽음으로. 나가면 받을 차례에서 뺀다(다시 들어오면 다시 넣는다)
*/
function noteConversationPanelVisible(id: string, visible: boolean): void {
if (!visible) {
visiblePanels.delete(id)
const index = detailQueue.indexOf(id)
if (index >= 0) detailQueue.splice(index, 1)
return
}
visiblePanels.add(id)
if (!conversationExpandedIds.value.has(id)) return
const state = conversationDetails.value.get(id)
if (state?.status === 'ready') {
// 손으로 펼친 줄은 받을 때 읽음으로 했다 — 화면에 드나들 때마다 다시 묻지 않는다(읽음이 실패한 뒤에도)
if (lazyRows.has(id)) readIfSeen(id, state.detail)
return
}
if (state || detailQueue.includes(id)) return
detailQueue.push(id)
void pumpDetailQueue()
}
function reset() {
runSeq += 1
conversationSeq += 1
@@ -593,6 +713,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
refreshAgain = false
markedRead.clear()
localReads.clear()
clearLazy()
}
return {
@@ -610,6 +731,8 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
conversationVisible,
conversationFailed,
conversationBusy,
conversationExpandableIds,
conversationAllExpanded,
loadConversation,
retryConversation,
refreshConversation,
@@ -618,6 +741,9 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
loadMoreConversation,
toggleConversationMessage,
retryConversationDetail,
expandAllConversation,
collapseAllConversation,
noteConversationPanelVisible,
reset,
}
}
+65 -21
View File
@@ -1,10 +1,15 @@
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계, mail.store.ts 가 조립한다)
// - 만들기 · 이름 바꾸기: 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계 · 15단계, mail.store.ts 가 조립한다)
// - 만들기 · 이름 바꾸기 · 옮기기(15단계): 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
// - 하위 메일함까지 지우기(15단계): 같은 지우기에 withChildren 을 실어 보낸다 — 나무 전체를 "지우는 중"으로 두고 끝나면 나무 전체를 뺀다
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다 —
// 0(지우기는 deleted: true)이 될 때까지 같은 요청을 다시 보내며 진행을 알린다. 도중에 실패하면 거기까지 처리한 수를 돌려준다
// (다시 하면 남은 것만 처리한다). 요청 실패는 인터셉터가 알린다. 그동안 다른 일을 해도 된다 — 같은 메일함의 같은 처리만 한 번에 하나
// - 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다: 비우기는 차례마다 그때의 휴지통을 다시 읽어 지우므로, 그동안 지운 메일함의 메일이
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다). 스팸메일함 비우기는 상관없다 (isOperationBlocked)
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다) (isOperationBlocked)
// - 같은 결로 스팸메일함 비우기는 이 탭에서 차단할 때 받은 메일을 옮기는 동안(15단계) 시작하지 않는다 — 그동안 옮겨 온 메일이 들어오자마자
// 영구 삭제되지 않게 (blockCleanupRunning — 반대로 차단 정리도 이 탭에서 스팸메일함을 비우는 동안 시작하지 않는다, mail-block-run.store 의 startBlock).
// 다른 창 · PC 와는 막지 못한다 — 서버의 (직원, 스팸메일함) 잠금은 요청 하나 동안만이라 두 창의 비우기와 차단 정리가 요청 사이에 번갈아 돌 수 있고,
// 그러면 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다(알려진 한계 — 다른 창끼리의 휴지통 비우기와 메일함 지우기와 같다, 계약 2절 "보낸 사람 차단")
// - 이미 지워진 메일함(지우기의 404 RES_001 — 다른 탭이 먼저 지웠거나, 응답이 끊긴 뒤 다시 보냈다)은 실패가 아니라 끝난 것으로 다룬다
// - 세션이 바뀌면(로그아웃 · 다른 계정 — reset) 하던 처리는 더 보내지 않고 멈춘다 (canceled — 알리지 않는다).
// 만들기 · 이름 바꾸기의 늦은 응답도 쓰지 않는다 — 메일함 id 는 계정마다 겹칠 수 있어 다음 사용자의 메일함을 덮게 된다
@@ -14,6 +19,7 @@ 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 { descendantMailboxes } from '@/shared/utils/mailbox'
import { initialOperationTotal, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import type { ListState } from './list'
@@ -24,6 +30,8 @@ interface MailboxAdminDeps {
mailApi: MailApi
mailboxes: MailboxesState
list: ListState
/** 이 탭에서 차단할 때 받은 메일을 옮기는 중인가 (mail-block-run.store 의 running — 15단계) — 그동안 스팸메일함 비우기를 막는다 */
blockCleanupRunning?: () => boolean
}
/** 진행 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
@@ -32,6 +40,12 @@ export interface MailboxOperationProgress {
total: number
}
/** 오래 걸리는 처리의 옵션 */
export interface MailboxOperationOptions {
/** 지우기 — 하위 메일함까지 (15단계 — 확인 창이 하위까지 지울지 물은 뒤에만. 서버는 ?withChildren=true 일 때만 하위가 있는 메일함을 지운다) */
withChildren?: boolean
}
export type MailboxOperationOutcome =
/** 끝났다 — 처리한 수 (0 이면 이미 끝나 있었다) · 휴지통으로 옮긴 수 (지우기에서만) */
| { status: 'done'; processed: number; movedToTrash: number }
@@ -58,7 +72,7 @@ function operationKey(kind: MailboxOperationKind, mailboxId: string): string {
return `${kind}:${mailboxId}`
}
export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDeps) {
export function createMailboxAdmin({ mailApi, mailboxes, list, blockCleanupRunning = () => false }: MailboxAdminDeps) {
/** 돌고 있는 처리 — "종류:메일함 id" (메뉴 항목 · 비우기 단추를 그동안 끈다) */
const runningOperations = shallowRef<ReadonlyMap<string, RunningOperation>>(new Map())
/** 세션이 바뀔 때마다 오른다 — 하던 처리 · 늦게 온 응답은 쓰지 않고 멈춘다 */
@@ -69,8 +83,9 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
}
/**
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기 (위 설명).
* 스팸메일함 비우기 · 모두 읽음은 막지 않는다
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기,
* 차단할 때 받은 메일을 옮기는 동안의 스팸메일함 비우기 (위 설명 — 메뉴 · 목록 머리 [비우기] · 확인 단추가 모두 이것을 본다).
* 모두 읽음은 막지 않는다
*/
function isOperationBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
if (kind === 'delete') {
@@ -80,6 +95,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
if (kind === 'empty' && mailbox.role === 'trash') {
return [...runningOperations.value.values()].some((operation) => operation.kind === 'delete')
}
if (kind === 'empty' && mailbox.role === 'junk') return blockCleanupRunning()
return false
}
@@ -118,10 +134,23 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
return renamed
}
async function sendStep(kind: MailboxOperationKind, mailboxId: string): Promise<OperationStep> {
/**
* 메일함 옮기기 (15단계) — 서버가 돌려준 메일함(새 parentId)을 바로 보이고 목록을 서버 기준으로 다시 맞춘다(하위 메일함은 parentId 가 그대로라
* 나무를 따라 함께 옮겨 보인다). 같은 이름이면 409 MAIL_009 (예외가 그대로 올라간다). 기다리는 사이 세션이 바뀌었으면 null
*/
async function moveMailbox(id: string, parentId: string | null, options?: MailRequestOptions): Promise<Mailbox | null> {
const started = epoch
const moved = await mailApi.moveMailbox(id, { parentId }, options)
if (started !== epoch) return null
mailboxes.upsertMailbox(moved)
void mailboxes.loadMailboxes({ background: true })
return moved
}
async function sendStep(kind: MailboxOperationKind, mailboxId: string, withChildren: boolean): Promise<OperationStep> {
if (kind === 'delete') {
// 이미 지워진 메일함(RES_001)은 끝난 것으로 다룬다 — 알림을 끄고 아래 runMailboxOperation 이 받는다
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'] })
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'], withChildren })
const processed = typeof result.processed === 'number' ? result.processed : result.movedToTrash
return { processed, movedToTrash: result.movedToTrash, remaining: result.remaining, done: result.deleted }
}
@@ -129,11 +158,19 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
return { processed: result.processed, movedToTrash: 0, remaining: result.remaining, done: result.remaining <= 0 }
}
/** 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다) */
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep) {
/** 지운 메일함을 패널에서 뺀다 — 하위까지 지웠으면 나무 전체를 (15단계) */
function removeDeleted(tree: readonly string[]) {
for (const id of tree) mailboxes.removeMailbox(id)
}
/**
* 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다).
* 하위까지 지우는 동안의 남은 수는 나무 전체의 것이라 그 메일함의 수로 쓰지 않는다 — 끝나면 나무 전체를 뺀다
*/
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep, tree: readonly string[]) {
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) })
else if (kind === 'delete' && step.done) removeDeleted(tree)
else if (tree.length === 1) mailboxes.setCounts(mailboxId, { totalCount: Math.max(0, step.remaining) })
}
/**
@@ -153,32 +190,38 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
/**
* 오래 걸리는 처리 — 끝날 때까지(남은 수 0 · 지우기는 deleted: true) 같은 요청을 다시 보낸다. 다시 보낼 때마다 onProgress.
* 실패는 요청마다 인터셉터가 알린다(notified). 한 번도 나아가지 못한 응답이 이어지면 멈추고 BIZ_001 로 돌려준다(아직 알리지 않았다).
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled)
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled).
* 하위까지 지우기(options.withChildren — 15단계)는 시작할 때의 나무(그 메일함 + 모든 하위)를 함께 "지우는 중"으로 두고(그 메뉴들도 끈다),
* 전체 수는 나무 전체의 메일 수로 센다. 나무 안의 메일함 하나라도 지우는 중이면 시작하지 않는다
*/
async function runMailboxOperation(
kind: MailboxOperationKind,
mailbox: Mailbox,
onProgress: (progress: MailboxOperationProgress) => void,
options: MailboxOperationOptions = {},
): Promise<MailboxOperationOutcome> {
if (isOperationRunning(kind, mailbox.id) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
const operation: RunningOperation = { kind, mailboxId: mailbox.id }
const withChildren = kind === 'delete' && options.withChildren === true
const descendants = withChildren ? descendantMailboxes(mailboxes.mailboxes.value, mailbox.id) : []
const tree = [mailbox.id, ...descendants.map((child) => child.id)]
if (tree.some((id) => isOperationRunning(kind, id)) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
const operations: RunningOperation[] = tree.map((mailboxId) => ({ kind, mailboxId }))
const started = epoch
markRunning(operation, true)
for (const operation of operations) markRunning(operation, true)
let processed = 0
let movedToTrash = 0
let total = initialOperationTotal(kind, mailbox)
let total = initialOperationTotal(kind, mailbox, descendants)
let stalls = 0
try {
for (;;) {
let step: OperationStep
try {
step = await sendStep(kind, mailbox.id)
step = await sendStep(kind, mailbox.id, withChildren)
} catch (error) {
if (started !== epoch) return { status: 'canceled' }
const apiError = toApiError(error)
if (kind === 'delete' && apiError.code === 'RES_001') {
// 이미 지워진 메일함 — 다른 탭이 먼저 지웠거나 끊긴 응답 뒤에 다시 보냈다
mailboxes.removeMailbox(mailbox.id)
removeDeleted(tree)
settle(kind, mailbox.id)
return { status: 'done', processed, movedToTrash }
}
@@ -189,7 +232,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
processed += Math.max(0, step.processed)
movedToTrash += Math.max(0, step.movedToTrash)
total = processed + Math.max(0, step.remaining)
applyStep(kind, mailbox.id, step)
applyStep(kind, mailbox.id, step, tree)
if (step.done) break
stalls = step.processed > 0 ? 0 : stalls + 1
if (stalls >= MAILBOX_OPERATION_MAX_STALLS) {
@@ -203,7 +246,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
return { status: 'done', processed, movedToTrash }
} finally {
// 세션이 바뀐 뒤 끝난 옛 처리는 새 세션의 표시를 건드리지 않는다 (reset 이 이미 비웠다)
if (started === epoch) markRunning(operation, false)
if (started === epoch) for (const operation of operations) markRunning(operation, false)
}
}
@@ -218,6 +261,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
isOperationBlocked,
createMailbox,
renameMailbox,
moveMailbox,
runMailboxOperation,
reset,
}
+160 -3
View File
@@ -6,19 +6,48 @@
// 같은 메일의 취소는 한 번에 하나다 — 이어 누른 두 번째가 첫 번째의 결과를 기다린다(두 번째 요청이 "이미 보냈다"(MAIL_014)로 거짓 알림을 띄우지 않게)
// - 엔진이 붙잡은 발송을 풀어도(보냄) 새 메일 확인 · 실시간 알림의 state 는 바뀌지 않는다 — 그래서 보낼 시각이 지나면 보이는 목록 · 열린 메일 ·
// 예약됨 수를 다시 본다(refreshPendingSends — 때는 usePendingSendRefresh 가 정한다). 취소가 MAIL_014(이미 보냄)이면 바로 다시 본다
// - 지금 보내기(15단계)는 결과를 ReleaseOutcome 으로 돌려준다 — 503 MAIL_001 · 결과를 모르는 실패(SYS_001)는 그 메일을 다시 받아
// 붙잡은 발송 · 메일함으로 가른다(unsentReleaseState): 임시보관함 reverted · 다시 낸 발송이 기다림 released · 예약 그대로 retryable ·
// 예약만 풀림 unscheduled · 다시 받지 못함 unknown. 열린 메일의 안내 줄은 예약이 풀린 것을 확인했을 때(보냄 · 되돌림 · 예약만 풀림)만 내린다
import { ref } from 'vue'
import { toApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
import { mailboxListIdOf, sameListSource } from '@/shared/utils/mail-list-source'
import { messagePendingSend, pendingSendOf, samePendingSend } from '@/shared/utils/pending-send'
import type { MessageSummary, ScheduledMessageCancel, ReleasedSend } from '@/shared/types/mail'
import { mailboxListIdOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
import { messagePendingSend, pendingSendOf, samePendingSend, unsentReleaseState } from '@/shared/utils/pending-send'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
import type { OpenedMessage } from './message'
import type { MailApi } from './types'
/** 이미 보낸 메일 — 지금 보내기에서는 바라던 대로 나간 것이다 (15단계) */
const MAIL_ALREADY_SENT: ErrorCode = 'MAIL_014'
/** 지금 보내지 못했다 — 결과를 모르거나 임시보관함으로 되돌렸다 (15단계 — 메일이 어디 있는지 보고 화면이 알린다) */
const MAIL_UNAVAILABLE: ErrorCode = 'MAIL_001'
/** 결과를 모르는 실패 — 앞단의 502 · 504, 시간 초과, 연결 끊김, 서버 오류 (15단계 — MAIL_001 과 같은 갈래) */
const UNKNOWN_FAILURE: ErrorCode = 'SYS_001'
/** 지금 보내기에서 화면이 알리는 코드 — 인터셉터 알림을 끈다 */
const RELEASE_SCREEN_CODES: readonly ErrorCode[] = [MAIL_ALREADY_SENT, MAIL_UNAVAILABLE, UNKNOWN_FAILURE]
/** 지금 보내기의 결과 (15단계 — 그 밖의 실패는 예외로 올라간다) */
export type ReleaseOutcome =
/** 보냈다 — 보내기 취소를 기다리거나 바로 보냈다(이미 보낸 메일 · 결과를 모른 뒤 다시 받아 보니 다시 낸 발송이 기다리는 메일 포함) */
| { status: 'released'; released: ReleasedSend }
/** 보내지 못해 그 메일이 임시보관함으로 되돌아갔다 — 예약됨을 떠났다 (다시 받아 보니 임시보관함) */
| { status: 'reverted' }
/**
* 예약은 풀렸고 기다리는 발송이 없다(보낸편지함에 그대로) — 예약한 시각에 따로 나가지는 않지만, 이미 나갔는지는 모른다(보내기 취소 0초 · 응답만 잃음).
* 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 이어 낸다
*/
| { status: 'unscheduled' }
/** 보내지 못했다 — 예약이 그대로인 것을 확인했다(예약한 시각에 나간다). 같은 요청을 다시 보내면 된다 */
| { status: 'retryable' }
/** 그 메일을 다시 받지 못해 어떻게 됐는지 모른다 — 이미 나갔을 수 있다. 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 낸다 */
| { status: 'unknown' }
interface ScheduledDeps {
mailApi: MailApi
mailboxes: MailboxesState
@@ -36,6 +65,8 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
let countSeq = 0
/** 메일마다 진행 중인 취소 — 같은 메일의 두 번째 취소는 이것을 기다린다 */
const cancels = new Map<string, Promise<ScheduledMessageCancel>>()
/** 메일마다 진행 중인 지금 보내기 (15단계) — 같은 메일의 두 번째는 이것을 기다린다 */
const releases = new Map<string, Promise<ReleaseOutcome>>()
/** 예약한 메일 수를 다시 묻는다 — 실패는 알리지 않고 지난 값을 둔다 */
async function loadScheduledCount(): Promise<void> {
@@ -122,6 +153,129 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
return result
}
/** 같은 메일의 지금 보내기가 진행 중인가 (15단계) */
function isReleasingPendingSend(id: string): boolean {
return releases.has(id)
}
/** 지금 보내기가 끝난 뒤 — 예약됨에서 빼고 열린 메일의 붙잡은 발송을 응답으로 바꾼다. 다른 목록이면 서버 기준으로 맞추기만 한다 */
function settleRelease(id: string, source: MailListSource | null, result: ReleasedSend) {
// 예약됨에서는 빠진다(이제 보내기 취소를 기다리거나 보낸 메일이다). 보낸편지함 목록이면 줄은 남고 날짜 칸만 바뀐다 — 다시 맞추며 들어온다
if (sameListSource(list.listSource.value, source) && source?.kind === 'scheduled') list.removeFromList(new Set([id]))
const current = opened.selected.value
if (current?.id === id) opened.selected.value = { ...current, pendingSend: result.pendingSend }
resyncAfterRelease()
}
/** 예약됨 수 · 메일함 개수 · 보이는 목록을 서버 기준으로 다시 맞춘다 — 열린 메일은 건드리지 않는다 */
function resyncAfterRelease() {
void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
void loadScheduledCount()
}
/**
* 예약이 풀린 채 그 메일이 남았다(임시보관함으로 되돌림 · 예약만 풀림) — 열린 메일의 안내 줄을 내린다(예약됨 · 보낸편지함 · 검색 어디서 열었든).
* 그 목록을 보고 있으면 뺀다: 예약됨에서는 늘(이제 예약이 아니다), 메일함 목록에서는 보낸편지함을 떠났을 때(leftMailbox — 되돌림)만
*/
function settleUnscheduled(id: string, source: MailListSource | null, mailboxIds: string[] | null, leftMailbox: boolean) {
if (
sameListSource(list.listSource.value, source) &&
(source?.kind === 'scheduled' || (leftMailbox && mailboxListIdOf(source) !== null))
) {
list.removeFromList(new Set([id]))
}
const current = opened.selected.value
if (current?.id === id) opened.selected.value = { ...current, mailboxIds: mailboxIds ?? current.mailboxIds, pendingSend: null }
}
/**
* 503 MAIL_001 · 결과를 모르는 실패(SYS_001) 뒤 — 결과를 모르는 실패(메일은 보낸편지함에 그대로 · 같은 요청을 다시 보내면 이어 낸다)와
* 엔진이 거절해 임시보관함으로 되돌린 실패가 같은 꼴로 온다. 그 메일을 다시 받아 가른다(unsentReleaseState):
* - 임시보관함: reverted — 예약됨 · 보낸편지함 목록에서 빼고 열린 메일의 안내 줄을 내린다(예약 취소와 같다)
* - 다시 낸 발송이 보내기 취소를 기다린다: released — 지금 보내기가 끝난 것과 같다(같은 요청을 다시 보내도 서버가 이 상태로 200 을 준다)
* - 예약 그대로: retryable — 안내 줄은 그대로(다시 받은 시각으로만 맞춘다)
* - 예약만 풀림(붙잡은 발송이 없다 · 보낸편지함): unscheduled — 예약이 아닌 것은 확인했으니 안내 줄을 내리고 예약됨에서 뺀다(보낸편지함 목록에는 남는다).
* 이미 나갔는지는 모른다 — 화면은 보냈다고도 못 보냈다고도 하지 않는다
* - 다시 받지 못함: unknown — 안내 줄은 마지막으로 안 그대로 둔다(다음 다시 맞추기가 바로잡는다). 보냈다 · 못 보냈다 · 되돌렸다고 말하지 않는다(닫힌 쪽)
* 예약됨 수와 목록은 어느 쪽이든 다시 맞춘다
*/
async function settleUnsentRelease(id: string, source: MailListSource | null, session: number): Promise<ReleaseOutcome> {
let fresh: MessageSummary | null = null
try {
fresh = await mailApi.getMessage(id, { silent: true })
} catch {
// 다시 받지 못했다 — 다시 시도할 것으로
}
if (sessionGeneration() !== session) return { status: 'unknown' }
const state = unsentReleaseState(fresh, mailboxes.mailboxByRole('drafts')?.id ?? null)
const pending = messagePendingSend(fresh)
const mailboxIds = fresh !== null && Array.isArray(fresh.mailboxIds) ? fresh.mailboxIds : null
if (state === 'undo' && pending) {
const released: ReleasedSend = { id, rejectedRecipients: [], pendingSend: pending }
settleRelease(id, source, released)
return { status: 'released', released }
}
if (state === 'reverted' || state === 'unscheduled') settleUnscheduled(id, source, mailboxIds, state === 'reverted')
if (state === 'scheduled') {
// 예약은 살아 있다 — 다시 받은 시각으로만 맞춘다(안내 줄은 그대로)
const current = opened.selected.value
if (current?.id === id && !samePendingSend(pending, messagePendingSend(current))) {
opened.selected.value = { ...current, pendingSend: pending }
}
}
resyncAfterRelease()
if (state === 'reverted') return { status: 'reverted' }
if (state === 'unscheduled') return { status: 'unscheduled' }
if (state === 'scheduled') return { status: 'retryable' }
return { status: 'unknown' }
}
async function sendRelease(id: string, options?: MailRequestOptions): Promise<ReleaseOutcome> {
const session = sessionGeneration()
const source = list.listSource.value
let result: ReleasedSend
try {
// 이미 보낸 메일(MAIL_014)은 서버가 200 으로 답하기로 했지만, 혹시 409 로 오더라도 바라던 대로 나간 것이다 — 보낸 것으로 다룬다.
// 503 MAIL_001 · 결과를 모르는 실패(SYS_001)는 화면이 그 메일을 다시 받아 보고 알린다 (settleUnsentRelease)
result = await mailApi.releaseScheduledMessage(id, { silent: RELEASE_SCREEN_CODES, ...options })
} catch (error) {
if (sessionGeneration() !== session) throw error
const { code, canceled } = toApiError(error)
if (canceled) throw error
if (code === MAIL_UNAVAILABLE || code === UNKNOWN_FAILURE) return settleUnsentRelease(id, source, session)
if (code !== MAIL_ALREADY_SENT) {
// 받는 사람이 모두 받지 않아 임시보관함으로 되돌렸거나(400 MAIL_010 · 503 MAIL_011) 그사이 예약이 사라졌다(404) ·
// 같은 메일을 다른 곳에서 하는 중(429) — 알림은 인터셉터, 화면의 예약 표시 · 수를 다시 맞춘다
void refreshPendingSends()
mailboxes.syncCounts()
throw error
}
result = { id, rejectedRecipients: [], pendingSend: null }
}
// 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다
if (sessionGeneration() === session) settleRelease(id, source, result)
return { status: 'released', released: result }
}
/**
* 예약한 메일을 지금 보낸다 (POST /scheduled-messages/{id}/release — 15단계). 응답의 pendingSend 가 { kind: 'undo' } 면 보내기 취소를 기다리는 중,
* null 이면 보냈다(예약 시각이 막 지나 먼저 보낸 메일도) — released. 예약됨 · 메일함 개수 · 보이는 목록 · 열린 메일을 맞춘다.
* 503 MAIL_001 · 결과를 모르는 실패(SYS_001)면 그 메일을 다시 받아 가른다 — 임시보관함으로 되돌렸다(reverted) · 다시 낸 발송이 기다린다(released) ·
* 예약만 풀렸다(unscheduled) · 예약이 그대로다(retryable) · 다시 받지 못했다(unknown) (settleUnsentRelease) — 알림은 부르는 쪽(화면 글).
* 같은 메일의 지금 보내기가 진행 중이면 새로 보내지 않고 그 결과를 함께 기다린다.
* 그 밖의 실패(404 · 400 MAIL_010 · 503 MAIL_011 · 429)는 예외가 그대로 올라간다 — 알림은 인터셉터가 띄운다
*/
function releasePendingSend(id: string, options?: MailRequestOptions): Promise<ReleaseOutcome> {
const running = releases.get(id)
if (running) return running
const release = sendRelease(id, options).finally(() => {
if (releases.get(id) === release) releases.delete(id)
})
releases.set(id, release)
return release
}
/**
* 붙잡은 발송을 취소한다 (DELETE /scheduled-messages/{id}) — 그 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id → draftId).
* 예약됨 · 메일함 목록에서는 뺀다(보낸편지함을 떠났다 — 임시보관함이면 다시 맞추며 들어온다), 열린 메일이면 붙잡은 발송 표시를 내린다.
@@ -143,6 +297,7 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
scheduledCount.value = 0
nextScheduled.value = null
cancels.clear()
releases.clear()
}
return {
@@ -151,6 +306,8 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
loadScheduledCount,
cancelPendingSend,
isCancellingPendingSend,
releasePendingSend,
isReleasingPendingSend,
refreshPendingSends,
reset,
}
+2
View File
@@ -7,6 +7,8 @@
* 70rem(1120px) 이상 — 레일 · 메일함 · 목록 · 본문이 한 줄에 놓이고 칸마다 따로 스크롤된다.
* 그보다 좁으면 칸이 아래로 접히고 화면 전체가 스크롤된다.
* 목록 보기 표(MessageTable) — 68.6875rem(1099px) 이하에서 크기 칸 숨김, 37.4375rem(599px) 이하에서 칸 축소.
* 상단 바(AppTopBar) — 30rem(480px) 이하에서 계정 칸의 주소를 감추고 이름만 둔다 (15단계).
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 35.625rem(570px, 휴지통은 37.1875rem 595px)보다 좁으면 아이콘만 (15단계).
*/
:root {
/* ── 글꼴 ─────────────────────────────── */
+59
View File
@@ -0,0 +1,59 @@
// 단위 테스트 전용 — 끌어다 놓기 이벤트 (jsdom 에는 DragEvent · DataTransfer 가 없다).
// 브라우저처럼 dragstart 에 넣은 값은 놓을 때만 읽히고(getData), 끄는 동안(dragenter · dragover)에는 종류(types)만 보인다 —
// 테스트도 같은 DataTransfer 를 dragstart · dragover · drop 에 함께 넘긴다. 앱 코드에서 import 하지 않는다
export interface FakeDataTransfer {
types: string[]
files: File[]
effectAllowed: string
dropEffect: string
setData(type: string, value: string): void
getData(type: string): string
/** 파일 밖의 값을 모두 지운다 (브라우저처럼 — 끌기를 시작할 때만 쓸 수 있다) */
clearData(): void
setDragImage(image: Element, x: number, y: number): void
/** setDragImage 로 붙인 그림 (테스트가 본다) */
dragImage: Element | null
}
/** 메일 끌기에 쓰는 빈 DataTransfer — 파일 끌기면 files 를 넘긴다(types 에 'Files') */
export function fakeDataTransfer(files: File[] = []): FakeDataTransfer {
const data = new Map<string, string>()
const transfer: FakeDataTransfer = {
types: files.length > 0 ? ['Files'] : [],
files,
effectAllowed: 'uninitialized',
dropEffect: 'none',
dragImage: null,
setData(type, value) {
data.set(type, value)
if (!transfer.types.includes(type)) transfer.types.push(type)
},
getData(type) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
transfer.types = transfer.types.filter((type) => type === 'Files')
},
setDragImage(image) {
transfer.dragImage = image
},
}
return transfer
}
/** dragstart · dragenter · dragover · dragleave · drop · dragend — bubbles · cancelable, 마우스 자리(clientX · clientY)는 0 */
export function dragEvent(
type: string,
dataTransfer: FakeDataTransfer,
init: { relatedTarget?: EventTarget | null; ctrlKey?: boolean } = {},
): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
Object.defineProperty(event, 'relatedTarget', { value: init.relatedTarget ?? null })
Object.defineProperty(event, 'clientX', { value: 0 })
Object.defineProperty(event, 'clientY', { value: 0 })
Object.defineProperty(event, 'ctrlKey', { value: init.ctrlKey ?? false })
return event
}