chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #3

Merged
ttipo merged 4 commits from feat/mail-send-read-tools into main 2026-10-30 12:51:07 +09:00
96 changed files with 9749 additions and 329 deletions
Showing only changes of commit b99ccfbf6c - Show all commits
@@ -0,0 +1,74 @@
<script setup lang="ts">
// 알림 아래의 남은 시간 막대 (디자인 원본 MailSendLater "보낸 뒤 알림" — 높이 3 · 밝은 청록) — 마감 시각이 있는 알림(toast.store 의 expiresAt)에만
// 시간은 마감 시각까지 줄어들고, 마우스를 올려도 멈추지 않는다(알림도 그 시각에 닫힌다). 남은 시간을 글로 읽어 주지는 않는다 — 장식이다(aria-hidden).
// 막대는 인라인 스타일 대신 <progress> 를 꾸민다 (첨부 올리기 막대와 같은 결 — ComposeAttachmentList)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
interface Props {
/** 띄운 시각(ms — Date.now) — 막대의 처음 길이 */
startsAt: number
/** 닫히는 시각(ms — Date.now) */
endsAt: number
}
const props = defineProps<Props>()
/** 막대를 다시 그리는 간격 — 30초 막대도 매끄럽게 줄어 보이는 만큼 */
const TICK_MS = 100
const now = ref<number>(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
const total = computed(() => Math.max(1, props.endsAt - props.startsAt))
const remaining = computed(() => Math.min(total.value, Math.max(0, props.endsAt - now.value)))
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now()
}, TICK_MS)
})
onBeforeUnmount(() => {
if (timer !== null) clearInterval(timer)
})
</script>
<template>
<progress
class="toast-countdown"
aria-hidden="true"
:max="total"
:value="remaining"
/>
</template>
<style scoped>
/* 높이 3 · 안쪽 오른쪽 여백 8 (디자인 원본 MailSendLater) — 알림의 둘째 줄을 다 채운다 */
.toast-countdown {
appearance: none;
display: block;
flex: 0 0 100%;
width: calc(100% - var(--space-8));
height: var(--space-3);
margin: var(--space-2) 0 var(--space-2);
border: 0;
border-radius: var(--space-2);
background: var(--toast-countdown-track);
overflow: hidden;
}
.toast-countdown::-webkit-progress-bar {
background: var(--toast-countdown-track);
border-radius: var(--space-2);
}
.toast-countdown::-webkit-progress-value {
background: var(--toast-countdown-bar);
border-radius: var(--space-2);
}
.toast-countdown::-moz-progress-bar {
background: var(--toast-countdown-bar);
border-radius: var(--space-2);
}
</style>
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ToastHost from './ToastHost.vue'
// 화면 오른쪽 아래 알림 — 단추는 한 번만 움직이고(리뷰 #9), 초점이 든 알림이 닫히면 초점을 돌려준다(리뷰 #8)
describe('ToastHost', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
useToastStore().clear()
document.body.innerHTML = ''
})
/** 화면 — main 안의 입력칸 하나 + 알림 틀 */
async function mountHost() {
const main = document.createElement('main')
const field = document.createElement('input')
main.append(field)
document.body.append(main)
wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
await flushPromises()
return { main, field }
}
function showUndo(onRun: () => void, expiresAt?: number): number {
return useToastStore().show({
kind: 'success',
message: '메일을 보냈습니다.',
action: { label: '보내기 취소', run: onRun },
expiresAt: expiresAt ?? null,
})
}
const actionButton = () => document.querySelector<HTMLButtonElement>('.toast__action')
it('단추를 누르면 알림을 닫고 할 일을 한 번 한다 — 닫히는 동안 한 번 더 눌러도 또 하지 않는다', async () => {
await mountHost()
let runs = 0
showUndo(() => {
runs += 1
})
await flushPromises()
const button = actionButton()
if (!button) throw new Error('단추가 없다')
button.click()
// 퇴장 전환 동안 남아 있는 같은 단추를 한 번 더 누른다
button.click()
await flushPromises()
expect(runs).toBe(1)
expect(useToastStore().toasts).toEqual([])
})
it('초점이 든 알림이 닫히면(마감 시각 · 시간이 다 됨) 알림에 들어오기 전 자리로 초점을 돌려준다', async () => {
const { field } = await mountHost()
const id = showUndo(() => undefined, Date.now() + 10_000)
await flushPromises()
field.focus()
actionButton()?.focus()
expect(document.activeElement).toBe(actionButton())
// 마감이 있는 알림은 초점이 있어도 닫힌다 (그 시각이 지나면 되돌릴 수 없다)
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(field)
})
it('그 자리가 사라졌으면 화면의 main 으로 — 초점을 받게 잠깐 단 tabindex 는 초점이 떠나면 뗀다', async () => {
const { main, field } = await mountHost()
const id = showUndo(() => undefined)
await flushPromises()
field.focus()
actionButton()?.focus()
field.remove()
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(main)
expect(main.getAttribute('tabindex')).toBe('-1')
expect(main.hasAttribute('data-focus-fallback')).toBe(true)
main.blur()
expect(main.hasAttribute('tabindex')).toBe(false)
expect(main.hasAttribute('data-focus-fallback')).toBe(false)
})
it('단추를 눌러 닫혀도 초점을 돌려준다 — 할 일이 초점을 옮기면 그쪽이 이긴다', async () => {
const { field } = await mountHost()
const other = document.createElement('button')
document.body.append(other)
showUndo(() => other.focus())
await flushPromises()
field.focus()
actionButton()?.focus()
actionButton()?.click()
await flushPromises()
expect(document.activeElement).toBe(other)
})
it('초점이 알림 밖에 있으면 닫혀도 초점을 건드리지 않는다', async () => {
const { field } = await mountHost()
const id = showUndo(() => undefined)
await flushPromises()
field.focus()
useToastStore().dismiss(id)
await flushPromises()
expect(document.activeElement).toBe(field)
})
it('알림 사이를 옮겨 다녀도 처음 들어온 자리를 기억한다', async () => {
const { field } = await mountHost()
const first = showUndo(() => undefined)
const second = showUndo(() => undefined)
await flushPromises()
const buttons = () => [...document.querySelectorAll<HTMLButtonElement>('.toast__action')]
field.focus()
buttons()[0]?.focus()
buttons()[1]?.focus()
useToastStore().dismiss(second)
await flushPromises()
expect(document.activeElement).toBe(field)
useToastStore().dismiss(first)
})
})
+102 -4
View File
@@ -4,7 +4,14 @@
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
// 마감 시각이 있는 알림(9단계 [보내기 취소])은 둘째 줄에 남은 시간 막대(ToastCountdown) — 그 시각에 닫히고 붙잡아도 멈추지 않는다
// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 —
// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰)
// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction)
import { ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ToastCountdown from '@/components/feedback/ToastCountdown.vue'
import type { IconName } from '@/shared/constants/icons'
import { formatTraceCode } from '@/shared/utils/format'
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
@@ -18,6 +25,13 @@ const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; st
progress: { name: 'loader', size: 16, strokeWidth: 2 },
}
/** 초점을 잃었을 때 대신 받는 화면의 main — tabindex 가 없으면 잠깐 단다(초점이 떠나면 뗀다) */
const MAIN_FALLBACK_ATTR = 'data-focus-fallback'
const hostRef = ref<HTMLElement | null>(null)
/** 알림으로 초점이 들어오기 전 초점이 있던 요소 — 알림 사이를 옮겨 다닌 것은 세지 않는다 */
let returnFocusTo: HTMLElement | null = null
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
function onFocusOut(event: FocusEvent, id: number) {
const next = event.relatedTarget
@@ -25,18 +39,70 @@ function onFocusOut(event: FocusEvent, id: number) {
toastStore.release(id, 'focus')
}
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */
/** 바깥에서 알림으로 초점이 들어왔다 — 온 자리를 기억한다 (Tab · F6 · 마우스로 누름 — 온 자리가 없으면 null) */
function onHostFocusIn(event: FocusEvent) {
const from = event.relatedTarget
if (from instanceof Node && hostRef.value?.contains(from)) return
returnFocusTo = from instanceof HTMLElement ? from : null
}
/** 화면의 main 에 초점을 둔다 — 초점을 받을 수 없으면 tabindex="-1" 을 잠깐 단다 */
function focusMainRegion() {
const main = document.querySelector<HTMLElement>('main')
if (!main) return
if (!main.hasAttribute('tabindex')) {
main.setAttribute('tabindex', '-1')
main.setAttribute(MAIN_FALLBACK_ATTR, '')
main.addEventListener(
'blur',
() => {
main.removeAttribute('tabindex')
main.removeAttribute(MAIN_FALLBACK_ATTR)
},
{ once: true },
)
}
main.focus({ preventScroll: true })
}
/** 알림에 들어오기 전 자리로 초점을 돌려준다 — 그 요소가 사라졌거나 초점을 받지 못하면 main */
function restoreFocus() {
const target = returnFocusTo
returnFocusTo = null
if (target?.isConnected && !hostRef.value?.contains(target)) {
target.focus({ preventScroll: true })
if (document.activeElement === target) return
}
focusMainRegion()
}
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 (이미 닫힌 알림이면 아무것도 하지 않는다) */
function runAction(toast: Toast) {
const action = toast.action
toastStore.dismiss(toast.id)
const action = toastStore.takeAction(toast.id)
if (action && 'run' in action) action.run()
}
// 알림이 닫힐 때 — 초점이 그 안에 있었으면 들어오기 전 자리로 (닫는 바로 그때 — 퇴장 전환 동안 남은 단추에 초점이 머물지 않게)
watch(
() => toastStore.toasts.map((toast) => toast.id),
(ids) => {
const host = hostRef.value
const active = document.activeElement
if (!host || !(active instanceof Element) || !host.contains(active)) return
const focusedId = Number(active.closest('[data-toast-id]')?.getAttribute('data-toast-id'))
if (ids.includes(focusedId)) return
restoreFocus()
},
{ flush: 'sync' },
)
</script>
<template>
<section
ref="hostRef"
class="toast-host"
aria-label="알림"
@focusin="onHostFocusIn"
>
<TransitionGroup
name="toast"
@@ -46,7 +112,8 @@ function runAction(toast: Toast) {
<div
v-for="toast in toastStore.toasts"
:key="toast.id"
:class="['toast', `toast--${toast.kind}`]"
:class="['toast', `toast--${toast.kind}`, { 'toast--timed': toast.expiresAt !== null }]"
:data-toast-id="toast.id"
:role="toast.kind === 'error' ? 'alert' : 'status'"
@mouseenter="toastStore.hold(toast.id, 'pointer')"
@mouseleave="toastStore.release(toast.id, 'pointer')"
@@ -95,6 +162,11 @@ function runAction(toast: Toast) {
:stroke-width="2"
/>
</button>
<ToastCountdown
v-if="toast.expiresAt !== null"
:starts-at="toast.shownAt"
:ends-at="toast.expiresAt"
/>
</div>
</TransitionGroup>
</section>
@@ -123,6 +195,25 @@ function runAction(toast: Toast) {
pointer-events: auto;
}
/* 마감 시각이 있는 알림 — 남은 시간 막대가 둘째 줄을 다 쓴다 (디자인 원본 MailSendLater: 위 8 · 아래 10) */
.toast--timed {
flex-wrap: wrap;
row-gap: var(--space-4);
--toast-countdown-track: var(--color-skeleton);
--toast-countdown-bar: var(--color-brand);
}
.toast--timed.toast--success {
padding-bottom: var(--space-10);
--toast-countdown-track: var(--color-on-brand-track);
--toast-countdown-bar: var(--color-on-brand-progress);
}
/* 줄을 나눌 때 글의 길이로 첫 줄을 넘기지 않게 — 아이콘 · 글 · 단추는 늘 첫 줄, 막대만 둘째 줄 */
.toast--timed .toast__body {
flex-basis: 0;
}
.toast--error {
padding: var(--space-12) var(--space-6) var(--space-12) var(--space-14);
border: var(--border-width) solid var(--color-border);
@@ -251,6 +342,13 @@ function runAction(toast: Toast) {
background: var(--color-surface-muted);
}
/* 인쇄하지 않는다 — 고정 위치라 남아 있으면 쪽마다 찍힌다 (9단계 인쇄 화면) */
@media print {
.toast-host {
display: none;
}
}
.toast-enter-active,
.toast-leave-active {
transition:
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { flushPromises } from '@/test-utils/mock-api'
import ComposeFooter from './ComposeFooter.vue'
// 쓰기 화면의 [보내기 ▾] — 메뉴는 보이는 영역 안에 다 들게 연다 (2026-10-29 리뷰 #10: 화면 맨 아래 단추 줄 밑으로 열려 화면 밖에 생기고,
// 초점이 들어갈 때 쓰기 화면이 47px 튀어 올랐다). jsdom 은 배치를 하지 않으므로 상자 크기를 정해 준다
const PROPS = {
canSend: true,
sending: false,
canSaveDraft: true,
draftStatus: 'idle' as const,
draftSavedAt: null,
sender: '김아라 <ara.kim@arasystem.test>',
}
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
describe('ComposeFooter — [보내기 ▾] 메뉴 자리', () => {
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
document.body.innerHTML = ''
})
/** ▾ 단추의 위쪽 — 메뉴(54)는 단추 4 아래에 그려진다 */
function layout(buttonTop: number) {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.matches('.compose__send-more')) return rectOf(buttonTop, buttonTop + 44)
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 48, buttonTop + 48 + 54)
return rectOf(0, 0)
})
}
async function openMenu() {
wrapper = mount(ComposeFooter, { props: PROPS, attachTo: document.body })
await wrapper.get('.compose__send-more').trigger('click')
await flushPromises()
return wrapper.get('[role="menu"]')
}
it('단추 줄이 화면 맨 아래에 붙어 아래가 모자라면 위로 연다 — 초점은 화면 안의 메뉴로', async () => {
layout(window.innerHeight - 50)
const menu = await openMenu()
expect(menu.classes()).toContain('compose__send-menu--up')
// 키보드로 연 것과 같다(누름의 detail 0) — 초점은 메뉴 안의 첫 항목
expect(menu.element.contains(document.activeElement)).toBe(true)
expect(document.activeElement?.textContent).toContain('예약 발송…')
})
it('아래가 넉넉하면 지금처럼 아래로 연다', async () => {
layout(100)
const menu = await openMenu()
expect(menu.classes()).not.toContain('compose__send-menu--up')
})
})
+174 -19
View File
@@ -1,11 +1,22 @@
<script setup lang="ts">
// 쓰기 화면의 아래 단추 줄 — [보내기] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist)
// 쓰기 화면의 아래 단추 줄 — [보내기 ▾] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist · MailSendLater)
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · 임시 저장 #compose-save-draft ·
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// Ctrl+Enter 는 그대로 [보내기]다. 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 첨부를 올리는 중 등 — canSend)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
interface Props {
canSend: boolean
@@ -21,27 +32,122 @@ interface Props {
interface Emits {
/** 「임시 저장」 · 상태 글의 [다시 시도] */
(e: 'save-draft'): void
/** ▾ › 「예약 발송…」 — 쓰기 화면이 예약 창을 연다 */
(e: 'schedule'): void
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const moreRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const menuOpen = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 위로 연다 — 단추 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
const { upward } = usePopupPlacement({
open: menuOpen,
trigger: () => moreRef.value,
popup: () => {
const menu: unknown = menuRef.value?.$el
return menu instanceof HTMLElement ? menu : null
},
})
const items: readonly EditorMenuItem[] = [{ id: 'schedule', label: SEND_LATER_TEXT.scheduleItem, icon: 'clock' }]
const trigger = computed<HTMLElement | null>(() => moreRef.value)
function openMenu(keyboard: boolean) {
if (!props.canSend) return
byKeyboard.value = keyboard
menuOpen.value = true
}
function onMoreClick(event: MouseEvent) {
if (menuOpen.value) {
menuOpen.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onMoreKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || menuOpen.value) return
event.preventDefault()
openMenu(true)
}
function onMenuClose(restoreFocus: boolean) {
menuOpen.value = false
if (restoreFocus) moreRef.value?.focus()
}
function onSelect(id: string) {
menuOpen.value = false
moreRef.value?.focus()
if (id === 'schedule' && props.canSend) emit('schedule')
}
// 보내는 중 등으로 꺼지면 메뉴도 닫는다
watch(
() => props.canSend,
(can) => {
if (!can) menuOpen.value = false
},
)
</script>
<template>
<div class="compose__footer">
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-busy="sending"
>
<AppIcon
name="send"
:size="15"
:stroke-width="2"
<div class="compose__send-group">
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-busy="sending"
>
<AppIcon
name="send"
:size="15"
:stroke-width="2"
/>
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
</button>
<span
class="compose__send-divider"
aria-hidden="true"
/>
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
</button>
<button
ref="moreRef"
type="button"
class="compose__send-more"
:aria-label="SEND_LATER_TEXT.menuButton"
:title="SEND_LATER_TEXT.menuButton"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:disabled="!canSend"
@click="onMoreClick"
@keydown="onMoreKeydown"
>
<AppIcon
name="chevron-down"
:size="16"
:stroke-width="2"
/>
</button>
<EditorMenu
v-if="menuOpen"
ref="menuRef"
:class="['compose__send-menu', { 'compose__send-menu--up': upward }]"
:label="SEND_LATER_TEXT.menuLabel"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
@select="onSelect"
@close="onMenuClose"
/>
</div>
<button
id="compose-save-draft"
type="button"
@@ -77,29 +183,78 @@ const emit = defineEmits<Emits>()
border-top: var(--border-width) solid var(--color-border);
}
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
.compose__send-group {
position: relative;
display: inline-flex;
align-items: stretch;
}
.compose__send {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-20);
padding: 0 var(--space-16) 0 var(--space-18);
border: 0;
border-radius: var(--radius-md);
border-radius: var(--radius-md) 0 0 var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.compose__send:hover:not(:disabled) {
/* 두 단추 사이 선 — 단추와 같은 바탕 위에 옅은 흰 선 (흰 쓰기 화면 위에서도 보이게). 단추가 꺼지면 함께 흐리게 */
.compose__send-divider {
width: var(--border-width);
background: var(--color-brand);
box-shadow: inset 0 0 0 var(--border-width) var(--color-on-brand-divider);
}
.compose__send:disabled + .compose__send-divider {
opacity: 0.6;
}
/* ▾ — 44×44 (보드의 40 은 새 밀도 예외가 되므로 44 — README "9단계") */
.compose__send-more {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
min-height: var(--size-touch);
padding: 0;
border: 0;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: var(--color-brand);
color: var(--color-on-brand);
}
.compose__send-more[aria-expanded='true'] {
background: var(--color-brand-strong);
}
.compose__send:disabled {
.compose__send:hover:not(:disabled),
.compose__send-more:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.compose__send:disabled,
.compose__send-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 메뉴 폭 260 (디자인 원본 MailSendLater) — 단추 덩어리 왼쪽에 맞춘다 */
.compose__send-menu {
min-width: 16.25rem;
}
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
.compose__send-group .compose__send-menu--up {
top: auto;
bottom: calc(100% + var(--space-4));
}
/* 「임시 저장」 — 보조 단추 (디자인 원본 ComposeAssist) */
.compose__save {
display: inline-flex;
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { CLOCK } from '@/composables/useClock'
import { formatScheduleTime } from '@/shared/utils/format'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import ComposeScheduleDialog from './ComposeScheduleDialog.vue'
// 예약 발송 창 (디자인 원본 MailSendLater · README "9단계 › 예약 창") — 시계는 주입한다(useClock), 시각은 브라우저 시간대(지역 시각)
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime()
describe('ComposeScheduleDialog', () => {
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountAt(now: number): { wrapper: VueWrapper; clock: ManualClock } {
const clock = createManualClock(now)
const wrapper = mount(ComposeScheduleDialog, {
props: { open: true },
attachTo: document.body,
global: { provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
return { wrapper, clock }
}
function rows(wrapper: VueWrapper) {
return wrapper.findAll('[data-schedule-preset]').map((input) => {
const row = input.element.closest('.schedule__choice') as HTMLElement
return {
id: input.attributes('data-schedule-preset'),
label: row.querySelector('.schedule__label')?.textContent,
time: row.querySelector('.schedule__time')?.textContent ?? null,
checked: (input.element as HTMLInputElement).checked,
}
})
}
it('목요일 오후 3시 — 오늘 오후 · 내일 오전 · 내일 오후 · 다음 주 월요일 · 직접 고르기, 첫 줄을 고른 채 연다', () => {
const { wrapper } = mountAt(local(10, 29, 15))
expect(wrapper.get('#compose-schedule-title').text()).toBe('예약 발송')
expect(rows(wrapper)).toEqual([
{ id: 'todayEvening', label: '오늘 오후', time: formatScheduleTime(local(10, 29, 18)), checked: true },
{ id: 'tomorrowMorning', label: '내일 오전', time: formatScheduleTime(local(10, 30, 9)), checked: false },
{ id: 'tomorrowAfternoon', label: '내일 오후', time: formatScheduleTime(local(10, 30, 13)), checked: false },
{ id: 'nextMonday', label: '다음 주 월요일', time: formatScheduleTime(local(11, 2, 9)), checked: false },
{ id: 'custom', label: '직접 고르기', time: null, checked: false },
])
// 직접 고르기의 처음 값은 내일 오전 9:00 · 날짜는 오늘부터 7일 뒤까지
const day = wrapper.get('[data-schedule-day]')
expect((day.element as HTMLInputElement).value).toBe('2026-10-30')
expect(day.attributes()).toMatchObject({ min: '2026-10-29', max: '2026-11-05' })
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(String(9 * 60))
expect(wrapper.findAll('[data-schedule-time] option')).toHaveLength(48)
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC 의 시간(\(.+\))?으로 보냅니다 · 7일 안에서 고를 수 있습니다\.$/)
expect(document.activeElement).toBe(wrapper.get('[data-schedule-confirm]').element)
})
it('[예약] — 고른 시각(ms)을 넘긴다', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-preset="tomorrowAfternoon"]').setValue(true)
await wrapper.get('[data-schedule-confirm]').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([[local(10, 30, 13)]])
})
it('직접 고르기 — 날짜 · 시각을 만지면 그 줄을 고르고, 그 시각을 넘긴다', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-day]').setValue('2026-10-31')
await wrapper.get('[data-schedule-time]').setValue(String(14 * 60 + 30))
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
await wrapper.get('[data-schedule-confirm]').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([[local(10, 31, 14, 30)]])
})
it('지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-day]').setValue('2026-10-29')
await wrapper.get('[data-schedule-time]').setValue(String(15 * 60))
await wrapper.get('[data-schedule-confirm]').trigger('click')
const error = wrapper.get('#compose-schedule-error')
expect(error.text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect(error.attributes('role')).toBe('alert')
expect(wrapper.get('[data-schedule-day]').attributes()).toMatchObject({
'aria-invalid': 'true',
'aria-describedby': 'compose-schedule-error',
})
expect(wrapper.emitted('confirm')).toBeUndefined()
// 다른 줄을 고르면 내린다
await wrapper.get('[data-schedule-preset="tomorrowMorning"]').setValue(true)
expect(wrapper.get('#compose-schedule-error').text()).toBe('')
})
it('7일을 넘으면 "7일 안의 시각만 고를 수 있습니다."', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-day]').setValue('2026-11-05')
await wrapper.get('[data-schedule-time]').setValue(String(15 * 60 + 30))
await wrapper.get('[data-schedule-confirm]').trigger('click')
expect(wrapper.get('#compose-schedule-error').text()).toBe('7일 안의 시각만 고를 수 있습니다.')
expect(wrapper.emitted('confirm')).toBeUndefined()
})
it('창을 연 뒤 시간이 흘러 고른 시각이 5분 안이 되면 [예약]을 누른 때로 다시 본다', async () => {
const { wrapper, clock } = mountAt(local(10, 29, 17, 50))
expect(rows(wrapper)[0]).toMatchObject({ id: 'todayEvening', checked: true })
clock.advance(6 * 60_000)
await wrapper.get('[data-schedule-confirm]').trigger('click')
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect(wrapper.emitted('confirm')).toBeUndefined()
})
it('다시 열면 그때의 시각으로 고르기를 새로 만든다 — 오늘 오후가 지났으면 내일 오전이 첫 줄', async () => {
const { wrapper, clock } = mountAt(local(10, 29, 15))
await wrapper.setProps({ open: false })
clock.advance(4 * 60 * 60_000)
await wrapper.setProps({ open: true })
expect(rows(wrapper).map((row) => row.id)).toEqual(['tomorrowMorning', 'tomorrowAfternoon', 'nextMonday', 'custom'])
expect(rows(wrapper)[0]?.checked).toBe(true)
})
it('[취소] · Esc 는 cancel', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-cancel]').trigger('click')
wrapper.get('dialog').element.dispatchEvent(new Event('cancel', { cancelable: true }))
expect(wrapper.emitted('cancel')).toHaveLength(2)
})
})
@@ -0,0 +1,336 @@
<script setup lang="ts">
// 예약 발송 창 (디자인 원본 MailSendLater · README "9단계 › 예약 창") — 모달 400(medium), 쓰기 화면의 ▾ › 「예약 발송…」이 연다
// 고르기: 「오늘 오후」 6:00(남았을 때만) · 「내일 오전」 9:00 · 「내일 오후」 1:00 · 「다음 주 월요일」 9:00 · 「직접 고르기」(날짜 칸 + 30분 단위 시각) —
// 줄마다 오른쪽에 날짜 · 요일 · 시각(브라우저 시간대). 예약할 수 없는 시각(5분 안 · 7일 넘음)인 고르기는 두지 않는다 (schedule-send.ts).
// 처음 고른 것은 첫 줄. 안내 "이 PC 의 시간(…)으로 보냅니다 · 7일 안에서 고를 수 있습니다." [예약] [취소]
// [예약]을 누르면 그 순간의 시각으로 다시 본다 — 지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요.", 7일을 넘으면
// "7일 안의 시각만 고를 수 있습니다." (고르기 아래 — 직접 고르기의 칸이 그 글을 가리킨다). 맞으면 보낼 시각(ms)을 confirm 으로 넘긴다.
// 시각은 주입한 시계(useClock)로 센다 — 테스트가 손으로 돌리는 시계로 프리셋 · 검사를 맞춘다. 열 때마다 처음 값으로 시작한다.
// 서버가 고른 시각을 받지 않아(400 VAL_001) 쓰기 화면이 창을 다시 열면(rejection) 그 시각을 「직접 고르기」 칸에 채우고, 창이 열린 뒤 고르기 아래에 까닭을 보인다
// (role="alert" 가 바뀜으로 읽히게 — 처음부터 있던 글은 읽지 않는 스크린리더가 있다)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useClock } from '@/composables/useClock'
import { SEND_LATER_TEXT, type SchedulePresetId } from '@/shared/constants/mail-send-later'
import { formatScheduleTime } from '@/shared/utils/format'
import {
browserTimeZoneName,
customScheduleDayRange,
customScheduleOf,
customScheduleTime,
defaultCustomSchedule,
formatTimeSlot,
schedulePresets,
scheduleProblem,
scheduleTimeSlots,
type ScheduleProblem,
type SchedulePreset,
type ScheduleRejection,
} from '@/shared/utils/schedule-send'
interface Props {
open: boolean
/** 서버가 받지 않은 시각으로 다시 열었다 — 그 시각 · 까닭을 보인다 */
rejection?: ScheduleRejection | null
}
interface Emits {
/** 보낼 시각(ms) — 검사를 통과했다 */
(e: 'confirm', at: number): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
rejection: null,
})
const emit = defineEmits<Emits>()
const clock = useClock()
const ERROR_ID = 'compose-schedule-error'
const NOTE_ID = 'compose-schedule-note'
const CUSTOM = 'custom'
type ScheduleChoice = SchedulePresetId | typeof CUSTOM
const openedAt = ref<number>(clock.now())
const choice = ref<ScheduleChoice>(CUSTOM)
const customDay = ref<string>('')
const customMinutes = ref<number>(0)
const problem = ref<ScheduleProblem | null>(null)
const presets = computed<SchedulePreset[]>(() => schedulePresets(openedAt.value))
const dayRange = computed(() => customScheduleDayRange(openedAt.value))
const slots = scheduleTimeSlots()
const zoneName = computed(() => browserTimeZoneName(openedAt.value))
const note = computed(() => (zoneName.value ? SEND_LATER_TEXT.note(zoneName.value) : SEND_LATER_TEXT.noteWithoutZone))
const problemText = computed(() => (problem.value ? SEND_LATER_TEXT[problem.value] : ''))
/** 열 때마다 — 지금 시각으로 고르기를 다시 만들고 첫 줄을 고른다 */
function reset() {
openedAt.value = clock.now()
const first = presets.value[0]
choice.value = first ? first.id : CUSTOM
const custom = defaultCustomSchedule(openedAt.value)
customDay.value = custom.day
customMinutes.value = custom.minutes
problem.value = null
const rejected = props.rejection
if (!rejected) return
// 서버가 받지 않은 시각 — 「직접 고르기」에 그 시각을 채우고(칸이 까닭을 가리킨다), 창이 열린 뒤 까닭을 보인다
const chosen = customScheduleOf(rejected.at)
if (chosen) {
choice.value = CUSTOM
customDay.value = chosen.day
customMinutes.value = chosen.minutes
}
void nextTick(() => {
if (props.open && props.rejection === rejected) problem.value = rejected.problem
})
}
/** 고른 시각 — 직접 고르기의 날짜가 비었거나 틀리면 NaN (검사가 "5분 뒤 이후"로 막는다) */
function chosenTime(): number {
if (choice.value === CUSTOM) {
return customScheduleTime({ day: customDay.value, minutes: customMinutes.value }) ?? Number.NaN
}
return presets.value.find((preset) => preset.id === choice.value)?.at ?? Number.NaN
}
/** 직접 고르기의 칸을 만지면 그 줄을 고른다 */
function chooseCustom() {
choice.value = CUSTOM
problem.value = null
}
function onConfirm() {
const at = chosenTime()
problem.value = scheduleProblem(at, clock.now())
if (problem.value === null) emit('confirm', at)
}
watch(
() => props.open,
(open) => {
if (open) reset()
},
{ immediate: true },
)
// 고르기를 바꾸면 오류를 내린다
watch(choice, () => {
problem.value = null
})
</script>
<template>
<ModalDialog
:open="open"
:title="SEND_LATER_TEXT.dialogTitle"
title-id="compose-schedule-title"
:described-by="NOTE_ID"
medium
@cancel="emit('cancel')"
>
<div
class="schedule"
data-schedule-dialog
>
<div
role="radiogroup"
class="schedule__choices"
:aria-label="SEND_LATER_TEXT.presetGroup"
>
<label
v-for="preset in presets"
:key="preset.id"
:class="['schedule__choice', { 'is-checked': choice === preset.id }]"
>
<input
v-model="choice"
type="radio"
name="compose-schedule"
:value="preset.id"
class="schedule__radio"
:data-schedule-preset="preset.id"
>
<span class="schedule__label">{{ SEND_LATER_TEXT.presets[preset.id] }}</span>
<span class="schedule__time">{{ formatScheduleTime(preset.at) }}</span>
</label>
<div :class="['schedule__choice', 'schedule__choice--custom', { 'is-checked': choice === CUSTOM }]">
<label class="schedule__custom-label">
<input
v-model="choice"
type="radio"
name="compose-schedule"
:value="CUSTOM"
class="schedule__radio"
data-schedule-preset="custom"
>
<span class="schedule__label">{{ SEND_LATER_TEXT.custom }}</span>
</label>
<div class="schedule__custom-fields">
<input
v-model="customDay"
type="date"
class="form-input schedule__date"
:min="dayRange.min"
:max="dayRange.max"
:aria-label="SEND_LATER_TEXT.customDate"
:aria-invalid="choice === CUSTOM && problem !== null ? 'true' : undefined"
:aria-describedby="choice === CUSTOM && problem ? ERROR_ID : undefined"
data-schedule-day
@focus="chooseCustom"
@input="chooseCustom"
>
<select
v-model.number="customMinutes"
class="form-input schedule__select"
:aria-label="SEND_LATER_TEXT.customTime"
:aria-invalid="choice === CUSTOM && problem !== null ? 'true' : undefined"
:aria-describedby="choice === CUSTOM && problem ? ERROR_ID : undefined"
data-schedule-time
@focus="chooseCustom"
@change="chooseCustom"
>
<option
v-for="slot in slots"
:key="slot"
:value="slot"
>
{{ formatTimeSlot(slot) }}
</option>
</select>
</div>
</div>
</div>
<p
:id="ERROR_ID"
class="form-message form-message--error schedule__error"
role="alert"
>
<template v-if="problem">
<AppIcon
name="alert-circle"
:size="14"
/>
<span>{{ problemText }}</span>
</template>
</p>
<p
:id="NOTE_ID"
class="form-message"
>
{{ note }}
</p>
</div>
<template #actions>
<button
type="button"
class="modal-dialog__action"
autofocus
data-schedule-confirm
@click="onConfirm"
>
{{ SEND_LATER_TEXT.confirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-schedule-cancel
@click="emit('cancel')"
>
{{ SEND_LATER_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.schedule {
display: flex;
flex-direction: column;
gap: var(--space-10);
margin-top: var(--space-6);
}
.schedule__choices {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 고르기 줄 — 44 · 테두리 상자, 고른 줄은 청록 테두리 + 옅은 바탕 (디자인 원본 MailSendLater) */
.schedule__choice {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
cursor: pointer;
}
.schedule__choice.is-checked {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.schedule__choice--custom {
flex-direction: column;
align-items: stretch;
gap: var(--space-8);
padding: var(--space-10);
cursor: default;
}
.schedule__custom-label {
display: flex;
align-items: center;
gap: var(--space-10);
cursor: pointer;
}
.schedule__radio {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
.schedule__label {
flex: 1 1 auto;
font-weight: 600;
}
.schedule__time {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* 날짜 · 시각 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
.schedule__custom-fields {
display: flex;
flex-wrap: wrap;
gap: var(--space-8);
padding-left: var(--space-28);
}
.schedule__date {
flex: 1 1 9rem;
min-width: 0;
}
.schedule__select {
flex: 0 0 8.125rem;
}
/* 오류가 없으면 자리를 차지하지 않는다 (글이 생기면 role="alert" 로 읽힌다) */
.schedule__error:empty {
display: none;
}
</style>
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { MAIL_FRAME_CSP } from '@/shared/utils/mail-html'
import { MAIL_FRAME_CSP, mailFrameCspImagesShown } from '@/shared/utils/mail-html'
import MailBodyFrame from './MailBodyFrame.vue'
// 보안 핵심 — 본문 iframe 의 격리 조건이 바뀌면 이 테스트가 깨져야 한다.
@@ -52,4 +52,44 @@ describe('MailBodyFrame', () => {
expect(srcdoc).toContain('&lt;b&gt;굵게&lt;/b&gt; 아님')
expect(srcdoc).not.toContain('<b>')
})
describe('외부 이미지를 보일 때 (9단계 — imagesShown)', () => {
const PROXIED = '/api/mail-images/tok3n_value'
it('내부 CSP 의 그림만 이 화면 출처의 중계 경로(/api/mail-images/)를 더 열고 중계 주소의 그림을 남긴다 — sandbox 는 그대로', () => {
const wrapper = mount(MailBodyFrame, { props: { htmlBody: `<p>본문</p><img src="${PROXIED}">`, imagesShown: true } })
const frame = wrapper.get('iframe').element as HTMLIFrameElement
const srcdoc = frame.getAttribute('srcdoc') ?? ''
const tokens = (frame.getAttribute('sandbox') ?? '').split(/\s+/).filter(Boolean).sort()
expect(srcdoc).toContain(`content="${mailFrameCspImagesShown(window.location.origin)}"`)
expect(srcdoc).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
expect(srcdoc).not.toContain("'self'")
expect(srcdoc).toContain(`src="${PROXIED}"`)
expect(tokens).toEqual(['allow-popups', 'allow-popups-to-escape-sandbox', 'allow-same-origin'])
})
it('막아 둔 본문이면(기본) 지금 CSP 그대로 — 중계 주소의 그림도 지운다', () => {
const wrapper = mount(MailBodyFrame, { props: { htmlBody: `<p>본문</p><img src="${PROXIED}">` } })
const srcdoc = wrapper.get('iframe').attributes('srcdoc') ?? ''
expect(srcdoc).toContain(`content="${MAIL_FRAME_CSP}"`)
expect(srcdoc).not.toContain(PROXIED)
})
})
it('문서를 다 열면 높이를 재서 measured 로 알리고, 본문 안의 키는 frame-keydown 으로 알린다 (읽기 화면의 Ctrl+P · 인쇄 화면)', async () => {
const wrapper = mount(MailBodyFrame, { props: { htmlBody: '<p>본문</p>' }, attachTo: document.body })
const frame = wrapper.get('iframe').element as HTMLIFrameElement
frame.dispatchEvent(new Event('load'))
await wrapper.vm.$nextTick()
expect(wrapper.emitted('measured')?.length).toBeGreaterThan(0)
expect(frame.getAttribute('height')).not.toBeNull()
frame.contentDocument?.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true }))
const keys = (wrapper.emitted('frame-keydown') ?? []).map(([event]) => (event as KeyboardEvent).key)
expect(keys).toContain('p')
wrapper.unmount()
})
})
+25 -1
View File
@@ -10,6 +10,9 @@
// - allow-popups · allow-popups-to-escape-sandbox: 링크를 새 창으로 열고, 새 창은 sandbox 없이 정상 동작하게 한다.
// 링크는 정화 단계에서 http·https·mailto 만 남기고 rel="noopener noreferrer" 를 붙인다.
// 문서 안에는 CSP(default-src 'none'; img-src data:; style-src 'unsafe-inline')와 <base target="_blank"> 가 들어간다.
// 외부 이미지를 보이는 본문(imagesShown — 서버가 중계 주소로 바꿔 넣었다, 9단계)일 때만 CSP 의 그림에 우리 출처의 중계 경로
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { buildMailDocument } from '@/shared/utils/mail-html'
@@ -19,15 +22,29 @@ interface Props {
htmlBody?: string | null
/** HTML 이 없을 때 이스케이프해서 보여 줄 텍스트 */
textBody?: string | null
/** 외부 이미지를 보이는 본문 — 서버의 MessageDetail.imagesShown */
imagesShown?: boolean
/** iframe 이름 (스크린리더) */
title?: string
}
interface Emits {
/** 문서를 다 열고(그림까지 — load) 내용 높이를 쟀다 · 폭이 바뀌어 다시 쟀다 (px) */
(e: 'measured', height: number): void
/**
* 본문 안에서 누른 키 — 본문을 눌러 초점이 iframe 안에 있으면 키가 부모 화면에 오지 않는다.
* 읽기 화면의 Ctrl+P(인쇄 화면)가 이것도 본다. 처리기는 부모 쪽 코드라 sandbox(스크립트 불허)와 상관없이 돈다
*/
(e: 'frame-keydown', event: KeyboardEvent): void
}
const props = withDefaults(defineProps<Props>(), {
htmlBody: null,
textBody: null,
imagesShown: false,
title: '메일 본문',
})
const emit = defineEmits<Emits>()
/** sandbox 값 — 위 설명 외의 권한을 추가하지 않는다 */
const SANDBOX = 'allow-same-origin allow-popups allow-popups-to-escape-sandbox'
@@ -37,7 +54,7 @@ const frameRef = ref<HTMLIFrameElement | null>(null)
const contentHeight = ref<number | null>(null)
const srcdoc = computed(() =>
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody }),
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody, imagesShown: props.imagesShown }),
)
let resizeObserver: ResizeObserver | null = null
@@ -56,6 +73,7 @@ function measure() {
const viewportHeight = frame.contentWindow?.innerHeight ?? 0
const scrollbar = Math.max(0, viewportHeight - doc.documentElement.clientHeight)
contentHeight.value = Math.ceil(doc.body.scrollHeight) + scrollbar
emit('measured', contentHeight.value)
}
function scheduleMeasure() {
@@ -63,8 +81,14 @@ function scheduleMeasure() {
measureFrame = requestAnimationFrame(measure)
}
function onFrameKeydown(event: KeyboardEvent) {
emit('frame-keydown', event)
}
function onLoad() {
documentLoaded = true
// 문서를 새로 열 때마다 단다 — 옛 문서와 함께 옛 처리기도 사라진다
frameRef.value?.contentDocument?.addEventListener('keydown', onFrameKeydown)
measure()
}
+27 -2
View File
@@ -3,21 +3,34 @@
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
// 고를 수 없는 줄(unavailable — 아직 보내지 않은 메일, 9단계)은 disabled 대신 aria-disabled — 초점은 받고(Tab 자리를 잃지 않게)
// 누르거나 Space · Enter 를 눌러도 알리지 않는다. 까닭(note)은 title — 마우스를 올리면 보이고 스크린리더가 설명으로 읽는다
interface Props {
checked: boolean
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
mixed?: boolean
label: string
/** 고를 수 없다 */
unavailable?: boolean
/** 고를 수 없는 까닭 */
note?: string
}
interface Emits {
(e: 'toggle', event: MouseEvent): void
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
mixed: false,
unavailable: false,
note: undefined,
})
const emit = defineEmits<Emits>()
function onClick(event: MouseEvent) {
if (props.unavailable) return
emit('toggle', event)
}
</script>
<template>
@@ -27,7 +40,9 @@ const emit = defineEmits<Emits>()
class="mail-checkbox"
:aria-checked="mixed ? 'mixed' : checked"
:aria-label="label"
@click="emit('toggle', $event)"
:aria-disabled="unavailable ? 'true' : undefined"
:title="unavailable ? note : undefined"
@click="onClick"
>
<svg
class="mail-checkbox__box"
@@ -102,4 +117,14 @@ const emit = defineEmits<Emits>()
stroke-linecap: round;
stroke-linejoin: round;
}
/* 고를 수 없는 줄 — 옅은 칸 */
.mail-checkbox[aria-disabled='true'] {
cursor: not-allowed;
}
.mail-checkbox[aria-disabled='true'] .mail-checkbox__empty {
fill: var(--color-surface-muted);
stroke: var(--color-border);
}
</style>
+11 -4
View File
@@ -2,7 +2,7 @@
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
// 그 아래 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
import { computed, nextTick, ref, watch } from 'vue'
@@ -22,7 +22,9 @@ import { useListPositionMemory } from '@/composables/useListPositionMemory'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
@@ -45,19 +47,24 @@ const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
useListPositionMemory(sectionRef, () => props.variant === 'list')
const search = computed(() => mailStore.search)
/** 예약됨 (9단계) — 고르기 · 알림 권하기가 없다 */
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
const selecting = computed(() => mailStore.checkedCount > 0)
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
const mailbox = computed(() => mailStore.currentMailbox)
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
scheduled.value
? { title: SEND_LATER_TEXT.emptyTitle, hint: SEND_LATER_TEXT.emptyHint }
: emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
const emptyIcon = computed(() => (scheduled.value ? 'clock' : mailboxIcon({ role: mailbox.value?.role ?? null })))
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
function retry() {
const current = search.value
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
else if (scheduled.value) void mailStore.openScheduled()
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
}
@@ -129,7 +136,7 @@ watch(
/>
<MailSearchScope v-if="search" />
<MailNotifyOffer
v-else
v-else-if="!scheduled"
:variant="variant"
@leave="onOfferLeave"
/>
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
@@ -8,7 +9,9 @@ import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { formatCount } from '@/shared/utils/format'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
@@ -24,8 +27,13 @@ withDefaults(defineProps<Props>(), {
const mailStore = useMailStore()
const title = computed(() => mailStore.currentMailbox?.name ?? '메일함')
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
const countText = computed(() => {
if (scheduled.value) {
if (mailStore.listStatus !== 'ready') return ''
return SEND_LATER_TEXT.listCount(formatCount(mailStore.pageInfo?.total ?? mailStore.messages.length))
}
const box = mailStore.currentMailbox
if (!box) return ''
const total = `전체 ${formatCount(box.totalCount)}`
@@ -34,7 +42,7 @@ const countText = computed(() => {
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
const emptiable = computed(() => {
const box = mailStore.currentMailbox
const box = scheduled.value ? null : mailStore.currentMailbox
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
})
</script>
@@ -5,15 +5,15 @@
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (usePopupPlacement)
// - 주소록 패널의 그룹 줄 ⋯ 도 이 단추를 쓴다(8단계 — 같은 32×40 밀도 예외) — 단추 · 메뉴 이름은 label · menuLabel 로 바꾼다
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
import { opensUpward } from '@/shared/utils/popup-placement'
interface Props {
/** 메일함 이름 — 단추 · 메뉴의 이름 */
@@ -36,16 +36,20 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
const buttonRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 위로 연다 — 아래가 모자랄 때 */
const upward = ref<boolean>(false)
/** 위로 연다 — 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
const { upward } = usePopupPlacement({
open,
trigger: () => buttonRef.value,
popup: () => {
const menu: unknown = menuRef.value?.$el
return menu instanceof HTMLElement ? menu : null
},
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
const buttonLabel = computed(() => props.label ?? MAILBOX_PANEL_TEXT.more(props.mailboxName))
@@ -57,37 +61,6 @@ function setOpen(next: boolean) {
emit('open-change', next)
}
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
function visibleArea(element: Element): { top: number; bottom: number } {
let top = 0
let bottom = window.innerHeight
for (let node = element.parentElement; node; node = node.parentElement) {
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
const rect = node.getBoundingClientRect()
top = Math.max(top, rect.top)
bottom = Math.min(bottom, rect.bottom)
}
return { top, bottom }
}
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
function place() {
const button = buttonRef.value
const menu = menuRef.value?.$el
if (!button || !(menu instanceof HTMLElement)) return
const buttonRect = button.getBoundingClientRect()
const menuRect = menu.getBoundingClientRect()
const area = visibleArea(button)
upward.value = opensUpward({
triggerTop: buttonRect.top,
triggerBottom: buttonRect.bottom,
popupHeight: menuRect.height,
areaTop: area.top,
areaBottom: area.bottom,
gap: Math.max(0, menuRect.top - buttonRect.bottom),
})
}
function onClick(event: MouseEvent) {
if (open.value) {
setOpen(false)
@@ -115,16 +88,6 @@ function onSelect(id: string) {
buttonRef.value?.focus()
emit('select', id)
}
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
watch(
open,
(isOpen) => {
upward.value = false
if (isOpen) place()
},
{ flush: 'post' },
)
</script>
<template>
@@ -3,6 +3,7 @@
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
@@ -15,6 +16,7 @@ import MailboxTree from '@/components/mail/MailboxTree.vue'
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
import { useMailboxActions } from '@/composables/useMailboxActions'
import type { Mailbox } from '@/shared/types/mail'
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
import type { MailboxAction } from '@/shared/utils/mailbox'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -30,6 +32,7 @@ const dialog = ref<MailboxDialogRequest | null>(null)
const isComposing = computed(() => route.name === 'mail-compose')
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
const scheduledActive = computed(() => isScheduledRouteName(route.name))
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
function onAction(action: MailboxAction, mailbox: Mailbox) {
@@ -88,6 +91,8 @@ watch(
v-if="mailStore.orderedMailboxes.length > 0"
:nodes="mailStore.orderedMailboxes"
:active-id="activeMailboxId"
:scheduled-count="mailStore.scheduledCount"
:scheduled-active="scheduledActive"
@action="onAction"
@create="dialog = { kind: 'create', parent: null }"
/>
@@ -0,0 +1,95 @@
<script setup lang="ts">
// 메일함 패널의 「예약됨」 (디자인 원본 MailSendLater · README "9단계 › 「예약됨」") — 보낸편지함 아래, 예약한 메일이 있을 때만(부르는 쪽이 정한다 —
// 예약됨을 보는 동안은 0통이어도 남는다)
// 시계 아이콘 · 이름 · 오른쪽에 예약한 통 수(평범한 숫자, 뱃지 아님 — 0 이면 숫자를 두지 않는다). 메일함 줄(MailboxPanelRow)과 같은 모양이고 ⋯ 메뉴는 없다
import AppIcon from '@/components/AppIcon.vue'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { formatCount } from '@/shared/utils/format'
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
interface Props {
count: number
/** 지금 예약됨 목록 · 예약됨에서 연 메일을 보고 있다 */
active: boolean
}
defineProps<Props>()
</script>
<template>
<li :class="['scheduled-mailbox', { 'is-active': active }]">
<RouterLink
:to="{ name: MAIL_SCHEDULED_ROUTE }"
class="scheduled-mailbox__link"
:aria-current="active ? 'page' : undefined"
data-scheduled-mailbox
>
<AppIcon name="clock" />
<span class="scheduled-mailbox__name">{{ SEND_LATER_TEXT.mailbox }}</span>
<span
v-if="count > 0"
class="scheduled-mailbox__count"
>{{ formatCount(count) }}<span class="visually-hidden">통</span></span>
</RouterLink>
</li>
</template>
<style scoped>
/* 메일함 줄(MailboxPanelRow)과 같은 높이 · 여백 · 현재 표시 */
.scheduled-mailbox {
display: flex;
align-items: center;
border-radius: var(--radius-item);
}
.scheduled-mailbox:hover {
background: var(--color-surface-muted);
}
.scheduled-mailbox.is-active {
background: var(--color-brand-subtle);
}
.scheduled-mailbox__link {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
/* 오른쪽 — 메일함 줄의 ⋯ 자리(32)만큼 비워 숫자 끝을 맞춘다 */
padding: 0 calc(var(--space-4) + var(--size-mailbox-more)) 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.scheduled-mailbox:hover .scheduled-mailbox__link {
color: var(--color-text);
}
.scheduled-mailbox.is-active .scheduled-mailbox__link {
color: var(--color-brand-strong);
font-weight: 600;
}
.scheduled-mailbox__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scheduled-mailbox__count {
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.scheduled-mailbox.is-active .scheduled-mailbox__count {
color: var(--color-brand-strong);
}
</style>
+45 -6
View File
@@ -2,10 +2,13 @@
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
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 { 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'
@@ -14,6 +17,10 @@ interface Props {
nodes: readonly MailboxNode[]
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
activeId: string | null
/** 예약한 메일 수 — 0 이면 「예약됨」을 그리지 않는다 (9단계) */
scheduledCount?: number
/** 지금 예약됨을 보고 있다 */
scheduledActive?: boolean
}
interface Emits {
@@ -21,24 +28,56 @@ interface Emits {
(e: 'create'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
scheduledCount: 0,
scheduledActive: false,
})
const emit = defineEmits<Emits>()
const MY_GROUP_ID = 'mailbox-panel-mine'
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
const AT_GROUP_END = ''
const groups = computed(() => splitMailboxGroups(props.nodes))
/**
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
*/
const scheduledAfterId = computed<string | null>(() => {
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
const system = groups.value.system
const sentIndex = system.findIndex((node) => node.mailbox.role === 'sent')
const sent = system[sentIndex]
if (!sent) return AT_GROUP_END
let last = sentIndex
while ((system[last + 1]?.depth ?? -1) > sent.depth) last += 1
return system[last]?.mailbox.id ?? AT_GROUP_END
})
</script>
<template>
<div class="mailbox-tree">
<ul class="mailbox-tree__list">
<MailboxPanelRow
<template
v-for="node in groups.system"
:key="node.mailbox.id"
:mailbox="node.mailbox"
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
>
<MailboxPanelRow
:mailbox="node.mailbox"
:depth="node.depth"
:active="node.mailbox.id === activeId"
@action="(action, mailbox) => emit('action', action, mailbox)"
/>
<MailboxScheduledRow
v-if="scheduledAfterId === node.mailbox.id"
:count="scheduledCount"
:active="scheduledActive"
/>
</template>
<MailboxScheduledRow
v-if="scheduledAfterId === AT_GROUP_END"
:count="scheduledCount"
:active="scheduledActive"
/>
</ul>
<p
@@ -0,0 +1,239 @@
<script setup lang="ts">
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
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'
interface Props {
open: boolean
/** 보낸 사람 주소 */
senderEmail: string
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
inJunk?: boolean
}
interface Emits {
/** target 은 주소 또는 "@도메인" (소문자) */
(e: 'confirm', target: string, moveToJunk: boolean): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
inJunk: false,
})
const emit = defineEmits<Emits>()
const scope = ref<'address' | 'domain'>('address')
const moveToJunk = ref<boolean>(true)
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))
function confirm() {
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
emit('confirm', target, moveToJunk.value && !props.inJunk)
}
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'address'
moveToJunk.value = true
},
)
</script>
<template>
<ModalDialog
:open="open"
:title="MAIL_BLOCK_TEXT.dialogTitle"
:message="MAIL_BLOCK_TEXT.dialogMessage"
title-id="block-dialog-title"
message-id="block-dialog-message"
medium
@cancel="emit('cancel')"
>
<div
class="block-dialog"
data-block-dialog
>
<div
role="radiogroup"
class="block-dialog__scopes"
:aria-label="MAIL_BLOCK_TEXT.scopeGroup"
>
<label :class="['block-dialog__scope', { 'is-checked': scope === 'address' }]">
<input
v-model="scope"
type="radio"
name="block-dialog-scope"
value="address"
class="block-dialog__input"
data-block-scope="address"
>
<span class="block-dialog__scope-text">
<span class="block-dialog__scope-label">{{ MAIL_BLOCK_TEXT.scopeAddress }}</span>
<span class="block-dialog__scope-hint">{{ address }}</span>
</span>
</label>
<label
:class="[
'block-dialog__scope',
{ 'is-checked': scope === 'domain' && !domainDisabled, 'is-disabled': domainDisabled },
]"
>
<input
v-model="scope"
type="radio"
name="block-dialog-scope"
value="domain"
class="block-dialog__input"
:disabled="domainDisabled"
:aria-describedby="domainDisabled && domain ? 'block-dialog-domain-note' : undefined"
data-block-scope="domain"
>
<span class="block-dialog__scope-text">
<span class="block-dialog__scope-label">{{ MAIL_BLOCK_TEXT.scopeDomain }}</span>
<span class="block-dialog__scope-hint">{{ MAIL_BLOCK_TEXT.scopeDomainHint(domainTarget) }}</span>
</span>
</label>
<p
v-if="domainDisabled && domain"
id="block-dialog-domain-note"
class="form-message block-dialog__note"
>
{{ 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
>
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
</label>
<p class="form-message">
{{ MAIL_BLOCK_TEXT.settingsHint }}
</p>
</div>
<template #actions>
<button
type="button"
class="modal-dialog__action"
autofocus
data-block-confirm
@click="confirm"
>
{{ MAIL_BLOCK_TEXT.confirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-block-cancel
@click="emit('cancel')"
>
{{ MAIL_BLOCK_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.block-dialog {
display: flex;
flex-direction: column;
gap: var(--space-14);
margin-top: var(--space-6);
}
.block-dialog__scopes {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 고르기 줄 — 44 이상 · 테두리 상자, 고른 줄은 청록 테두리 + 옅은 바탕 (디자인 원본 MailReadTools) */
.block-dialog__scope {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding: var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
cursor: pointer;
}
.block-dialog__scope.is-checked {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.block-dialog__scope.is-disabled {
color: var(--color-icon-muted);
cursor: default;
}
.block-dialog__input {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.block-dialog__scope-text {
display: flex;
flex-direction: column;
line-height: var(--line-height-heading);
min-width: 0;
overflow-wrap: anywhere;
}
.block-dialog__scope-label {
font-weight: 600;
}
.block-dialog__scope-hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.block-dialog__scope.is-disabled .block-dialog__scope-hint {
color: var(--color-icon-muted);
}
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
.block-dialog__note {
margin-left: var(--space-28);
}
.block-dialog__move {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.block-dialog__move .block-dialog__input {
margin: 0;
}
</style>
@@ -3,16 +3,23 @@
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
// - 아직 보내지 않은 메일(9단계 — pendingSend 가 있다: 보내기 취소를 기다리는 중 · 예약): 확인 대화상자(처음 초점은 「취소」 — 발송 취소는
// 되돌릴 수 없다)를 거쳐 발송을 취소한 뒤 휴지통으로 — "보내지 않고 휴지통으로 옮겼습니다." (useScheduledMessageActions).
// 취소하지 않고 휴지통으로 옮기면 엔진은 그 시각에 그대로 보낸다 (2026-10-29 리뷰)
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MessageSummary } from '@/shared/types/mail'
import { isHeldMessage } from '@/shared/utils/pending-send'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -25,35 +32,61 @@ const props = defineProps<Props>()
const mailStore = useMailStore()
const removal = useMessageRemoval()
const scheduledActions = useScheduledMessageActions()
const working = ref<boolean>(false)
const confirmOpen = ref<boolean>(false)
/** 열린 확인 창 — 누른 때의 메일 상태로 정한다 (창을 연 채 상태가 바뀌면 닫는다 — 아래 watch) */
const confirming = ref<'held' | 'purge' | null>(null)
/** 아직 보내지 않은 메일 — 발송을 취소한 뒤 휴지통으로 */
const held = computed(() => isHeldMessage(props.message))
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
const permanent = computed(() => removal.deletesPermanently(props.message))
const permanent = computed(() => !held.value && removal.deletesPermanently(props.message))
/**
* 그려 둘 확인 창 — 누를 때 정한다. 그사이 발송이 풀리거나 창을 닫아도 바꾸지 않는다 —
* 닫히는 창이 그대로 남아 초점을 이 단추로 돌려준다 (ModalDialog)
*/
const dialogKind = ref<'held' | 'purge'>(held.value ? 'held' : 'purge')
async function run(kind: 'trash' | 'purge') {
async function run(kind: 'trash' | 'purge' | 'held') {
const target = props.message
const source = mailStore.listSource
working.value = true
try {
if (kind === 'held') {
await scheduledActions.deletePendingSend(target, props.layout)
return
}
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
await removal.leaveRemoved(outcome, source, props.layout)
await removal.leaveRemoved(outcome, source, props.layout, target.id)
} finally {
working.value = false
}
}
function openConfirm(kind: 'held' | 'purge') {
dialogKind.value = kind
confirming.value = kind
}
function onClick() {
if (working.value) return
if (permanent.value) confirmOpen.value = true
if (held.value) openConfirm('held')
else if (permanent.value) openConfirm('purge')
else void run('trash')
}
function onConfirm() {
confirmOpen.value = false
void run('purge')
const kind = confirming.value
confirming.value = null
if (kind) void run(kind)
}
// 확인 창을 연 채 그 메일이 바뀌면 닫는다 — 엔진이 발송을 풀어 보낸 메일이 됐거나(9단계) 다른 메일 · 메일함으로 바뀌었다.
// 연 때와 다른 일(보낸 메일의 영구 삭제 확인 등)을 묻지 않게
watch([held, permanent, () => props.message.id], () => {
confirming.value = null
})
</script>
<template>
@@ -65,11 +98,42 @@ function onConfirm() {
data-reader-delete
@click="onClick"
/>
<ModalDialog
v-if="dialogKind === 'held'"
:open="confirming === 'held'"
role="alertdialog"
:title="SEND_LATER_TEXT.deleteTitle"
:message="SEND_LATER_TEXT.deleteMessage"
title-id="reader-scheduled-delete-title"
message-id="reader-scheduled-delete-message"
@cancel="confirming = null"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
data-scheduled-delete-confirm
@click="onConfirm"
>
{{ SEND_LATER_TEXT.deleteConfirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
data-scheduled-delete-cancel
@click="confirming = null"
>
{{ SEND_LATER_TEXT.deleteCancel }}
</button>
</template>
</ModalDialog>
<MailPurgeDialog
:open="confirmOpen"
v-else
:open="confirming === 'purge'"
:count="1"
id-prefix="reader-purge"
@confirm="onConfirm"
@cancel="confirmOpen = false"
@cancel="confirming = null"
/>
</template>
@@ -134,6 +134,27 @@ describe('MessageList (분할 보기 목록)', () => {
})
})
it('아직 보내지 않은 메일(9단계)은 고를 수 없다 — 꺼진 상자(aria-disabled · 까닭 title)를 눌러도 · Ctrl(⌘)+클릭도 고르지 않고, 그냥 누르면 연다 (리뷰 #1)', async () => {
const rows = [
summary('u1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } }),
summary('m1', { mailboxIds: ['sent'] }),
]
const { wrapper, router, store } = await mountList(SENT, rows)
const box = wrapper.findAll('.message-row [role="checkbox"]')[0]
expect(box?.attributes('aria-disabled')).toBe('true')
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.')
await box?.trigger('click')
await wrapper.findAll('.message-row__main')[0]?.trigger('click', { metaKey: true })
await flushPromises()
expect(store.checkedCount).toBe(0)
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
await wrapper.findAll('.message-row__main')[0]?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/u1')
})
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
const q = encodeURIComponent('회의')
const { wrapper, store } = await mountList(
@@ -2,18 +2,23 @@
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
// 아직 보내지 않은 내 메일(9단계)은 시각 자리에 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록 — 디자인 원본 MailSendLater)
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
@@ -51,15 +56,22 @@ const emit = defineEmits<Emits>()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const time = computed(() => formatMailListTime(props.message.receivedAt))
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.message))
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.message.receivedAt))
const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
/** 아직 보내지 않은 메일 — 고를 수 없다 */
const held = computed(() => isHeldMessage(props.message))
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
/**
* Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다).
* 아직 보내지 않은 메일은 고르지 않는다
*/
function onMainClick(event: MouseEvent) {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
event.preventDefault()
emit('check', props.message.id, event.shiftKey)
if (!held.value) emit('check', props.message.id, event.shiftKey)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
@@ -81,6 +93,8 @@ function onMouseDown(event: MouseEvent) {
class="message-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
/>
<RouterLink
@@ -101,8 +115,8 @@ function onMouseDown(event: MouseEvent) {
class="message-row__mailbox"
>{{ mailboxName }} ·</span>
<time
class="message-row__time"
:datetime="message.receivedAt"
:class="['message-row__time', { 'is-pending': pendingLabel }]"
:datetime="message.pendingSend?.sendAt ?? message.receivedAt"
>{{ time }}</time>
</span>
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
@@ -254,6 +268,13 @@ function onMouseDown(event: MouseEvent) {
font-variant-numeric: tabular-nums;
}
/* 아직 보내지 않은 내 메일 — 청록 굵게 (디자인 원본 MailSendLater) */
.message-row__time.is-pending,
.message-row.is-unread .message-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
.message-row__bottom {
min-width: 0;
display: flex;
+108 -18
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 그 아래 둘째 묶음에 「보낸 사람을 주소록에 추가」(8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께") ·
// 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴 (디자인 원본 MailReadTools · README "9단계 › ⋯ 메뉴"):
// 「인쇄」(Ctrl+P 표시 — 9단계) → 「원문 내려받기 (.eml)」 → 구분선 → 「보낸 사람을 주소록에 추가」/「주소록에서 보기」(8단계) →
// 「이런 메일로 필터 만들기」(6단계) → 「보낸 사람 차단」(빨간 글 — 9단계) / 「보낸 사람 차단 풀기」
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 인쇄: 새 탭에 인쇄 화면을 연다(useMailPrint — 외부 이미지를 보이고 있었으면 그대로)
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
// 주소록: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다
// (창은 메일 레이아웃에 있다 — useMailContactDialog. 메일을 바꿔도 창이 남는다).
@@ -11,43 +12,61 @@
// 결과가 오기 전에는 「추가」로 둔다 (저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보인다).
// 연락처가 3,000개면 「추가」를 끄고 까닭을 아래에 (수를 모르거나 3,000개로 받아 두었으면 열 때 다시 받아 본다)
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
// 보낸 사람 주소가 없는 메일에는 두 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 보낸 사람 주소가 없는 메일에는 주소록 · 필터 · 차단 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
// 차단: 사내 직원(사내 도메인) · 내 주소에는 항목이 없다. 이미 막았으면(주소 또는 도메인) 「보낸 사람 차단 풀기」(바로 푼다 + [되돌리기]),
// 아니면 「보낸 사람 차단」 → 확인 창(MessageBlockDialog). 500개면 끄고 까닭을 아래에. 차단 목록은 메일 화면에 들어올 때 받아 두고,
// 메뉴를 열 때 못 받았으면 다시 받아 본다(알리지 않는다). 막으며 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로 간다
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MessageBlockDialog from '@/components/mail/MessageBlockDialog.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { useMailContactDialog } from '@/composables/useMailContactDialog'
import { useMailPrint } from '@/composables/useMailPrint'
import { useMessageRemoval } from '@/composables/useMessageRemoval'
import { useSenderBlock } from '@/composables/useSenderBlock'
import { CONTACT_READER_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import { MAIL_PRINT_TEXT } from '@/shared/constants/mail-print'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { Contact } from '@/shared/types/contacts'
import type { MessageDetail } from '@/shared/types/mail'
import { contactLocation } from '@/shared/utils/contacts-route'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { isUnblockableSender } from '@/shared/utils/mail-block'
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 { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
messageId: string
/** 보낸 사람 주소 — 없으면 주소록 · 필터 항목을 두지 않는다 */
senderEmail?: string | null
/** 보낸 사람 표시 이름 — 주소록에 추가할 때 이름 칸을 채운다 */
senderName?: string | null
message: MessageDetail
/** split: 차단하며 옮기면 다음 메일을 연다 · list: 목록으로 */
layout?: 'split' | 'list'
}
const props = withDefaults(defineProps<Props>(), {
senderEmail: null,
senderName: null,
layout: 'split',
})
const { downloadMessageSource } = useMail()
const authStore = useAuthStore()
const mailStore = useMailStore()
const filterStore = useMailFilterStore()
const contactsStore = useContactsStore()
const blockStore = useMailBlockStore()
const contactDialog = useMailContactDialog()
const print = useMailPrint()
const senderBlock = useSenderBlock()
const removal = useMessageRemoval()
const router = useRouter()
const buttonRef = ref<HTMLButtonElement | null>(null)
@@ -56,11 +75,30 @@ const open = ref<boolean>(false)
const byKeyboard = ref<boolean>(false)
/** 보낸 사람의 연락처 — 내 주소록에 있으면 그 연락처, 없거나 아직 모르면 null */
const senderContact = ref<Contact | null>(null)
/** 차단 확인 창 */
const blockDialogOpen = ref<boolean>(false)
let lookupSeq = 0
const sender = computed(() => props.senderEmail?.trim() ?? '')
const sender = computed(() => props.message.from?.email?.trim() ?? '')
/**
* 차단할 수 있는 보낸 사람 — 주소가 있고 내 주소 · 사내 직원이 아니다. 아직 보내지 않은 메일(9단계 — 내 메일이라 보통 차단 항목이 없다)에도 두지 않는다:
* 차단 창은 그 메일을 스팸메일함으로 옮기는데, 붙잡은 발송은 옮겨도 그 시각에 보내진다
*/
const blockable = computed(
() =>
sender.value !== '' &&
!isHeldMessage(props.message) &&
!isUnblockableSender(sender.value, authStore.user?.email ?? ''),
)
/** 이미 막고 있는 차단 (주소 또는 도메인) */
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
const inJunk = computed(() => removal.isInJunk(props.message))
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
const list: EditorMenuItem[] = [
{ id: 'print', label: MAIL_PRINT_TEXT.menu, icon: 'printer', hint: print.shortcut.label },
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
]
if (!sender.value) return list
list.push(menuSeparator('sender'))
if (senderContact.value) {
@@ -81,6 +119,19 @@ const items = computed<EditorMenuItem[]>(() => {
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
})
if (!blockable.value) return list
if (senderBlockEntry.value) {
list.push({ id: 'unblock', label: MAIL_BLOCK_TEXT.menuUnblock, icon: 'block' })
} else {
list.push({
id: 'block',
label: MAIL_BLOCK_TEXT.menuBlock,
icon: 'block',
tone: 'danger',
disabled: blockStore.isFull,
note: blockStore.isFull ? MAIL_BLOCK_TEXT.fullNote : undefined,
})
}
return list
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
@@ -104,6 +155,8 @@ function openMenu(keyboard: boolean) {
// 주소록에 있는지 · 연락처가 3,000개인지 (알리지 않는다)
void lookUpSender()
contactsStore.ensureContactTotal()
// 차단했는지 · 500개인지 — 막을 수 있는 보낸 사람일 때만, 받아 둔 것이 없으면 (알리지 않는다)
if (blockable.value) blockStore.ensureLoaded()
}
function onClick(event: MouseEvent) {
@@ -129,14 +182,20 @@ function onClose(restoreFocus: boolean) {
function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
if (id === 'print') print.openPrint(props.message)
if (id === 'source') void downloadMessageSource(props.message.id)
if (id === 'contact-view' && senderContact.value) void router.push(contactLocation(senderContact.value.id))
if (id === 'contact-add' && sender.value && !contactsStore.isFull) {
contactDialog?.open({ name: props.senderName?.trim() || null, email: sender.value, label: 'work' }, focusButton)
contactDialog?.open(
{ name: props.message.from?.name?.trim() || null, email: sender.value, label: 'work' },
focusButton,
)
}
if (id === 'filter' && sender.value && !filterStore.isFull) {
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
}
if (id === 'block' && blockable.value && !blockStore.isFull) blockDialogOpen.value = true
if (id === 'unblock' && senderBlockEntry.value) void senderBlock.unblock(senderBlockEntry.value.address)
}
/** 연락처 창을 닫은 뒤 — 이 단추가 아직 있으면 초점을 돌려준다 (다른 메일로 바뀌어 단추가 사라졌으면 그대로) */
@@ -145,12 +204,36 @@ function focusButton() {
if (button?.isConnected) button.focus()
}
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다
/**
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
*/
async function onBlock(target: string, moveToJunk: 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)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.messages.some((item) => item.id === message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
}
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다 (확인 창도 닫는다 — 다른 메일의 보낸 사람을 막지 않게)
watch(
() => normalizeEmail(sender.value),
() => {
lookupSeq += 1
senderContact.value = null
blockDialogOpen.value = false
},
)
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인 창을 닫는다 — 세션 만료 대화상자와 겹치지 않게
watch(
() => authStore.generation,
() => {
blockDialogOpen.value = false
},
)
</script>
@@ -182,6 +265,13 @@ watch(
@select="onSelect"
@close="onClose"
/>
<MessageBlockDialog
:open="blockDialogOpen"
:sender-email="sender"
:in-junk="inJunk"
@confirm="onBlock"
@cancel="blockDialogOpen = false"
/>
</div>
</template>
@@ -210,9 +300,9 @@ watch(
color: var(--color-text);
}
/* 메뉴 폭 280 (디자인 원본 ContactsMail) — 필터 50개 · 연락처 3,000개의 까닭(note)은 그 안에서 줄을 바꾼다 */
/* 메뉴 폭 280~300 (디자인 원본 ContactsMail · MailReadTools) — 필터 50개 · 연락처 3,000개 · 차단 500개의 까닭(note)은 그 안에서 줄을 바꾼다 */
.reader-more__menu {
min-width: 12.5rem;
max-width: 17.5rem;
max-width: 18.75rem;
}
</style>
@@ -622,7 +622,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
// 첫 항목은 인쇄(9단계 — 단축키 표시), 사내 직원의 메일이라 「보낸 사람 차단」은 없다
expect(items.map((item) => item.text())).toEqual([
'인쇄Ctrl+P',
'원문 내려받기 (.eml)',
'보낸 사람을 주소록에 추가',
'이런 메일로 필터 만들기',
@@ -631,7 +633,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(wrapper.findAll('[role="menu"] hr')).toHaveLength(1)
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await items[1]?.trigger('click')
await flushPromises()
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
@@ -655,7 +657,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.get('[role="menuitem"]').trigger('click')
await wrapper.findAll('[role="menuitem"]')[1]?.trigger('click')
await flushPromises()
expect(downloads).toEqual([])
@@ -667,7 +669,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]')[2]
const item = wrapper.findAll('[role="menuitem"]')[3]
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
expect(item?.attributes('disabled')).toBeUndefined()
@@ -693,7 +695,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menuitem"]')
const item = items[2]
const item = items[3]
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.attributes('disabled')).toBeUndefined()
@@ -703,6 +705,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
await items[1]?.trigger('keydown', { key: 'ArrowDown' })
await items[2]?.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement).toBe(item?.element)
await item?.trigger('click')
@@ -720,7 +723,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
},
})
const more = wrapper.get('[data-reader-more]')
const filterItem = () => wrapper.findAll('[role="menuitem"]')[2]
const filterItem = () => wrapper.findAll('[role="menuitem"]')[3]
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
await more.trigger('click')
@@ -749,7 +752,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['인쇄Ctrl+P', '원문 내려받기 (.eml)'])
expect(wrapper.find('[role="menu"] hr').exists()).toBe(false)
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
expect(requests.some((request) => request.url === '/contacts')).toBe(false)
@@ -767,7 +770,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
note: null,
groupIds: [],
}
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[1]
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[2]
it('내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 보낸 사람의 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다', async () => {
const { wrapper, router } = await mountWith({ routeView: true })
@@ -855,7 +858,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const failing = await mountWith({ contacts: () => fail(503, 'MAIL_001') })
await failing.wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(failing.wrapper.findAll('[role="menuitem"]')[1]?.text()).toBe('보낸 사람을 주소록에 추가')
expect(failing.wrapper.findAll('[role="menuitem"]')[2]?.text()).toBe('보낸 사람을 주소록에 추가')
expect(useToastStore().toasts).toEqual([])
})
@@ -0,0 +1,738 @@
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 type { BlockedSender, Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockStore } from '@/stores/mail-block.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'
// 읽기 화면의 9단계 — 외부 이미지 [이미지 보기] · 예약한 메일(안내 줄 · 도구 줄 · 삭제) · 보낸 사람 차단(⋯ · 확인 창 · 스팸메일함의 줄) · 인쇄
// (디자인 원본 MailReadTools · MailSendLater · README "9단계" · 계약 2절 "외부 이미지 보기" · "보내기 취소 · 예약 발송" · "보낸 사람 차단" · "인쇄")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts' }
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
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: '[클라우드 브리프] 10월 넷째 주',
preview: '',
receivedAt: '2026-10-28T23:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: '<p>안녕하세요, 이번 주 클라우드 브리프입니다.</p>',
textBody: '안녕하세요',
hasBlockedImages: true,
imagesShown: false,
attachments: [],
}
const SHOWN: MessageDetail = {
...NEWSLETTER,
htmlBody: '<p>안녕하세요, 이번 주 클라우드 브리프입니다.</p><img src="/api/mail-images/t0ken">',
hasBlockedImages: false,
imagesShown: true,
}
const SEND_AT = '2026-10-30T00:00:00.000Z'
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
const SCHEDULED: MessageDetail = {
...NEWSLETTER,
id: 's1',
mailboxIds: ['sent'],
from: USER,
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
htmlBody: null,
textBody: '도윤님, 확인 부탁드립니다.',
hasBlockedImages: false,
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'
blocks?: BlockedSender[]
/** 그 밖의 요청 — 앞에서 처리하지 않은 것 */
extra?: (request: MockRequest) => MockResult | undefined
}
describe('MessageReader — 9단계', () => {
let pinia: Pinia
let requests: MockRequest[]
let opened: unknown[][]
/** 띄운 화면 — 읽기 화면은 window 에 Ctrl+P 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
opened = []
vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => {
opened.push(args)
return 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: options.blocks ?? [], isActive: true })
}
if (request.url === '/mail-blocks' && request.method === 'POST') {
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-10-29T06:00:00.000Z' }, 201)
}
if (request.url.startsWith('/mail-blocks/')) return ok({ address: decodeURIComponent(request.url.slice(13)) })
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: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
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 }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = message
store.selectedId = message.id
store.selectedStatus = 'ready'
if (options.blocks) await useMailBlockStore().load()
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 toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
function runToastAction(index = 0) {
const action = useToastStore().toasts[index]?.action
if (!action || !('run' in action)) throw new Error('단추가 없다')
action.run()
}
describe('외부 이미지 — [이미지 보기] (36)', () => {
it('노란 안내 줄의 [이미지 보기] → images=show 로 다시 받아 본문만 바꾸고 안내 줄은 사라진다 — 본문 틀이 중계 주소의 그림을 그린다', async () => {
const { wrapper, store } = await mountWith({
extra: (request) => (request.url === '/messages/m1' ? ok(SHOWN) : undefined),
})
const notice = wrapper.get('[data-notice="blockedImages"]')
expect(notice.text()).toContain('외부 이미지를 차단했습니다.')
const button = notice.get('[data-show-images]')
expect(button.text()).toBe('이미지 보기')
await button.trigger('click')
expect(button.attributes('aria-busy')).toBe('true')
await flushPromises()
expect(requests.find((request) => request.url === '/messages/m1')?.params).toEqual({ images: 'show' })
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(false)
expect(store.selected?.imagesShown).toBe(true)
const srcdoc = wrapper.get('iframe').attributes('srcdoc') ?? ''
expect(srcdoc).toContain('src="/api/mail-images/t0ken"')
expect(srcdoc).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
})
it('설정으로 처음부터 보이면(imagesShown) 안내 줄이 없다', async () => {
const { wrapper } = await mountWith({ message: SHOWN })
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(false)
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0ken"')
})
it('받는 사이 다른 메일로 바뀌면 늦은 응답은 본문을 바꾸지 않는다', async () => {
let finish: (result: MockResult) => void = () => undefined
const late = new Promise<MockResult>((resolve) => {
finish = resolve
})
const { wrapper, store } = await mountWith({
extra: (request) => {
if (request.url === '/messages/m1') return late as unknown as MockResult
if (request.url === '/messages/m2') return ok({ ...NEWSLETTER, id: 'm2', subject: '다음 메일' })
return undefined
},
})
await wrapper.get('[data-show-images]').trigger('click')
await store.openMessage('m2')
finish(ok(SHOWN))
await flushPromises()
expect(store.selected?.id).toBe('m2')
expect(store.selected?.imagesShown).toBe(false)
expect(wrapper.find('[data-notice="blockedImages"]').exists()).toBe(true)
})
})
describe('인쇄 — ⋯ 「인쇄」(첫 항목 · Ctrl+P) · 읽기 화면의 Ctrl+P', () => {
it('⋯ 「인쇄」는 새 탭(noopener)에 인쇄 화면을 연다 — 첫 항목 · 단축키 표시', async () => {
const { wrapper } = await mountWith()
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const first = wrapper.findAll('[role="menuitem"]')[0]
expect(first?.get('.menu__label').text()).toBe('인쇄')
expect(first?.get('.menu__hint').text()).toBe('Ctrl+P')
expect(first?.find('rect').exists()).toBe(true)
await first?.trigger('click')
expect(opened).toEqual([['/mail/print/m1', '_blank', 'noopener']])
})
it('외부 이미지를 보이고 있었으면 인쇄에도 그대로 — ?images=show', async () => {
const { wrapper } = await mountWith({ message: SHOWN })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]')[0]?.trigger('click')
expect(opened).toEqual([['/mail/print/m1?images=show', '_blank', 'noopener']])
})
it('읽기 화면에서 Ctrl+P 는 브라우저 인쇄 대신 인쇄 화면을 연다 — 본문 안에서 눌러도, 다른 글쇠와 함께면 아니다', async () => {
const { wrapper } = await mountWith()
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, cancelable: true })
window.dispatchEvent(press)
expect(press.defaultPrevented).toBe(true)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true }))
expect(opened).toEqual([['/mail/print/m1', '_blank', 'noopener']])
// 본문(iframe) 안에 초점이 있어도 — 본문 틀이 키를 알린다
const frame = wrapper.get('iframe').element as HTMLIFrameElement
frame.dispatchEvent(new Event('load'))
const inFrame = new KeyboardEvent('keydown', { key: 'P', ctrlKey: true, bubbles: true, cancelable: true })
frame.contentDocument?.dispatchEvent(inFrame)
expect(inFrame.defaultPrevented).toBe(true)
expect(opened).toHaveLength(2)
wrapper.unmount()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true }))
expect(opened).toHaveLength(2)
})
it('메일을 불러오는 중이면 Ctrl+P 를 가로채지 않는다', async () => {
const { store } = await mountWith()
store.selectedStatus = 'loading'
store.selected = null
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, cancelable: true })
window.dispatchEvent(press)
expect(press.defaultPrevented).toBe(false)
expect(opened).toEqual([])
})
})
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('머리 아래 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', 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(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
})
it('[예약 취소] — 보내지 않고 임시보관함으로, "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] · 다음 메일을 연다', async () => {
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.method === 'DELETE') return ok({ draftId: 's1' })
// 취소한 뒤 다시 맞추는 예약됨 목록 — 남은 메일
if (request.url === '/scheduled-messages') {
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
return undefined
},
})
await wrapper.get('[data-scheduled-cancel]').trigger('click')
await flushPromises()
expect(cancels().map((request) => request.url)).toEqual(['/scheduled-messages/s1'])
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
expect(useToastStore().toasts).toEqual([
expect.objectContaining({
message: '예약을 취소하고 임시보관함으로 옮겼습니다.',
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 's1' } } },
}),
])
})
it('[예약 취소] 뒤 그 자리에 남으면(검색 결과) 줄과 함께 사라진 단추 대신 본문으로 초점을 옮긴다 (리뷰 #8)', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'search', q: '배포', scope: 'all' },
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
})
const button = wrapper.get('[data-scheduled-cancel]')
;(button.element as HTMLButtonElement).focus()
await button.trigger('click')
await flushPromises()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('iframe').element)
})
it('[고치기] — 예약을 취소하고 그 메일로 쓰기 화면을 연다', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
})
await wrapper.get('[data-scheduled-edit]').trigger('click')
await flushPromises()
expect(cancels()).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
expect(toastMessages()).toEqual(['예약을 취소했습니다. 고친 뒤 다시 보내 주세요.'])
})
it('[고치기] 뒤 쓰기 화면으로 가지 못하면(이동이 막힘) 그 자리에 두고 "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기]', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
})
router.beforeEach((to) => to.name !== 'mail-compose')
await wrapper.get('[data-scheduled-edit]').trigger('click')
await flushPromises()
expect(cancels()).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
expect(useToastStore().toasts).toEqual([
expect.objectContaining({
message: '예약을 취소하고 임시보관함으로 옮겼습니다.',
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 's1' } } },
}),
])
})
it('이미 보냈으면(409) 아무 데도 가지 않는다 — 알림은 인터셉터', async () => {
const { wrapper, router } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? fail(409, 'MAIL_014') : undefined),
})
await wrapper.get('[data-scheduled-edit]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
})
it('[삭제] — 확인 창(처음 초점 「취소」) 뒤 예약을 취소하고 휴지통으로 옮긴다', async () => {
const { wrapper } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 's1' }) : undefined),
})
await wrapper.get('[data-reader-delete]').trigger('click')
await flushPromises()
expect(wrapper.get('#reader-scheduled-delete-title').text()).toBe('보내지 않은 메일을 삭제할까요?')
expect(document.activeElement).toBe(wrapper.get('[data-scheduled-delete-cancel]').element)
expect(cancels()).toEqual([])
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
await flushPromises()
expect(cancels()).toHaveLength(1)
expect(requests.find((request) => request.method === 'PATCH' && request.url === '/messages/s1')?.data).toEqual({
mailboxId: 'trash',
})
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
})
it('[예약 취소] 를 기다리는 사이 다른 메일함으로 옮겨 갔으면 옛 목록의 다음 메일로 끌고 가지 않는다 (리뷰 #4)', async () => {
const cancel = deferred()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
})
await wrapper.get('[data-scheduled-cancel]').trigger('click')
// 기다리는 사이 받은편지함의 다른 메일을 연다
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [{ ...NEWSLETTER, id: 'i1' }]
store.selectedId = 'i1'
await router.push('/mail/inbox/i1')
cancel.resolve(ok({ draftId: 's1' }))
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/i1')
// 취소는 됐다 — 알림은 띄운다
expect(toastMessages()).toEqual(['예약을 취소하고 임시보관함으로 옮겼습니다.'])
})
it('[삭제] 를 기다리는 사이 다른 메일을 열었으면 그 자리에 둔다 (리뷰 #4)', async () => {
const cancel = deferred()
const { wrapper, router, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
})
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }]
await wrapper.get('[data-reader-delete]').trigger('click')
await flushPromises()
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
// 같은 목록의 다른 메일(m3 — 지운 메일의 다음 메일 m2 가 아니다)을 연다
store.selectedId = 'm3'
await router.push('/mail/sent/m3')
cancel.resolve(ok({ draftId: 's1' }))
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m3')
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
})
it('같은 메일의 취소를 거듭 해도 DELETE 는 하나다 — 두 번째가 거짓 "이미 보냄"을 띄우지 않는다 (리뷰 #9)', async () => {
const cancel = deferred()
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
})
await wrapper.get('[data-scheduled-cancel]').trigger('click')
// 다른 길(예: 보낸 뒤 알림의 [보내기 취소])로 같은 메일을 한 번 더 취소한다
const second = store.cancelPendingSend('s1')
cancel.resolve(ok({ draftId: 's1' }))
await flushPromises()
await expect(second).resolves.toEqual({ draftId: 's1' })
expect(cancels()).toHaveLength(1)
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['success'])
})
it('이미 보냈으면(409 MAIL_014) 보이는 목록 · 열린 메일 · 예약됨 수를 바로 다시 본다 — 남은 청록 줄이 내려간다 (리뷰 #2)', async () => {
const { wrapper, store } = await mountWith({
message: SCHEDULED,
source: { kind: 'scheduled' },
extra: (request) => {
if (request.method === 'DELETE') return fail(409, 'MAIL_014')
// 엔진이 이미 보냈다 — 예약됨에는 m2 만 남았다
if (request.url === '/scheduled-messages') {
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
if (request.url === '/messages/s1') return ok({ ...SCHEDULED, pendingSend: null })
return undefined
},
})
await wrapper.get('[data-scheduled-cancel]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'GET').map((request) => request.url)).toEqual(
expect.arrayContaining(['/scheduled-messages', '/messages/s1']),
)
expect(store.selected?.pendingSend).toBeNull()
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(store.scheduledCount).toBe(1)
})
})
describe('보내기 취소를 기다리는 메일(pendingSend undo) — 스팸 신고 없음 · 삭제는 확인 뒤 취소하고 휴지통으로 (리뷰 #1)', () => {
const UNDO: MessageDetail = {
...SCHEDULED,
id: 'u1',
subject: '방금 보낸 메일',
pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' },
}
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('도구 줄에 스팸 신고가 없다 — 답장 · 전달은 있고 예약 안내 줄은 없다', async () => {
const { wrapper } = await mountWith({ message: UNDO, source: { kind: 'mailbox', mailboxId: 'sent' } })
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual([
'답장',
'전체 답장',
'전달',
'삭제',
])
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
})
it('[삭제] — 확인 창(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 · "보내지 않고 휴지통으로 옮겼습니다."', async () => {
const { wrapper, router } = await mountWith({
message: UNDO,
source: { kind: 'mailbox', mailboxId: 'sent' },
extra: (request) => (request.method === 'DELETE' ? ok({ draftId: 'u1' }) : undefined),
})
await wrapper.get('[data-reader-delete]').trigger('click')
await flushPromises()
expect(wrapper.get('#reader-scheduled-delete-title').text()).toBe('보내지 않은 메일을 삭제할까요?')
expect(wrapper.get('#reader-scheduled-delete-message').text()).toBe('보내지 않고 휴지통으로 옮깁니다.')
expect(document.activeElement).toBe(wrapper.get('[data-scheduled-delete-cancel]').element)
// 그냥 옮기지 않는다 — 옮기면 엔진은 그 시각에 그대로 보낸다
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
await flushPromises()
expect(cancels().map((request) => request.url)).toEqual(['/scheduled-messages/u1'])
expect(requests.find((request) => request.method === 'PATCH' && request.url === '/messages/u1')?.data).toEqual({
mailboxId: 'trash',
})
expect(toastMessages()).toEqual(['보내지 않고 휴지통으로 옮겼습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m2')
})
it('확인 창을 연 채 엔진이 보냈으면(붙잡은 발송이 풀림) 창을 닫는다 — 다른 확인(영구 삭제 등)으로 바뀌지 않는다', async () => {
const { wrapper, store } = await mountWith({ message: UNDO, source: { kind: 'mailbox', mailboxId: 'sent' } })
await wrapper.get('[data-reader-delete]').trigger('click')
await flushPromises()
expect(wrapper.find('dialog[open]').exists()).toBe(true)
store.selected = { ...UNDO, pendingSend: null }
await flushPromises()
expect(wrapper.find('dialog[open]').exists()).toBe(false)
expect(requests.some((request) => request.method === 'DELETE' || request.method === 'POST')).toBe(false)
})
})
describe('보낸 사람 차단 — ⋯ 「보낸 사람 차단」(빨간 글) · 확인 창 · 스팸메일함의 회색 줄', () => {
async function openBlockDialog(wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) {
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]').at(-1)
expect(item?.text()).toBe('보낸 사람 차단')
expect(item?.classes()).toContain('menu__item--danger')
await item?.trigger('click')
await flushPromises()
return wrapper.get('[data-block-dialog]')
}
it('확인 창 — 「이 주소만」 · 옮기기 켜짐, [차단](처음 초점)이면 막고 그 메일을 스팸메일함으로 · 알림 [되돌리기]는 풀고 받은편지함으로', async () => {
const { wrapper, router, store } = await mountWith()
const dialog = await openBlockDialog(wrapper)
expect(wrapper.get('#block-dialog-title').text()).toBe('보낸 사람 차단')
expect((dialog.get('[data-block-scope="address"]').element as HTMLInputElement).checked).toBe(true)
expect(dialog.text()).toContain('news@cloudbrief.example')
expect(dialog.text()).toContain('@cloudbrief.example 에서 오는 메일 모두')
expect((dialog.get('[data-block-move]').element as HTMLInputElement).checked).toBe(true)
expect(document.activeElement).toBe(wrapper.get('[data-block-confirm]').element)
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
expect(requests.find((request) => request.method === 'POST' && request.url === '/mail-blocks')?.data).toEqual({
address: 'news@cloudbrief.example',
})
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({
ids: ['m1'],
mailboxId: 'junk',
isJunk: true,
})
// 옮겼으니 삭제처럼 다음 메일을 연다
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다.'])
runToastAction()
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/news%40cloudbrief.example')).toBe(true)
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({
ids: ['m1'],
mailboxId: 'inbox',
isJunk: false,
})
expect(toastMessages()).toContain('‘news@cloudbrief.example’ 차단을 풀고 메일을 되돌렸습니다.')
})
it('막는 사이 다른 메일함으로 옮겨 갔으면 옛 목록의 다음 메일로 끌고 가지 않는다 — 옮기기 · 알림은 그대로 (리뷰 #4)', async () => {
const blocking = deferred()
const { wrapper, router, store } = await mountWith({
extra: (request) =>
request.method === 'POST' && request.url === '/mail-blocks' ? (blocking.promise as unknown as MockResult) : undefined,
})
await openBlockDialog(wrapper)
await wrapper.get('[data-block-confirm]').trigger('click')
// 기다리는 사이 보낸편지함의 메일을 연다
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }]
store.selectedId = 'x1'
await router.push('/mail/sent/x1')
blocking.resolve(ok({ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T06:00:00.000Z' }, 201))
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/x1')
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다.'])
})
it('「이 도메인 전체」를 고르고 옮기기를 끄면 @도메인을 막고 메일은 그대로 둔다', async () => {
const { wrapper, router } = await mountWith()
const dialog = await openBlockDialog(wrapper)
await dialog.get('[data-block-scope="domain"]').setValue(true)
await dialog.get('[data-block-move]').setValue(false)
await wrapper.get('[data-block-confirm]').trigger('click')
await flushPromises()
expect(requests.find((request) => request.method === 'POST')?.data).toEqual({ address: '@cloudbrief.example' })
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(toastMessages()).toEqual(['‘@cloudbrief.example’을(를) 차단했습니다.'])
})
it('여러 사람이 쓰는 메일 서비스(gmail.com 등)는 「이 도메인 전체」를 끄고 그 아래에 까닭', async () => {
const { wrapper } = await mountWith({ message: { ...NEWSLETTER, from: { name: null, email: 'spammer@gmail.com' } } })
const dialog = await openBlockDialog(wrapper)
const domain = dialog.get('[data-block-scope="domain"]')
expect((domain.element as HTMLInputElement).disabled).toBe(true)
expect(domain.attributes('aria-describedby')).toBe('block-dialog-domain-note')
expect(wrapper.get('#block-dialog-domain-note').text()).toBe('여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.')
})
it('이미 막은 보낸 사람(도메인으로 막음 포함)이면 「보낸 사람 차단 풀기」 — 바로 풀고 [되돌리기]는 다시 막는다', async () => {
const { wrapper } = await mountWith({ blocks: [{ address: '@cloudbrief.example', blockedAt: '2026-10-21T00:00:00Z' }] })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]').at(-1)
expect(item?.text()).toBe('보낸 사람 차단 풀기')
expect(item?.classes()).not.toContain('menu__item--danger')
await item?.trigger('click')
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/%40cloudbrief.example')).toBe(true)
expect(toastMessages()).toEqual(['‘@cloudbrief.example’ 차단을 풀었습니다.'])
runToastAction()
await flushPromises()
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
{ address: '@cloudbrief.example' },
])
})
it('500개를 다 막았으면 항목을 끄고 까닭을 이름 아래에 — 골라도 창을 열지 않는다', async () => {
const blocks = Array.from({ length: 500 }, (_, index) => ({ address: `s${index}@spam.example`, blockedAt: '2026-10-21T00:00:00Z' }))
const { wrapper } = await mountWith({ blocks })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]').at(-1)
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.get('.menu__note').text()).toBe('차단한 주소는 500개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.')
await item?.trigger('click')
await flushPromises()
expect(wrapper.find('dialog[open] [data-block-dialog]').exists()).toBe(false)
})
it('막을 수 있는 보낸 사람이면 ⋯ 를 열 때 차단 목록을 알림 없이 받아 본다 — 내 메일(예약한 메일)에는 차단 항목이 없다', async () => {
const external = await mountWith()
await external.wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/mail-blocks')).toHaveLength(1)
external.wrapper.unmount()
const own = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
await own.wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(own.wrapper.findAll('[role="menuitem"]').map((item) => item.text())).not.toContain('보낸 사람 차단')
expect(requests.some((request) => request.url === '/mail-blocks')).toBe(false)
})
it('스팸메일함에서 차단한 보낸 사람의 메일 — 회색 줄 "차단한 보낸 사람의 메일입니다." + [차단 풀기]', async () => {
const { wrapper } = await mountWith({
message: { ...NEWSLETTER, mailboxIds: ['junk'] },
blocks: [{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T00:00:00Z' }],
})
const notice = wrapper.get('[data-notice="blockedSender"]')
expect(notice.text()).toContain('차단한 보낸 사람의 메일입니다.')
await notice.get('[data-sender-unblock]').trigger('click')
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-blocks/news%40cloudbrief.example')).toBe(true)
expect(wrapper.find('[data-notice="blockedSender"]').exists()).toBe(false)
expect(toastMessages()).toEqual(['‘news@cloudbrief.example’ 차단을 풀었습니다.'])
})
it('받은편지함의 메일이면 차단했어도 회색 줄이 없다', async () => {
const { wrapper } = await mountWith({ blocks: [{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T00:00:00Z' }] })
expect(wrapper.find('[data-notice="blockedSender"]').exists()).toBe(false)
})
})
})
+77 -72
View File
@@ -3,11 +3,16 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기)
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
@@ -18,14 +23,16 @@ import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
import { isHeldMessage, isScheduledMessage } from '@/shared/utils/pending-send'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -42,8 +49,15 @@ withDefaults(defineProps<Props>(), {
const mailStore = useMailStore()
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
const print = useMailPrint()
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))
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
@@ -54,8 +68,6 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
@@ -68,6 +80,30 @@ function leaveToList() {
const source = mailStore.listSource
void (source ? backToSourceList(source) : replaceWithMailbox(null))
}
/** Ctrl+P(맥 ⌘P) — 열린 메일이 있으면 브라우저 인쇄 대신 인쇄 화면을 연다 */
function onPrintKey(event: KeyboardEvent) {
const current = message.value
if (!current || mailStore.selectedStatus !== 'ready' || !print.isPrintShortcut(event)) return
event.preventDefault()
print.openPrint(current)
}
/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */
function onNoticeSettled() {
const active = document.activeElement
if (active !== null && active !== document.body) return
const frame: unknown = bodyFrameRef.value?.$el
if (frame instanceof HTMLElement) frame.focus()
}
onMounted(() => {
window.addEventListener('keydown', onPrintKey)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onPrintKey)
})
</script>
<template>
@@ -87,34 +123,36 @@ function leaveToList() {
>
<slot name="toolbar-start" />
<template v-if="message">
<MailToolButton
icon="reply"
label="답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
:message="message"
:layout="layout"
/>
<template v-if="!scheduled">
<MailToolButton
icon="reply"
label="답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
/>
<MailToolButton
icon="reply-all"
label="전체 답장"
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
/>
<MailToolButton
icon="forward"
label="전달"
:to="{ name: 'mail-compose', query: { forward: message.id } }"
/>
<MailToolbarDivider v-if="layout === 'split'" />
<MessageSpamButton
v-if="!held"
:message="message"
:layout="layout"
/>
</template>
<MessageDeleteButton
:message="message"
:layout="layout"
/>
<MessageMoreMenu
:message-id="message.id"
:sender-email="message.from?.email ?? null"
:sender-name="message.from?.name ?? null"
:message="message"
:layout="layout"
/>
</template>
</div>
@@ -211,38 +249,20 @@ function leaveToList() {
</div>
</div>
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="reader__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="reader__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<p
v-if="message.hasBlockedImages"
class="reader__notice"
data-notice="blockedImages"
>
<AppIcon
name="shield"
:size="15"
class="reader__notice-icon"
/>
<span>외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.</span>
</p>
<!-- 안내 줄 — 예약 · 차단한 보낸 사람 · 본문이 다 보이는지(생략 → 잘림) · 외부 이미지 차단 [이미지 보기] -->
<MessageReaderNotices
:message="message"
:layout="layout"
@settled="onNoticeSettled"
/>
<MailBodyFrame
ref="bodyFrameRef"
:html-body="message.htmlBody"
:text-body="message.textBody"
:images-shown="message.imagesShown === true"
:title="`메일 본문: ${subject}`"
@frame-keydown="onPrintKey"
/>
<MessageAttachments
@@ -413,21 +433,6 @@ function leaveToList() {
color: var(--color-text-muted);
}
.reader__notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
}
.reader__notice-icon {
margin-top: var(--space-2);
}
/* 툴바가 없을 때(분할 보기)는 툴바 높이만큼 비워 두어 불러온 뒤 화면이 튀지 않게 한다 */
.reader__loading {
@@ -0,0 +1,266 @@
<script setup lang="ts">
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
import { useSenderBlock } from '@/composables/useSenderBlock'
import { MAIL_IMAGES_TEXT } from '@/shared/constants/mail'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MessageDetail } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { messagePendingSend } from '@/shared/utils/pending-send'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
message: MessageDetail
/** split: 예약을 취소하면 다음 메일을 연다 · list: 목록으로 */
layout: 'split' | 'list'
}
interface Emits {
/** 누른 단추가 줄과 함께 사라졌다 — 초점을 옮겨 달라 */
(e: 'settled'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const mailStore = useMailStore()
const blockStore = useMailBlockStore()
const scheduledActions = useScheduledMessageActions()
const senderBlock = useSenderBlock()
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
const pending = computed(() => messagePendingSend(props.message))
const scheduledTime = computed(() =>
pending.value?.kind === 'scheduled' ? formatScheduleTime(pending.value.sendAt) : null,
)
/** 스팸메일함에 든 메일의 보낸 사람이 차단되어 있으면 그 차단 */
const senderBlockEntry = computed(() => {
const junkId = mailStore.mailboxByRole('junk')?.id
if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
return blockStore.blockOf(props.message.from?.email)
})
const bodyNotices = computed(() => mailBodyNotices(props.message))
const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
async function showImages() {
if (imagesLoading.value) return
if (await mailStore.showImages()) emit('settled')
}
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
async function runScheduled(kind: '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)
} finally {
working.value = null
}
if (done) emit('settled')
}
async function unblock() {
const entry = senderBlockEntry.value
if (!entry || working.value) return
working.value = 'unblock'
try {
if (await senderBlock.unblock(entry.address)) emit('settled')
} finally {
working.value = null
}
}
</script>
<template>
<div
v-if="scheduledTime"
class="reader__notice reader__notice--brand"
data-notice="scheduled"
>
<AppIcon
name="clock"
:size="15"
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>
</div>
<div
v-if="senderBlockEntry"
class="reader__notice reader__notice--muted"
data-notice="blockedSender"
>
<AppIcon
name="block"
:size="15"
class="reader__notice-icon"
/>
<span class="reader__notice-text">{{ MAIL_BLOCK_TEXT.senderNotice }}</span>
<button
type="button"
class="reader__notice-action"
:aria-disabled="working ? 'true' : undefined"
:aria-busy="working === 'unblock' ? 'true' : undefined"
data-sender-unblock
@click="unblock"
>
{{ MAIL_BLOCK_TEXT.unblock }}
</button>
</div>
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="reader__notice reader__notice--plain"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="reader__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<div
v-if="message.hasBlockedImages && !message.imagesShown"
class="reader__notice"
data-notice="blockedImages"
>
<AppIcon
name="shield"
:size="15"
class="reader__notice-icon"
/>
<span class="reader__notice-text">{{ MAIL_IMAGES_TEXT.blocked }}</span>
<button
type="button"
class="reader__notice-action"
:aria-disabled="imagesLoading ? 'true' : undefined"
:aria-busy="imagesLoading ? 'true' : undefined"
data-show-images
@click="showImages"
>
{{ MAIL_IMAGES_TEXT.show }}
</button>
</div>
</template>
<style scoped>
/* 노란 안내 줄 (디자인 원본 MailReadTools — 바탕 #FFF7E6 · 글 #7A5200 · 12.5px · 위아래 8 · 왼쪽 12 · 오른쪽 8) */
.reader__notice {
display: flex;
align-items: center;
gap: var(--space-10);
margin: 0;
padding: var(--space-8) var(--space-8) var(--space-8) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
--reader-notice-button-border: var(--color-warning-border);
--reader-notice-button-hover: var(--color-warning-hover);
}
/* 단추 없는 본문 안내 — 지금까지의 모양 그대로 (위아래 10 · 좌우 12, 아이콘은 첫 줄에) */
.reader__notice--plain {
align-items: flex-start;
gap: var(--space-8);
padding: var(--space-10) var(--space-12);
}
.reader__notice--plain .reader__notice-icon {
margin-top: var(--space-2);
}
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
.reader__notice--brand {
gap: var(--space-8);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
--reader-notice-button-border: var(--color-on-brand-link);
--reader-notice-button-hover: var(--color-brand-tint);
}
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
.reader__notice--muted {
background: var(--color-surface-muted);
color: var(--color-text-strong);
--reader-notice-button-border: var(--color-border-strong);
--reader-notice-button-hover: var(--color-surface-panel);
}
.reader__notice-icon {
flex: 0 0 auto;
}
.reader__notice-text {
flex: 1 1 auto;
min-width: 0;
}
.reader__notice-text--strong {
font-weight: 500;
}
/* 줄 안의 흰 단추 36 — 밀도 예외 (--size-notice-action) */
.reader__notice-action {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--reader-notice-button-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: inherit;
font-weight: 600;
}
.reader__notice-action:hover:not([aria-disabled='true']) {
background: var(--reader-notice-button-hover);
}
.reader__notice-action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -30,11 +30,12 @@ const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox'
async function onClick() {
if (working.value) return
const target = props.message
const source = mailStore.listSource
working.value = true
try {
const outcome = await removal.moveSpam(props.message)
await removal.leaveRemoved(outcome, source, props.layout)
const outcome = await removal.moveSpam(target)
await removal.leaveRemoved(outcome, source, props.layout, target.id)
} finally {
working.value = false
}
@@ -258,6 +258,41 @@ describe('MessageTable (목록 보기)', () => {
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
})
it('아직 보내지 않은 메일(보내는 중 · 예약 — 9단계)은 고를 수 없다 — 꺼진 상자(aria-disabled · 까닭 title), 누름 · Ctrl · Shift 도 · 머리 상자도 건너뛴다 (리뷰 #1)', async () => {
const held = [
summary('m1', { mailboxIds: ['sent'] }),
summary('u1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } }),
summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' } }),
summary('m2', { mailboxIds: ['sent'] }),
]
const { wrapper, router, store } = await mountTable({ mailbox: SENT, messages: held })
const boxes = () => wrapper.findAll('tbody [role="checkbox"]')
const rows = () => wrapper.findAll('tbody tr')
expect(boxes().map((box) => box.attributes('aria-disabled') ?? null)).toEqual([null, 'true', 'true', null])
expect(boxes()[1]?.attributes('title')).toBe(
'보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
)
expect(boxes()[0]?.attributes('title')).toBeUndefined()
await boxes()[1]?.trigger('click')
await rows()[2]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
await flushPromises()
expect(store.checkedCount).toBe(0)
// 수정 글쇠 누름은 메일도 열지 않는다
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
// 범위 · 모두 고르기는 붙잡은 메일을 건너뛴다
await boxes()[0]?.trigger('click')
await rows()[3]?.get('.mail-table__who').trigger('click', { shiftKey: true })
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
expect(wrapper.get('thead [role="checkbox"]').attributes('aria-checked')).toBe('true')
await wrapper.get('thead [role="checkbox"]').trigger('click')
expect(store.checkedCount).toBe(0)
await wrapper.get('thead [role="checkbox"]').trigger('click')
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
})
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
const { wrapper, store } = await mountTable({ messages: three() })
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
@@ -3,6 +3,8 @@
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
@@ -10,9 +12,11 @@ import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
@@ -43,6 +47,10 @@ const who = computed(() => rowWho(props.message, { role: props.role, mixed: prop
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.message))
/** 아직 보내지 않은 메일 — 고를 수 없다 */
const held = computed(() => isHeldMessage(props.message))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent) {
@@ -50,7 +58,7 @@ function onRowClick(event: MouseEvent) {
if (target?.closest('button')) return
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
emit('check', props.message.id, event.shiftKey)
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
@@ -77,6 +85,8 @@ function onMouseDown(event: MouseEvent) {
class="mail-table__check-button"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
/>
</td>
@@ -127,8 +137,13 @@ function onMouseDown(event: MouseEvent) {
/>
</div>
</td>
<td class="mail-table__date">
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
<td
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
:title="pendingLabel ?? undefined"
>
<time :datetime="message.pendingSend?.sendAt ?? message.receivedAt">{{
pendingLabel ?? formatMailTableDate(message.receivedAt)
}}</time>
</td>
<td class="mail-table__size">
{{ formatFileSize(message.size) }}
@@ -239,4 +254,10 @@ function onMouseDown(event: MouseEvent) {
font-weight: 600;
color: var(--color-text);
}
/* 아직 보내지 않은 내 메일 — 청록 굵게 (디자인 원본 MailSendLater) */
.mail-table__row .mail-table__date.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,167 @@
<script setup lang="ts">
// 예약됨 목록의 한 행 (디자인 원본 MailSendLater "예약됨 목록") — 누르면 읽기 화면(/mail/scheduled/<id>)에서 연다
// - 분할 보기(split): 2줄 — 받는 사람(“받는 사람: 이도윤 외 2명”) · 보낼 시각(청록 굵게) / 제목 + 첨부 클립
// - 목록 보기(list): 한 줄 세 칸 — 받는 사람 150 · 제목 · 보낼 시각 150(오른쪽 맞춤), 행 52
// 보낼 시각은 응답의 pendingSend.sendAt — 없으면(보낸 직후 등) 받은 시각으로 대신한다. 고르는 칸 · 별은 없다
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatMailListTime, formatScheduleTime } from '@/shared/utils/format'
import { rowWho } from '@/shared/utils/mail-list-row'
import { messagePendingSend } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
variant: 'split' | 'list'
to: RouteLocationRaw
/** 지금 열려 있는 메일 */
selected?: boolean
}
const props = withDefaults(defineProps<Props>(), {
selected: false,
})
/** 받는 사람 — 보낸편지함과 같은 규칙에 "받는 사람: " 을 붙인다 (보드의 두 보기 모두) */
const who = computed(() => rowWho(props.message, { role: 'sent', mixed: false, view: 'split' }))
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const sendAt = computed(() => messagePendingSend(props.message)?.sendAt ?? null)
const time = computed(() =>
sendAt.value ? formatScheduleTime(sendAt.value) : formatMailListTime(props.message.receivedAt),
)
</script>
<template>
<li :class="['scheduled-row', `scheduled-row--${variant}`, { 'is-selected': selected }]">
<RouterLink
class="scheduled-row__link"
:to="to"
:data-message-id="message.id"
:aria-current="selected ? 'true' : undefined"
>
<span class="scheduled-row__who">{{ who.text }}</span>
<span class="scheduled-row__subject">
<span class="scheduled-row__subject-text">{{ subject }}</span>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
label="첨부 있음"
class="scheduled-row__clip"
/>
</span>
<time
class="scheduled-row__time"
:datetime="sendAt ?? message.receivedAt"
>{{ time }}</time>
</RouterLink>
</li>
</template>
<style scoped>
.scheduled-row {
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.scheduled-row:hover {
background: var(--color-surface-panel);
}
.scheduled-row.is-selected {
background: var(--color-brand-subtle);
}
.scheduled-row__link {
color: inherit;
text-decoration: none;
}
.scheduled-row__link:hover {
color: inherit;
}
.scheduled-row__link:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.scheduled-row__who,
.scheduled-row__subject-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scheduled-row__who {
color: var(--color-text-strong);
}
.scheduled-row__subject {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
font-weight: 500;
color: var(--color-text);
}
.scheduled-row__clip {
color: var(--color-text-muted);
}
/* 보낼 시각 — 청록 굵게 (디자인 원본 MailSendLater #0A5F67 · 600) */
.scheduled-row__time {
flex: 0 0 auto;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 분할 보기 — 2줄 행 50: 받는 사람 · 시각 / 제목 (분할 보기 메일 행과 같은 여백) */
.scheduled-row--split .scheduled-row__link {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'who time'
'subject subject';
align-content: center;
gap: var(--space-1) var(--space-6);
min-height: var(--size-list-row);
padding: var(--space-6) var(--space-16);
line-height: var(--line-height-heading);
}
.scheduled-row--split .scheduled-row__who {
grid-area: who;
}
.scheduled-row--split .scheduled-row__time {
grid-area: time;
font-size: var(--font-size-small);
}
.scheduled-row--split .scheduled-row__subject {
grid-area: subject;
}
/* 목록 보기 — 한 줄 세 칸 (디자인 원본 MailSendLater: 150 · 나머지 · 150, 사이 12, 행 52, 좌우 20) */
.scheduled-row--list .scheduled-row__link {
display: grid;
grid-template-columns: 9.375rem minmax(0, 1fr) 9.375rem;
align-items: center;
gap: var(--table-gap);
min-height: var(--size-header-min);
padding: 0 var(--space-20);
}
.scheduled-row--list .scheduled-row__time {
text-align: right;
}
</style>
@@ -0,0 +1,57 @@
<script setup lang="ts">
// 예약됨 목록 (디자인 원본 MailSendLater "예약됨 목록" · README "9단계 › 「예약됨」") — /mail/scheduled
// 받는 사람 · 제목 · 보낼 시각(청록 — "10월 30일 (금) 오전 9:00") 차례로, 보낼 시각이 빠른 것부터(서버 차례 그대로).
// 고르는 칸 · 여러 통 처리 · 중요 표시는 없다 — 한 통씩 연다. 보낸 메일은 저절로 빠지고 보낸편지함에 남는다(새 메일 확인 · 실시간 알림이 맞춘다).
// 분할 보기는 2줄 행(받는 사람 · 시각 / 제목), 목록 보기는 한 줄 세 칸(받는 사람 150 · 제목 · 보낼 시각 150 — 행 52).
// 머리 · 상태(불러오는 중 · 못 불러옴 · 비었음) · 메일 더 보기 · 표 위치 기억은 목록 틀(MailListFrame)이 맡는다.
import MailListFrame from '@/components/mail/MailListFrame.vue'
import ScheduledMessageItem from '@/components/mail/ScheduledMessageItem.vue'
import { messageLocationOf, SCHEDULED_LIST_SOURCE } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
interface Props {
variant: 'split' | 'list'
}
defineProps<Props>()
const mailStore = useMailStore()
</script>
<template>
<MailListFrame
:class="['scheduled-list', `scheduled-list--${variant}`]"
:variant="variant"
title-id="scheduled-list-title"
>
<ul class="scheduled-list__items">
<ScheduledMessageItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:variant="variant"
:to="messageLocationOf(SCHEDULED_LIST_SOURCE, message.id)"
:selected="message.id === mailStore.selectedId"
/>
</ul>
</MailListFrame>
</template>
<style scoped>
/* 분할 보기 — 목록 칸 (MessageList 와 같은 폭 · 오른쪽 선) */
.scheduled-list--split {
flex: 1 1 24rem;
border-right: var(--border-width) solid var(--color-border);
}
/* 목록 보기 — 메일함 패널 오른쪽 전체 폭 (MessageTable 과 같다) */
.scheduled-list--list {
flex: 1 1 auto;
}
.scheduled-list__items {
margin: 0;
padding: 0;
list-style: none;
}
</style>
@@ -2,6 +2,7 @@
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
// 제목 바로 옆(같은 줄 · 글자 아래 선)에 작은 글을 둘 때는 title-aside 슬롯 (디자인 원본 SettingsSendRead 의 "3 / 500")
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
@@ -22,12 +23,15 @@ defineProps<Props>()
<div class="settings-page__column">
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<div class="settings-page__title-row">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<slot name="title-aside" />
</div>
<p class="settings-page__description">
{{ description }}
</p>
@@ -80,6 +84,13 @@ defineProps<Props>()
gap: var(--space-4);
}
.settings-page__title-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-4) var(--space-10);
}
.settings-page__title {
margin: 0;
font-size: var(--font-size-title-lg);
+78
View File
@@ -43,11 +43,89 @@ describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
id: 'sent-1',
rejectedRecipients,
pendingSend: null,
})
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
})
})
describe('useMail.sendMessage — 붙잡아 둔 발송 pendingSend 는 늘 값 또는 null (계약 2절 "보내기 취소 · 예약 발송" — 9단계)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
const UNDO = { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' }
const SCHEDULED = { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' }
it.each([
['보내기 취소를 기다리는 중이면 그대로', UNDO, UNDO],
['예약이면 그대로', SCHEDULED, SCHEDULED],
['바로 보냈으면(null) null', null, null],
['필드가 없는 옛 서버 응답은 null', undefined, null],
['모르는 종류는 null — 보내기 취소 단추를 띄우지 않는다', { kind: 'later', sendAt: SCHEDULED.sendAt }, null],
['시각으로 읽을 수 없으면 null', { kind: 'undo', sendAt: '곧' }, null],
])('%s', async (_label, pendingSend, expected) => {
installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], ...(pendingSend === undefined ? {} : { pendingSend }) }, 201))
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
id: 'sent-1',
rejectedRecipients: [],
pendingSend: expected,
})
})
it('예약 발송은 본문의 sendAt(UTC ISO)을 그대로 싣는다', async () => {
const requests = installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: SCHEDULED }, 201))
await useMail().sendMessage({ ...PAYLOAD, sendAt: SCHEDULED.sendAt }, { idempotencyKey: 'key-1' })
expect(requests[0]?.data).toMatchObject({ sendAt: SCHEDULED.sendAt })
})
})
describe('useMail 예약됨 · 보내기 취소 · 외부 이미지 (9단계)', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('예약한 메일 목록은 GET /scheduled-messages 에 cursor · limit 을 싣는다', async () => {
const requests = installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
await useMail().listScheduledMessages({ limit: 1 })
await useMail().listScheduledMessages({ cursor: 'c2', limit: 20 })
expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
['GET', '/scheduled-messages', { limit: 1 }],
['GET', '/scheduled-messages', { cursor: 'c2', limit: 20 }],
])
})
it('붙잡은 발송 취소는 DELETE /scheduled-messages/{id} — id 는 인코딩하고 { draftId } 를 돌려준다', async () => {
const requests = installMockApi(() => ok({ draftId: 'a/b' }))
await expect(useMail().cancelScheduledMessage('a/b')).resolves.toEqual({ draftId: 'a/b' })
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['DELETE /scheduled-messages/a%2Fb'])
})
it('외부 이미지 보기는 GET /messages/{id}?images=show — 주지 않으면 쿼리 없이 (서버가 설정을 따른다)', async () => {
const requests = installMockApi(() => ok({ id: 'm1' }))
await useMail().getMessage('m1', { showImages: true, silent: true })
await useMail().getMessage('m1')
expect(requests.map((request) => request.params)).toEqual([{ images: 'show' }, {}])
expect(requests[0]?.config.silent).toBe(true)
})
})
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
const SET: MailFilterSet = {
rules: [
+58 -7
View File
@@ -4,6 +4,8 @@ import { buildApiUrl, confirmSession, useApi } from './useApi'
import {
MAIL_FILTER_RUN_TIMEOUT_MS,
MAIL_IDEMPOTENCY_HEADER,
MAIL_IMAGES_PARAM,
MAIL_IMAGES_SHOW,
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
@@ -37,16 +39,23 @@ import type {
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
ScheduledMessageCancel,
SendMessagePayload,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
export interface GetMessageOptions extends MailRequestOptions {
showImages?: boolean
}
export interface SendMessageOptions extends MailRequestOptions {
/**
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
@@ -55,8 +64,14 @@ export interface SendMessageOptions extends MailRequestOptions {
idempotencyKey: string
}
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
/**
* POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients · pendingSend 가 없다
* (sendMessage 가 배열 · PendingSend 또는 null 로 맞춘다)
*/
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'> & {
rejectedRecipients?: unknown
pendingSend?: unknown
}
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
@@ -221,9 +236,17 @@ export function useMail() {
})
}
/** 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다 */
function getMessage(id: string, options?: MailRequestOptions): Promise<MessageDetail> {
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
/**
* 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
* showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
*/
function getMessage(id: string, options: GetMessageOptions = {}): Promise<MessageDetail> {
const { showImages, ...requestOptions } = options
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, {
...requestOptions,
...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
})
}
function updateMessage(
@@ -330,7 +353,33 @@ export function useMail() {
...requestOptions,
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
})
return { ...sent, rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [] }
return {
...sent,
rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [],
// 엔진이 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계) — 없거나 모양이 틀리면 바로 보낸 것으로 본다
pendingSend: pendingSendOf(sent.pendingSend),
}
}
// ── 보내기 취소 · 예약 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) ─────────────
/** 예약한 메일 — 보낼 시각 차례, total 이 있다. 메일함 패널의 「예약됨」은 limit=1 로 total 만 쓴다 */
function listScheduledMessages(
query: CursorPaginationQuery,
options?: MailRequestOptions,
): Promise<PaginatedResult<MessageSummary>> {
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/scheduled-messages', {
...options,
params: query,
})
}
/**
* 붙잡은 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id. 엔진에서 취소한 뒤 그 메일을 임시보관함으로 옮긴다
* → { draftId }(같은 id — 쓰기 화면이 이어 쓴다). 이미 보냈으면 409 MAIL_014 (아무것도 바꾸지 않는다), 없는 메일은 404 RES_001
*/
function cancelScheduledMessage(id: string, options?: MailRequestOptions): Promise<ScheduledMessageCancel> {
return api.delete<ScheduledMessageCancel, ScheduledMessageCancel>(`/scheduled-messages/${segment(id)}`, options)
}
/**
@@ -408,6 +457,8 @@ export function useMail() {
updateMessages,
deleteMessages,
sendMessage,
listScheduledMessages,
cancelScheduledMessage,
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
+38
View File
@@ -0,0 +1,38 @@
import { useRouter, type RouteLocationRaw } from 'vue-router'
import { MAIL_IMAGES_PARAM, MAIL_IMAGES_SHOW } from '@/shared/constants/mail'
import { MAIL_PRINT_ROUTE, MAIL_PRINT_SHORTCUT_KEYS } from '@/shared/constants/mail-print'
import type { MessageDetail } from '@/shared/types/mail'
import { currentPlatform, formatShortcut, isApplePlatform, isShortcutEvent } from '@/shared/utils/keyboard-shortcut'
/**
* 인쇄 — 읽기 화면 ⋯ › 인쇄 · Ctrl+P(맥 ⌘P) (디자인 README "9단계 › 인쇄 화면" · 계약 2절 "인쇄").
* 새 탭(noopener — 새 탭이 이 창을 건드리지 못하게)에 인쇄 화면(/mail/print/<id> — 앱 틀 없음)을 연다. 인쇄 화면이 본문을 인쇄 폭에 맞춰 잰 뒤
* 브라우저의 인쇄 창을 띄운다(MailPrintPage). 읽기 화면에서 외부 이미지를 보이고 있었으면 ?images=show — 인쇄에도 그대로 보인다
*/
export function useMailPrint() {
const router = useRouter()
const apple = isApplePlatform(currentPlatform())
/** 메뉴에 보이는 단축키 — "Ctrl+P" · "⌘P" */
const shortcut = formatShortcut(MAIL_PRINT_SHORTCUT_KEYS, apple)
function printLocation(message: Pick<MessageDetail, 'id' | 'imagesShown'>): RouteLocationRaw {
return {
name: MAIL_PRINT_ROUTE,
params: { messageId: message.id },
query: message.imagesShown ? { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } : {},
}
}
function openPrint(message: Pick<MessageDetail, 'id' | 'imagesShown'>): void {
window.open(router.resolve(printLocation(message)).href, '_blank', 'noopener')
}
/** 인쇄 단축키인가 — 한글 조합 중 키 · 이미 처리한 키는 아니다 */
function isPrintShortcut(event: KeyboardEvent): boolean {
return !event.defaultPrevented && !event.isComposing && isShortcutEvent(event, MAIL_PRINT_SHORTCUT_KEYS, apple)
}
return { shortcut, printLocation, openPrint, isPrintShortcut }
}
@@ -0,0 +1,47 @@
import type { AxiosRequestConfig } from 'axios'
import { useApi } from './useApi'
import type { BlockedSender, MailBlockList, MailSettings } from '@/shared/types/mail'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
const segment = (value: string) => encodeURIComponent(value)
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
export function useMailSettings() {
const api = useApi()
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.get<MailSettings, MailSettings>('/mail-settings', options)
}
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
}
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
function getBlocks(options?: MailSettingsRequestOptions): Promise<MailBlockList> {
return api.get<MailBlockList, MailBlockList>('/mail-blocks', options)
}
/**
* 차단 — 주소 또는 "@도메인". 201(이미 있으면 200) BlockedSender. 형식 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 ·
* 500개 초과는 400 VAL_001, 엔진 규칙 한도는 400 MAIL_013
*/
function addBlock(address: string, options?: MailSettingsRequestOptions): Promise<BlockedSender> {
return api.post<BlockedSender, BlockedSender>('/mail-blocks', { address }, options)
}
/** 차단 풀기 — 없으면 404 RES_001 */
function removeBlock(address: string, options?: MailSettingsRequestOptions): Promise<{ address: string }> {
return api.delete<{ address: string }, { address: string }>(`/mail-blocks/${segment(address)}`, options)
}
return { getSettings, updateSettings, getBlocks, addBlock, removeBlock }
}
+16 -4
View File
@@ -4,7 +4,7 @@ import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import { useMailNavigation } from './useMailNavigation'
import type { MessageSummary } from '@/shared/types/mail'
import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { listLocationOf, messageLocationOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
@@ -94,13 +94,25 @@ export function useMessageRemoval() {
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
/**
* 그 메일을 아직 그 목록에서 보고 있는가 — 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 아니다
* (그때 옛 목록의 다음 메일로 바꿔 끼우면 사용자가 연 화면을 빼앗는다 — 2026-10-29 리뷰)
*/
function stillShowing(source: MailListSource | null, messageId: string): boolean {
return sameListSource(mailStore.listSource, source) && mailStore.selectedId === messageId
}
/**
* 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록, messageId 는 뺀 메일.
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 움직이지 않는다 (stillShowing)
*/
async function leaveRemoved(
outcome: MessageRemovalOutcome,
source: MailListSource | null,
layout: 'split' | 'list',
messageId: string,
): Promise<void> {
if (!outcome.removed || !source) return
if (!outcome.removed || !source || !stillShowing(source, messageId)) return
if (layout === 'list') {
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToSourceList(source)
@@ -109,5 +121,5 @@ export function useMessageRemoval() {
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
}
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, stillShowing, leaveRemoved }
}
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope, type EffectScope } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import type { MessageSummary, PendingSend } 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 } from '@/test-utils/mock-api'
import { usePendingSendRefresh } from './usePendingSendRefresh'
// 엔진이 붙잡은 발송을 풀 때(보낼 시각) 다시 보기 (계약 2절 "보내기 취소 · 예약 발송" · 2026-10-29 리뷰 #2)
// 새 메일 확인 · 실시간 알림의 state 는 엔진이 풀어도 바뀌지 않는다 — 보낼 시각 + 2초에 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다.
// 손으로 돌리는 시계로 시간을 넘긴다 (진짜 타이머 · 고정 대기 없이)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
function summary(id: string, pendingSend: PendingSend | null): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-29T05:59:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend,
}
}
const at = (ms: number) => new Date(NOW + ms).toISOString()
const page = (items: MessageSummary[]) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', () => {
let clock: ManualClock
let scope: EffectScope
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
scope = effectScope()
})
afterEach(() => {
scope.stop()
restoreApiAdapter()
useToastStore().clear()
})
/** 보낸편지함 목록에 붙잡은 발송 하나를 두고 시작한다 — 서버는 sent() 가 주는 목록을 돌려준다 */
function start(initial: MessageSummary[], sent: () => MessageSummary[] = () => initial) {
requests = installMockApi((request) => {
if (request.url === '/scheduled-messages') return page([])
if (request.url === '/messages') return page(sent())
return ok({})
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = initial
store.pageInfo = { hasNext: false, nextCursor: null, total: initial.length }
store.listStatus = 'ready'
scope.run(() => usePendingSendRefresh({ clock }))
return store
}
const listRequests = () => requests.filter((request) => request.url === '/messages')
const countRequests = () => requests.filter((request) => request.url === '/scheduled-messages')
async function advance(ms: number) {
clock.advance(ms)
await flushPromises()
}
it('보낼 시각 + 2초에 한 번 — 보이는 목록 · 예약됨 수를 알리지 않고 다시 보고, 풀렸으면 "보내는 중"이 내려간다', async () => {
const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
const store = start([held], () => [summary('u1', null)])
await advance(11_999)
expect(listRequests()).toEqual([])
await advance(1)
expect(listRequests()).toHaveLength(1)
expect(listRequests()[0]?.config.silent).toBe(true)
expect(countRequests()).toHaveLength(1)
expect(store.messages[0]?.pendingSend).toBeNull()
// 지켜볼 것이 없다 — 더 걸지 않는다
expect(clock.pendingCount()).toBe(0)
expect(useToastStore().toasts).toEqual([])
})
it('다시 본 뒤에도 시각이 지난 채 남아 있으면 5초마다 세 번 더 보고, 그래도 남으면 멈춘다', async () => {
const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
start([held])
await advance(12_000)
expect(listRequests()).toHaveLength(1)
for (let recheck = 1; recheck <= 3; recheck += 1) {
await advance(4_999)
expect(listRequests()).toHaveLength(recheck)
await advance(1)
expect(listRequests()).toHaveLength(recheck + 1)
}
await advance(60_000)
expect(listRequests()).toHaveLength(4)
expect(clock.pendingCount()).toBe(0)
})
it('처음부터 시각이 지나 보이는 발송(이 PC 시계가 빠름)은 바로 묻지 않고 5초 뒤부터 다시 본다', async () => {
start([summary('u1', { kind: 'undo', sendAt: at(-60_000) })], () => [summary('u1', null)])
await advance(4_999)
expect(listRequests()).toEqual([])
await advance(1)
expect(listRequests()).toHaveLength(1)
})
it('가장 이른 시각부터 차례로 — 앞 것이 풀리면 다음 것의 시각에 다시 잡는다', async () => {
let released = new Set<string>()
const items = [summary('u1', { kind: 'undo', sendAt: at(5_000) }), summary('s1', { kind: 'scheduled', sendAt: at(60_000) })]
start(items, () => items.map((item) => (released.has(item.id) ? { ...item, pendingSend: null } : item)))
released = new Set(['u1'])
await advance(7_000)
expect(listRequests()).toHaveLength(1)
released = new Set(['u1', 's1'])
await advance(54_999)
expect(listRequests()).toHaveLength(1)
await advance(1)
expect(listRequests()).toHaveLength(2)
expect(clock.pendingCount()).toBe(0)
})
it('목록 밖의 메일도 — 열린 메일 · 가장 이른 예약(예약됨 수의 첫 줄)의 시각도 지켜본다', async () => {
const store = start([])
store.nextScheduled = summary('s9', { kind: 'scheduled', sendAt: at(30_000) })
await flushPromises()
await advance(32_000)
expect(countRequests()).toHaveLength(1)
})
it('메일 화면이 사라지면 멈춘다', async () => {
start([summary('u1', { kind: 'undo', sendAt: at(10_000) })])
expect(clock.pendingCount()).toBe(1)
scope.stop()
await advance(20_000)
expect(listRequests()).toEqual([])
expect(clock.pendingCount()).toBe(0)
})
})
@@ -0,0 +1,110 @@
import { computed, onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import {
PENDING_RELEASE_GRACE_MS,
PENDING_RELEASE_MAX_RECHECKS,
PENDING_RELEASE_RECHECK_MS,
} from '@/shared/constants/mail-send-later'
import {
nextPendingCheckAt,
overduePendingWatches,
pendingWatchesOf,
type PendingWatch,
} from '@/shared/utils/pending-send'
import { useMailStore } from '@/stores/mail.store'
// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
export interface PendingSendRefreshOptions {
clock?: Clock
}
export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const mailStore = useMailStore()
const clock = options.clock ?? systemClock
let timer: ClockTimer | null = null
let refreshing = false
let stopped = false
/** 다시 본 뒤에도 시각이 지난 채 남은 차례 (연달아) — 넘으면 그때 남은 발송은 그만 본다 */
let overdueStreak = 0
/** 그만 보는 발송 (PendingWatch.key) — 지켜볼 것에서 사라지면 잊는다 */
const givenUp = new Set<string>()
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
)
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
function activeWatches(): PendingWatch[] {
const current = watches.value
const keys = new Set(current.map((watch) => watch.key))
for (const key of givenUp) if (!keys.has(key)) givenUp.delete(key)
return current.filter((watch) => !givenUp.has(watch.key))
}
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
/** 다음에 다시 볼 때를 잡는다 — 다시 보는 중이면 끝난 뒤에 잡는다 */
function plan() {
cancelTimer()
if (stopped || refreshing) return
const now = clock.now()
const at = nextPendingCheckAt(activeWatches(), now, {
graceMs: PENDING_RELEASE_GRACE_MS,
recheckMs: PENDING_RELEASE_RECHECK_MS,
})
if (at === null) return
timer = clock.setTimeout(
() => {
timer = null
void refresh()
},
Math.max(0, at - now),
)
}
async function refresh(): Promise<void> {
refreshing = true
try {
await mailStore.refreshPendingSends()
} finally {
refreshing = false
}
if (stopped) return
const overdue = overduePendingWatches(activeWatches(), clock.now(), PENDING_RELEASE_GRACE_MS)
if (overdue.length === 0) {
overdueStreak = 0
} else {
overdueStreak += 1
if (overdueStreak > PENDING_RELEASE_MAX_RECHECKS) {
for (const watch of overdue) givenUp.add(watch.key)
overdueStreak = 0
}
}
plan()
}
// 지켜볼 것이 바뀌면(새로 보낸 메일 · 다른 목록 · 다른 메일 · 다시 받은 예약됨 수) 다시 잡는다 — 같은 것이면 그대로 둔다
watch(
() => watches.value.map((watch) => watch.key).join(' '),
() => plan(),
{ immediate: true },
)
onScopeDispose(() => {
stopped = true
cancelTimer()
})
}
@@ -0,0 +1,68 @@
import { ref, watch, type Ref } from 'vue'
import { opensUpward } from '@/shared/utils/popup-placement'
// 펼침 메뉴를 아래로 열지 위로 열지 — 메뉴를 그린 직후(초점을 주기 전) 아래로 그린 메뉴를 재어, 보이는 영역 아래가 모자라면 위로 연다
// (판단은 순수 함수 popup-placement.ts 의 opensUpward, 여기서는 재기만 한다).
// 쓰는 곳: 메일함 · 주소록 그룹 줄의 ⋯ (MailboxMoreButton — 스크롤되는 패널 안), 쓰기 화면의 [보내기 ▾] (ComposeFooter — 화면 맨 아래 단추 줄.
// 아래로 열면 메뉴가 화면 밖에 생기고 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 메뉴는 EditorPopup 이 연 뒤 다음 틱에 초점을 준다 — 그 전에 위아래가 정해진다 (같은 그리기 차례 안의 post 감시)
/** 잘라 내는 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
for (let node = element.parentElement; node; node = node.parentElement) {
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
const rect = node.getBoundingClientRect()
top = Math.max(top, rect.top)
bottom = Math.min(bottom, rect.bottom)
}
return { top, bottom }
}
export interface PopupPlacementOptions {
/** 메뉴가 열려 있다 */
open: Ref<boolean>
/** 연 단추 */
trigger: () => HTMLElement | null
/** 그린 메뉴 (열린 동안) */
popup: () => HTMLElement | null
}
/** upward — 위로 연다 (쓰는 쪽이 메뉴에 위로 여는 class 를 붙인다). 닫으면 다음에 열 때 다시 아래로 재기 시작한다 */
export function usePopupPlacement(options: PopupPlacementOptions) {
const upward = ref<boolean>(false)
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
function place() {
const button = options.trigger()
const menu = options.popup()
if (!button || !menu) return
const buttonRect = button.getBoundingClientRect()
const menuRect = menu.getBoundingClientRect()
const area = visibleArea(button)
upward.value = opensUpward({
triggerTop: buttonRect.top,
triggerBottom: buttonRect.bottom,
popupHeight: menuRect.height,
areaTop: area.top,
areaBottom: area.bottom,
gap: Math.max(0, menuRect.top - buttonRect.bottom),
})
}
watch(
options.open,
(isOpen) => {
upward.value = false
if (isOpen) place()
},
{ flush: 'post' },
)
return { upward }
}
@@ -0,0 +1,118 @@
import { isNavigationFailure, useRouter, type RouteLocationRaw } from 'vue-router'
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 { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/**
* 붙잡아 둔 발송을 다루는 일 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계") — 모두 DELETE /scheduled-messages/{id} 한 길이다.
* 취소한 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id — draftId). 실패(이미 보냄 409 MAIL_014 — "이미 보낸 메일이라 취소할 수 없습니다." ·
* 없음 404)는 인터셉터가 알린다 — 여기서는 아무것도 바꾸지 않는다(MAIL_014 면 스토어가 보이는 목록 · 열린 메일 · 예약됨 수를 다시 맞춘다).
* - [보내기 취소](보낸 뒤 알림): 쓰기 화면을 ?draft=<draftId> 로 다시 연다(새 발송 키 — 새 양식) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."
* - [고치기](예약 안내 줄): 예약을 취소하고 쓰기 화면으로 + "예약을 취소했습니다. 고친 뒤 다시 보내 주세요."
* 두 경우 모두 쓰기 화면으로 가지 못했으면(지금 화면의 나가기 확인에서 머물기로 함 등) 그 자리에 두고
* "…취소하고 임시보관함으로 옮겼습니다." + [고치기](그 메일로 쓰기 화면) — 취소는 이미 됐다
* - [예약 취소](예약 안내 줄): 임시보관함으로 + "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] — 삭제처럼 목록에서 빠진 뒤의 이동
* - [삭제](아직 보내지 않은 메일 — 보내는 중 · 예약, 확인 창 뒤): 발송을 취소한 뒤 휴지통으로 + "보내지 않고 휴지통으로 옮겼습니다."
* 같은 메일의 취소가 이미 진행 중이면(빠르게 두 번 누름 · 두 곳에서 누름) 두 번째는 아무것도 하지 않는다 (스토어도 요청을 하나만 보낸다).
* 세션은 일을 시작할 때 잡는다 — 기다리는 사이 바뀌었으면(로그아웃 · 다른 계정) 이동 · 알림을 하지 않는다(앞 사용자의 메일을 새 세션에 열지 않게).
* 목록에서 빠진 뒤의 이동은 그 메일을 아직 그 목록에서 보고 있을 때만 한다 (useMessageRemoval 의 leaveRemoved — 그사이 다른 곳으로 간 사용자를 끌고 가지 않게)
*/
export function useScheduledMessageActions() {
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const toast = useToast()
const removal = useMessageRemoval()
function draftLocation(draftId: string): RouteLocationRaw {
return { name: 'mail-compose', query: { draft: draftId } }
}
/** 쓰기 화면으로 그 메일을 연다 — 가지 못했으면(나가기 확인에서 머묾 · 다른 이동에 밀림) false */
async function openDraft(draftId: string): Promise<boolean> {
return !isNavigationFailure(await router.push(draftLocation(draftId)))
}
/**
* 취소 — 성공하면 draftId, 실패(인터셉터가 알렸다) · 그사이 세션이 바뀌었으면 · 같은 메일의 취소가 이미 진행 중이면 null.
* session 은 부르는 쪽이 일을 시작할 때 잡은 세대다
*/
async function cancel(messageId: string, session: number): Promise<string | null> {
if (mailStore.isCancellingPendingSend(messageId)) return null
try {
const { draftId } = await mailStore.cancelPendingSend(messageId)
return authStore.generation === session ? draftId : null
} catch {
return null
}
}
/** [보내기 취소] — 보낸 뒤 알림의 단추 */
async function undoSend(messageId: string): Promise<boolean> {
const session = authStore.generation
const draftId = await cancel(messageId, session)
if (!draftId) return false
if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.undone)
else toast.success(SEND_LATER_TEXT.undoneStayed, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
return true
}
/** [고치기] — 예약을 취소하고 쓰기 화면으로 */
async function editScheduled(messageId: string): Promise<boolean> {
const session = authStore.generation
const draftId = await cancel(messageId, session)
if (!draftId) return false
if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.editing)
else toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
return true
}
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
function removedFromList(messageId: string): boolean {
return !mailStore.messages.some((message) => message.id === messageId)
}
/** [예약 취소] — 보내지 않고 임시보관함으로 */
async function cancelScheduled(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
const session = authStore.generation
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
const draftId = await cancel(message.id, session)
if (!draftId) return false
toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
const removed = removedFromList(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
return true
}
/**
* [삭제] — 아직 보내지 않은 메일(보내는 중 · 예약)의 확인 창에서 「삭제」를 누른 뒤에만 부른다.
* 발송을 취소한 뒤 휴지통으로 옮긴다 (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다)
*/
async function deletePendingSend(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
const session = authStore.generation
const source = mailStore.listSource
const nextId = mailStore.neighborAfterRemoval(message.id)
const draftId = await cancel(message.id, session)
if (!draftId) return false
const trash = await mailStore.resolveMailboxByRole('trash')
if (authStore.generation !== session) return false
if ('error' in trash) {
// 발송은 취소했다 — 메일은 임시보관함에 있다 (휴지통을 몰라 옮기지 못했다)
notifyApiError(trash.error)
return false
}
if (!(await mailStore.moveMessage(draftId, trash.mailbox.id)) || authStore.generation !== session) return false
toast.success(SEND_LATER_TEXT.deleted)
const removed = removedFromList(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
return true
}
return { undoSend, editScheduled, cancelScheduled, deletePendingSend }
}
+115
View File
@@ -0,0 +1,115 @@
import { notifyApiError } from './useApi'
import { useToast } from './useToast'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { MessageSummary } from '@/shared/types/mail'
import type { BulkOrigin } from '@/shared/utils/mail-bulk'
import { objectParticle } from '@/shared/utils/korean'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailStore } from '@/stores/mail.store'
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다) */
export interface SenderBlockOutcome {
blocked: boolean
moved: boolean
/**
* 옮기기가 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있었다(여러 통 처리의 stale) — 옛 목록에 기대어 다음 메일로 넘어가지 않는다
* (옮긴 것은 맞다 — 알림 · 되돌리기는 그대로)
*/
stale: boolean
}
/**
* 보낸 사람 차단 · 풀기와 그 알림 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면 · 설정 › 차단한 주소").
* - 차단(읽기 화면의 확인 창): POST /mail-blocks → 「이 메일도 스팸메일함으로 옮기기」를 켰으면 그 메일을 스팸메일함으로(isJunk: true — 스팸 신고와 같은 길) →
* "‘주소’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 받은편지함에서 막았으면 받은편지함)
* - 풀기(⋯ 「보낸 사람 차단 풀기」 · 스팸메일함의 [차단 풀기] · 설정의 [차단 풀기]): DELETE → "‘주소’ 차단을 풀었습니다." + [되돌리기](다시 차단).
* 이미 받은 메일은 옮기지 않는다. 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 (스토어가 가른다)
* 요청 실패는 인터셉터가 알린다 — 여기서는 알리지 않는다. 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast).
* 기다리는 사이 세션이 바뀌었으면(로그아웃 · 다른 계정) 그 뒤의 일(메일 옮기기 · 알림)을 하지 않는다 — 앞 사용자의 주소가 새 세션에 보이지 않게
*/
export function useSenderBlock() {
const authStore = useAuthStore()
const blockStore = useMailBlockStore()
const mailStore = useMailStore()
const toast = useToast()
/** 지금 세션에서 시작한 일이 그대로인지 — 시작할 때 잡아 둔 세대와 견준다 */
function sessionGuard(): () => boolean {
const session = authStore.generation
return () => authStore.generation === session
}
async function undoBlock(address: string, origins: readonly BulkOrigin[]): Promise<void> {
const sameSession = sessionGuard()
try {
await blockStore.remove(address)
} catch {
return
}
if (!sameSession()) return
if (origins.length > 0) await mailStore.restoreMoved(origins, { isJunk: false })
if (!sameSession()) return
toast.success(origins.length > 0 ? MAIL_BLOCK_TEXT.blockRestored(address) : MAIL_BLOCK_TEXT.unblocked(address))
}
/**
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다)
*/
async function block(message: MessageSummary, target: string, moveToJunk: boolean): Promise<SenderBlockOutcome> {
const sameSession = sessionGuard()
let address: string
try {
address = (await blockStore.add(target)).address
} catch {
return { blocked: false, moved: false, stale: false }
}
if (!sameSession()) return { blocked: true, moved: false, stale: false }
let origins: BulkOrigin[] = []
let moved = false
let stale = false
if (moveToJunk) {
const junk = await mailStore.resolveMailboxByRole('junk')
if ('error' in junk) {
notifyApiError(junk.error)
} else if (!message.mailboxIds.includes(junk.mailbox.id)) {
const result = await mailStore.moveMessages([message], junk.mailbox.id, { isJunk: true })
moved = result.doneIds.includes(message.id)
stale = result.stale
origins = result.origins
}
}
if (!sameSession()) return { blocked: true, moved: false, stale }
const particle = objectParticle(address)
toast.success(
moved ? MAIL_BLOCK_TEXT.blockedAndMoved(address, particle) : MAIL_BLOCK_TEXT.blocked(address, particle),
{ label: MAIL_BLOCK_TEXT.undo, run: () => void undoBlock(address, origins) },
)
return { blocked: true, moved, stale }
}
async function reblock(address: string): Promise<void> {
const sameSession = sessionGuard()
try {
await blockStore.add(address)
} catch {
return
}
if (sameSession()) toast.success(MAIL_BLOCK_TEXT.unblockRestored(address, objectParticle(address)))
}
/** 차단 풀기 — 풀었으면 true (다른 곳에서 이미 풀었어도 — 404. 그 밖의 실패는 인터셉터가 알렸다) */
async function unblock(address: string): Promise<boolean> {
const sameSession = sessionGuard()
try {
await blockStore.remove(address)
} catch {
return false
}
if (!sameSession()) return false
toast.success(MAIL_BLOCK_TEXT.unblocked(address), { label: MAIL_BLOCK_TEXT.undo, run: () => void reblock(address) })
return true
}
return { block, unblock }
}
+20 -5
View File
@@ -16,6 +16,11 @@ export interface ProgressToast {
dismiss: () => void
}
/** 알림 옵션 — expiresAt(ms — Date.now)에 닫는다. 붙잡아도 멈추지 않고 남은 시간 막대가 보인다 (9단계 [보내기 취소]) */
export interface ToastOptions {
expiresAt?: number
}
/**
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
@@ -38,17 +43,27 @@ export function useToast() {
}
}
function success(message: string, action?: ToastAction): number {
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
function success(message: string, action?: ToastAction, options: ToastOptions = {}): number {
return store.show({
kind: 'success',
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
})
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아닌 실패를 알릴 때만 쓴다: 브라우저 상태(예: 바탕화면 알림을 막아 둠 + [설정 열기]),
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함]).
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함] · 붙잡아 둔 발송이면 [보내기 취소]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
function error(message: string, action?: ToastAction): number {
return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
function error(message: string, action?: ToastAction, options: ToastOptions = {}): number {
return store.show({
kind: 'error',
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
})
}
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
@@ -0,0 +1,402 @@
import { afterEach, beforeAll, 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 { Draft, Mailbox, RejectedRecipient } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import { formatDate } from '@/shared/utils/format'
import { schedulePresets, sendAtOf } from '@/shared/utils/schedule-send'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
// 쓰기 화면의 9단계 — 보내기 취소(보낸 뒤 알림 · [보내기 취소]) · [보내기 ▾] › 예약 발송
// (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
/** 2026-10-29(목) 15:00 (서울) */
const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 1, totalCount: 1, unreadCount: 0 },
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
]
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
/** 보내기 취소로 임시보관함에 돌아온 메일 */
const RETURNED: Draft = {
id: 'sent-1',
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
cc: [],
bcc: [],
subject: '일정 공유',
body: '확인 부탁드립니다.',
bodyHtml: null,
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-29T06:00:00Z',
}
describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
let pinia: Pinia
let clock: ManualClock
const mountedWrappers: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
clock = createManualClock(NOW)
})
afterEach(() => {
for (const wrapper of mountedWrappers.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
document.body.innerHTML = ''
})
/** 보내기 응답을 차례로 — 메일함 · 예약됨 수 · 되돌아온 저장본은 늘 같다 */
function server(sendReplies: MockResult[], cancelReply: MockResult = ok({ draftId: 'sent-1' })): MockHandler {
return (request) => {
if (request.method === 'POST' && request.url === '/messages') return sendReplies.shift() ?? ok({ id: 'sent-9' }, 201)
if (request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/')) return cancelReply
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
if (request.url === '/drafts/sent-1') return ok(RETURNED)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
}
async function mountCompose(handler: MockHandler, attach = false) {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push('/mail/inbox')
await router.push('/mail/compose')
await router.isReady()
useMailStore().rememberBrowseLocation('/mail/inbox')
await useMailStore().loadMailboxes()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
})
mountedWrappers.push(wrapper)
await flushPromises()
await vi.waitFor(
() => {
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
return { wrapper, router, requests }
}
async function addRecipient(wrapper: VueWrapper, email: string) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const sends = (requests: MockRequest[]) => requests.filter((request) => request.method === 'POST' && request.url === '/messages')
const toasts = (): Toast[] => useToastStore().toasts
/** 알림의 단추를 누른다 — 알림 틀(ToastHost)처럼 알림을 닫고 할 일을 한다 */
function runAction(toast: Toast | undefined) {
if (!toast?.action || !('run' in toast.action)) throw new Error('단추가 없다')
useToastStore().dismiss(toast.id)
toast.action.run()
}
describe('보내기 취소 — 엔진이 붙잡아 둔 발송 (pendingSend: undo)', () => {
const undoReply = (rejected: RejectedRecipient[] = []) =>
ok({ id: 'sent-1', rejectedRecipients: rejected, pendingSend: { kind: 'undo', sendAt: new Date(NOW + 10_000).toISOString() } }, 201)
it('"메일을 보냈습니다." + [보내기 취소] — 서버가 보낼 시각(sendAt)에 닫힌다(마감 · 남은 시간 막대)', async () => {
const { wrapper, router } = await mountCompose(server([undoReply()]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'success',
message: '메일을 보냈습니다.',
action: expect.objectContaining({ label: '보내기 취소' }),
expiresAt: NOW + 10_000,
}),
])
})
it('[보내기 취소] → DELETE → 그 메일로 쓰기 화면을 다시 연다(받는 사람 · 제목 그대로) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."', async () => {
const { wrapper, router, requests } = await mountCompose(server([undoReply()]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await wrapper.get('#compose-subject').setValue('일정 공유')
await submit(wrapper)
runAction(toasts()[0])
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/scheduled-messages/sent-1',
])
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=sent-1')
await vi.waitFor(() => {
if (!requests.some((request) => request.url === '/drafts/sent-1')) throw new Error('저장본을 받는 중')
})
await flushPromises()
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('일정 공유')
expect(toasts().map((toast) => toast.message)).toEqual(['보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.'])
// 다시 보내면 새 발송 키다 — 취소한 발송의 키로 다시 보내면 서버가 MAIL_005 로 막는다 (계약)
await submit(wrapper)
const keys = sends(requests).map((request) => request.config.headers.get('Idempotency-Key'))
expect(keys).toHaveLength(2)
expect(keys[1]).not.toBe(keys[0])
})
it('[보내기 취소] 뒤 쓰기 화면으로 가지 못하면(지금 화면의 나가기 확인에서 머묾) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]', async () => {
const { wrapper, router } = await mountCompose(server([undoReply()]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
router.beforeEach((to) => to.name !== 'mail-compose')
runAction(toasts()[0])
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'success',
message: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 'sent-1' } } },
}),
])
})
it('늦게 누르면(이미 보냄 — 409) 쓰기 화면으로 가지 않는다 — 알림은 인터셉터', async () => {
const { wrapper, router } = await mountCompose(server([undoReply()], fail(409, 'MAIL_014')))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
runAction(toasts()[0])
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(toasts().map((toast) => toast.kind)).toEqual(['error'])
})
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => {
const { wrapper } = await mountCompose(server([undoReply([NOBODY])]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await addRecipient(wrapper, NOBODY.email)
await submit(wrapper)
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'error',
message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
action: expect.objectContaining({ label: '보내기 취소' }),
expiresAt: NOW + 10_000,
}),
])
})
it('보낼 시각이 이 PC 시계로 이미 지났으면 취소할 수 없다 — 지금처럼 [보낸편지함 보기]', async () => {
const past = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW - 1_000).toISOString() } }, 201)
const { wrapper } = await mountCompose(server([past]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
expect(toasts()).toEqual([
expect.objectContaining({
message: '메일을 보냈습니다.',
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
expiresAt: null,
}),
])
})
it('이 PC 시계가 늦어 보낼 시각이 멀리 보여도 알림은 30초(설정의 가장 긴 값)까지만 남는다', async () => {
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
const { wrapper } = await mountCompose(server([far]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
const sentAt = Date.now()
await submit(wrapper)
expect(toasts()[0]?.expiresAt).toBe(sentAt + 30_000)
})
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
settings.status = 'ready'
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
const { wrapper } = await mountCompose(server([far]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
const sentAt = Date.now()
await submit(wrapper)
expect(toasts()[0]?.expiresAt).toBe(sentAt + 4_000)
})
it('세션이 바뀌면(로그아웃) 알림을 닫는다 — 메일은 그대로 보내진다', async () => {
const { wrapper, requests } = await mountCompose(server([undoReply()]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
expect(toasts()).toHaveLength(1)
useAuthStore().clearAuth()
await flushPromises()
expect(toasts()).toEqual([])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
})
})
describe('[보내기 ▾] › 「예약 발송…」', () => {
async function openSchedule(wrapper: VueWrapper) {
await wrapper.get('.compose__send-more').trigger('click')
await flushPromises()
const item = wrapper.get('[role="menu"] [role="menuitem"]')
expect(item.text()).toBe('예약 발송…')
await item.trigger('click')
await flushPromises()
}
it('▾ 는 보내기와 같은 규칙으로 꺼진다 — 메뉴는 「예약 발송…」(시계) 하나', async () => {
const { wrapper } = await mountCompose(server([]))
const more = wrapper.get('.compose__send-more')
expect(more.attributes('aria-label')).toBe('보내기 방법 더 보기')
expect(more.attributes('aria-haspopup')).toBe('menu')
expect(more.attributes('disabled')).toBeUndefined()
await more.trigger('click')
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['예약 발송…'])
expect(items[0]?.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
})
it('받는 사람이 없으면 창을 열지 않고 받는 사람 칸에 알린다 (보내기와 같은 검사)', async () => {
const { wrapper } = await mountCompose(server([]))
await openSchedule(wrapper)
expect(wrapper.find('[data-schedule-dialog]').exists()).toBe(true)
expect(wrapper.get('#compose-schedule-title').element.closest('dialog')?.hasAttribute('open')).toBe(false)
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
})
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
const preset = schedulePresets(NOW)[0]
if (!preset) throw new Error('고르기가 없다')
const sendAt = sendAtOf(preset.at)
const { wrapper, router, requests } = await mountCompose(
server([ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'scheduled', sendAt } }, 201)]),
true,
)
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await openSchedule(wrapper)
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
expect(dialog?.hasAttribute('open')).toBe(true)
expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
await wrapper.get('[data-schedule-confirm]').trigger('click')
await flushPromises()
expect(sends(requests).map((request) => (request.data as { sendAt?: string }).sendAt)).toEqual([sendAt])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'success',
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
expiresAt: null,
}),
])
expect(requests.some((request) => request.url === '/scheduled-messages' && request.params.limit === 1)).toBe(true)
})
it('서버가 예약 시각을 받지 않으면(VAL_001) 알림 대신 예약 창을 다시 연다 — 그 시각을 직접 고르기에 채우고 고르기 아래에 까닭 (리뷰 #11)', async () => {
const preset = schedulePresets(NOW)[0]
if (!preset) throw new Error('고르기가 없다')
const { wrapper, router, requests } = await mountCompose(server([fail(400, 'VAL_001')]), true)
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await openSchedule(wrapper)
await wrapper.get('[data-schedule-confirm]').trigger('click')
await flushPromises()
expect(sends(requests)).toHaveLength(1)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
// 사전 문구 알림은 없다 — 창이 안내한다
expect(toasts()).toEqual([])
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
expect(dialog?.hasAttribute('open')).toBe(true)
// 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
const chosen = new Date(preset.at)
expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen))
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(
String(chosen.getHours() * 60 + chosen.getMinutes()),
)
expect(wrapper.get('[data-schedule-day]').attributes('aria-describedby')).toBe('compose-schedule-error')
// 닫으면 까닭도 지운다 — 다시 열면 처음 값
await wrapper.get('[data-schedule-cancel]').trigger('click')
await flushPromises()
await openSchedule(wrapper)
expect(wrapper.get('#compose-schedule-error').text()).toBe('')
expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
})
it('예약 창의 [취소] 는 보내지 않는다 · [보내기]는 sendAt 없이 보낸다', async () => {
const { wrapper, requests } = await mountCompose(server([ok({ id: 'sent-1', rejectedRecipients: [] }, 201)]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await openSchedule(wrapper)
await wrapper.get('[data-schedule-cancel]').trigger('click')
await flushPromises()
expect(sends(requests)).toEqual([])
await submit(wrapper)
expect(sends(requests).map((request) => request.data)).toEqual([expect.not.objectContaining({ sendAt: expect.anything() })])
})
})
})
+4 -1
View File
@@ -1244,8 +1244,11 @@ describe('ComposePage', () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router, requests } = await mountCompose('/mail/compose', savable(replyApi))
expect(wrapper.find('.compose__cancel').exists()).toBe(false)
expect(wrapper.findAll('.compose__footer a, .compose__footer > button').map((item) => item.text())).toEqual([
// [보내기 ▾](9단계) — 보내기 · 보내기 방법(▾) · 임시 저장뿐이다
const footerButtons = wrapper.findAll('.compose__footer a, .compose__footer > button, .compose__send-group > button')
expect(footerButtons.map((item) => item.text() || item.attributes('aria-label'))).toEqual([
'보내기',
'보내기 방법 더 보기',
'임시 저장',
])
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
+133 -12
View File
@@ -31,6 +31,15 @@
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
// 보내기 취소 · 예약 발송 (9단계 — 계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계"):
// - 엔진이 발송을 붙잡아 두면(응답의 pendingSend) 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — 설정한 초(받아 둔 보내기 · 읽기 설정 —
// 이 PC 시계가 서버와 어긋나도 늘어나지 않게, 모르면 그 시각 sendAt)에 닫히고 붙잡아도 멈추지 않는다 (pending-send.ts 의 undoToastDeadline).
// 일부가 받지 못했으면 그 실패 알림("…받지 못합니다")에 [보내기 취소]. [보내기 취소]는 쓰기 화면을 그 메일로 다시 연다
// (useScheduledMessageActions — 새 양식이라 새 발송 키)
// - [보내기 ▾] › 「예약 발송…」 — 받는 사람 · 제목을 먼저 보고(보내기와 같은 검사) 예약 창(ComposeScheduleDialog)을 연다.
// [예약]은 보내기와 같은 흐름(임시 저장 기다리기 · 길이 검사 · 같은 발송 키)에 sendAt 을 더해 보낸다 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기].
// 서버가 그 시각을 받지 않으면(VAL_001 — 그사이 5분 안이 됐거나 시계가 어긋남) 알림 대신 예약 창을 다시 열고 고르기 아래에 까닭
// ("지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." — 이 화면의 검사로 고른다, schedule-send.ts 의 rejectedScheduleProblem)
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
// 휴지통 #compose-discard · 닫기 .compose__close
import {
@@ -52,6 +61,7 @@ import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue
import ComposeFooter from '@/components/mail/ComposeFooter.vue'
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
import ComposeScheduleDialog from '@/components/mail/ComposeScheduleDialog.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
@@ -68,6 +78,7 @@ import { useDraftTrash } from '@/composables/useDraftTrash'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
@@ -84,6 +95,8 @@ import {
type ComposeMode,
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS } from '@/shared/constants/mail-send-later'
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
import {
@@ -93,6 +106,7 @@ import {
} from '@/shared/utils/compose-changes'
import { composePrefillOf } from '@/shared/utils/compose-prefill'
import { isImageFile } from '@/shared/utils/editor-files'
import { formatScheduleTime } from '@/shared/utils/format'
import {
createIdempotencyKey,
shouldRenewSendKey,
@@ -111,7 +125,11 @@ import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
import { undoToastDeadline } from '@/shared/utils/pending-send'
import { rejectedScheduleProblem, sendAtOf, type ScheduleRejection } from '@/shared/utils/schedule-send'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailStore } from '@/stores/mail.store'
@@ -136,13 +154,21 @@ type ComposeSource = ComposeMode | 'draft'
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
type RecipientRejectionCode = Extract<ErrorCode, 'MAIL_002' | 'MAIL_010'>
/**
* [보내기 취소] 알림이 남아 있을 수 있는 가장 긴 시간 — 설정의 가장 긴 값(30초). 설정한 초를 모를 때만 쓴다(undoToastDeadline).
* 이 PC 의 시계가 서버보다 늦어 sendAt 이 멀리 보여도 알림이 그보다 오래 남지 않게 한다 (늦게 누르면 MAIL_014 — 인터셉터)
*/
const UNDO_SEND_MAX_MS = Math.max(...UNDO_SEND_OPTIONS) * 1000
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const signatureStore = useMailSignatureStore()
const { getDraft, getMessage, sendMessage } = useMail()
const draftTrash = useDraftTrash()
const scheduledActions = useScheduledMessageActions()
const toast = useToast()
const clock = useClock()
const {
@@ -186,6 +212,10 @@ const composeKind = ref<ComposeMode>('new')
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
const inReplyTo = ref<string | null>(null)
const sending = ref<boolean>(false)
/** 예약 창 (9단계) — ▾ › 「예약 발송…」 */
const scheduleOpen = ref<boolean>(false)
/** 서버가 받지 않은 예약 시각과 까닭 — 예약 창을 다시 열 때 고르기 아래에 보인다 (창을 닫으면 지운다) */
const scheduleRejection = ref<ScheduleRejection | null>(null)
/** 머리의 휴지통 — 저장본을 버리는 중 · 버렸다 (버렸으면 떠날 때 묻지도 저장하지도 않는다) */
const discarding = ref<boolean>(false)
const discarded = ref<boolean>(false)
@@ -226,6 +256,8 @@ let formGeneration = 0
let resetCount = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
/** 예약 발송에서 이 화면이 직접 안내하는 코드 — 위에 더해 VAL_001(서버가 그 시각을 받지 않음 — 예약 창을 다시 연다) */
const SCHEDULE_INLINE_CODES: readonly ErrorCode[] = [...SEND_INLINE_CODES, 'VAL_001']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -711,11 +743,41 @@ function settleSendKey(outcome: SendOutcome) {
}
/**
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — send). 받는 사람 일부가 받지 못했으면
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS).
* 엔진이 붙잡아 둔 발송(9단계 — pendingSend):
* - 보내기 취소를 기다리는 중(undo): 단추가 [보내기 취소]이고 알림은 마감에 닫힌다 · 남은 시간 막대. 마감은 받아 둔 설정의 초(− 1초)로 센다 —
* 이 PC 시계가 서버와 어긋나도 설정보다 오래 남지 않게. 설정을 모르면 그 시각(sendAt — 이 PC 시계로 30초를 넘지 않게).
* 마감이 이미 지났으면(이 PC 시계가 서버보다 빠르다) 취소할 수 없으니 지금처럼 알린다
* - 예약(scheduled): "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기] — 일부가 받지 못했으면 "예약했지만 … 받지 못합니다" + [예약됨 보기]
*/
function notifySent(result: SentMessage) {
const pending = result.pendingSend
if (pending?.kind === 'scheduled') {
const scheduledLink = { label: SEND_LATER_TEXT.viewScheduled, to: { name: MAIL_SCHEDULED_ROUTE } }
const partial = partialSendMessage(result.rejectedRecipients, { pendingKind: 'scheduled' })
if (partial) toast.error(partial, scheduledLink)
else toast.success(SEND_LATER_TEXT.scheduled(formatScheduleTime(pending.sendAt)), scheduledLink)
return
}
const now = Date.now()
const expiresAt = pending
? undoToastDeadline({
sendAt: pending.sendAt,
now,
undoSendSeconds: settingsStore.knownUndoSendSeconds,
marginMs: UNDO_SEND_DEADLINE_MARGIN_MS,
maxMs: UNDO_SEND_MAX_MS,
})
: Number.NaN
if (pending && expiresAt > now) {
const undo = { label: SEND_LATER_TEXT.undo, run: () => void scheduledActions.undoSend(result.id) }
const partial = partialSendMessage(result.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(result.rejectedRecipients)
@@ -734,24 +796,61 @@ function endSending(generation: number) {
syncDraftUrl(generation)
}
async function onSubmit() {
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
/**
* 보내기 전 검사 — 받는 사람(비었음 · 형식 · 100명 초과)과 제목 길이. 걸리면 그 칸에 안내하고 초점을 옮긴다(false).
* [보내기]와 ▾ › 「예약 발송…」(예약 창을 열기 전)이 함께 쓴다. 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
*/
function precheckSend(): boolean {
commitRecipientInputs()
attempted.value = true
rejectedByServer.value = null
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
toField.value?.focus()
return
return false
}
if (!canSend.value || !editorRef.value) return
if (!canSend.value || !editorRef.value) return false
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
const subjectText = subject.value.trim()
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
if (subject.value.trim().length > MAIL_SUBJECT_MAX_LENGTH) {
lengthError.value = 'subject'
subjectInputRef.value?.focus()
return
return false
}
return true
}
function onSubmit() {
void send()
}
/** ▾ › 「예약 발송…」 — 보내기와 같은 검사를 먼저 하고 예약 창을 연다 */
function openSchedule() {
if (!precheckSend()) return
scheduleRejection.value = null
scheduleOpen.value = true
}
/** 예약 창의 [예약] — 고른 시각(ms)으로 보낸다 */
function onScheduleConfirm(at: number) {
closeSchedule()
void send(sendAtOf(at))
}
function closeSchedule() {
scheduleOpen.value = false
scheduleRejection.value = null
}
/** 서버가 예약 시각을 받지 않았다(VAL_001) — 예약 창을 다시 열고 고르기 아래에 까닭을 보인다 (까닭은 이 화면의 검사로 고른다) */
function reopenSchedule(at: number) {
scheduleRejection.value = { at, problem: rejectedScheduleProblem(at, clock.now()) }
scheduleOpen.value = true
}
/** 보내기 — sendAt 이 있으면 예약 발송 (9단계). 흐름은 같다 */
async function send(sendAt?: string) {
if (!precheckSend() || !editorRef.value) return
const subjectText = subject.value.trim()
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
@@ -801,6 +900,8 @@ async function onSubmit() {
return
}
const draftId = drafts.draftId.value
/** 이 화면이 직접 안내하는 코드 — 인터셉터 알림을 끈다 (예약이면 VAL_001 도 — 예약 창을 다시 연다) */
const inlineCodes = sendAt ? SCHEDULE_INLINE_CODES : SEND_INLINE_CODES
const payload: SendMessagePayload = {
...recipients,
subject: subjectText,
@@ -814,6 +915,8 @@ async function onSubmit() {
...(replyTo ? { inReplyTo: replyTo } : {}),
// 이어 쓴 저장본 — 보낸 뒤 서버가 지운다
...(draftId ? { draftId } : {}),
// 예약 발송 — 지금부터 5분 뒤 ~ 7일 안 (예약 창이 검사했다 · 서버도 같은 한도)
...(sendAt ? { sendAt } : {}),
}
try {
@@ -823,12 +926,13 @@ async function onSubmit() {
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: inlineCodes })
if (sameSession()) {
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
notifySent(result)
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다 — 예약했으면 패널의 「예약됨」 수도
mailStore.syncCounts()
if (result.pendingSend?.kind === 'scheduled') void mailStore.loadScheduledCount()
}
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
@@ -845,7 +949,7 @@ async function onSubmit() {
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
if (!sameSession()) return
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
if (inlineCodes.includes(apiError.code)) notifyApiError(apiError)
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
void draftLink.keepUnsent(payload, session)
return
@@ -865,6 +969,9 @@ async function onSubmit() {
} else if (code === 'MAIL_004') {
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
await guideMissingAttachments(markMissing())
} else if (code === 'VAL_001' && sendAt) {
// 서버가 예약 시각을 받지 않았다(그사이 5분 안이 됨 · 시계가 어긋남) — 알림 대신 예약 창을 다시 열어 고르게 한다
reopenSchedule(Date.parse(sendAt))
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
}
@@ -1004,6 +1111,11 @@ watch(hasMissing, (missing) => {
watch(allRecipients, () => {
rejectedByServer.value = null
})
// 보낼 수 없게 되면(보내는 중 · 양식을 새로 채움 · 첨부를 올리기 시작함) 예약 창을 닫는다. 세션이 바뀌어도 닫는다 — 세션 만료 대화상자와 겹치지 않게
watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
if (!can || authStore.generation !== previousSession) closeSchedule()
})
</script>
<template>
@@ -1166,9 +1278,18 @@ watch(allRecipients, () => {
:draft-saved-at="draftSavedAt"
:sender="sender"
@save-draft="onSaveDraft"
@schedule="openSchedule"
/>
</form>
<!-- 예약 발송 창 (9단계) — [예약]이면 보내기와 같은 흐름에 sendAt 을 더한다 -->
<ComposeScheduleDialog
:open="scheduleOpen"
:rejection="scheduleRejection"
@confirm="onScheduleConfirm"
@cancel="closeSchedule"
/>
<!-- 저장하지 못한 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<ComposeLeaveDialog
:open="leaveConfirming"
+18 -1
View File
@@ -5,6 +5,10 @@
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
// 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」의 창은 여기에 둔다 — 메일이 바뀌어도 사라지지 않게 (useMailContactDialog)
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
@@ -15,11 +19,15 @@ import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
const clock = useClock()
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
@@ -30,18 +38,27 @@ void mailStore.ensureQuota()
void useMailSignatureStore().ensureSignature()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 패널의 「예약됨」 — 예약한 메일이 있을 때만 그린다
void mailStore.loadScheduledCount()
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
useMailBlockStore().ensureLoaded()
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
useMailSettingsStore().ensureLoaded()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
useMailStatePolling({
clock: useClock(),
clock,
onChange: () => {
mailStore.syncCounts()
void mailStore.syncVisibleList()
void mailStore.loadScheduledCount()
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
useMailTabTitle()
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
usePendingSendRefresh({ clock })
</script>
<template>
@@ -0,0 +1,270 @@
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 { createMemoryHistory, createRouter } from 'vue-router'
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
import { routes } from '@/router/routes'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailPrintPage from './MailPrintPage.vue'
import printPageSource from './MailPrintPage.vue?raw'
// 인쇄 화면 (디자인 원본 MailPrint · README "9단계 › 인쇄 화면" · 계약 2절 "인쇄") — 새 탭, 앱 틀 없이 메일 한 통
// 인쇄 창(window.print)은 가짜로 바꿔 부른 횟수만 센다. 화면은 다음 그리기 차례(requestAnimationFrame)에 인쇄 창을 띄운다 —
// 테스트가 그 차례를 손으로 돌린다(runFrames — 진짜 시간을 기다리지 않는다)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const MESSAGE: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: 'Re: 메일 서버 이전 일정 공유',
preview: '',
receivedAt: '2026-10-26T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: true,
size: 100,
pendingSend: null,
cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
bcc: [{ name: '숨은 사람', email: 'hidden@arasystem.it.kr' }],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: '<p>김아라님, 안녕하세요.</p>',
textBody: '김아라님, 안녕하세요.',
hasBlockedImages: false,
imagesShown: false,
attachments: [
{ id: 'a1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49_152 },
{ id: 'a2', name: '서명.png', type: 'image/png', size: 2_000, isInline: true },
],
}
describe('MailPrintPage', () => {
let pinia: Pinia
let printed: number
/** 다음 그리기 차례를 기다리는 일 — runFrames 가 돌린다 */
let frames: FrameRequestCallback[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
printed = 0
frames = []
vi.spyOn(window, 'print').mockImplementation(() => {
printed += 1
})
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback)
return frames.length
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountAt(
path: string,
reply: (request: MockRequest) => MockResult = () => ok(MESSAGE),
options: { stubFrame?: boolean } = {},
) {
const requests = installMockApi(reply)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailPrintPage, {
attachTo: document.body,
// 본문 틀을 가짜로 — jsdom 은 srcdoc 를 열자마자 높이를 재므로, 잰 때를 테스트가 정한다
global: { plugins: [pinia, router], stubs: options.stubFrame ? { MailBodyFrame: true } : {} },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, requests }
}
/** 본문 틀이 높이를 다 쟀다고 알린다 (jsdom 은 배치를 하지 않는다) */
async function measure(wrapper: VueWrapper) {
wrapper.findComponent(MailBodyFrame).vm.$emit('measured', 1200)
await flushPromises()
}
/** 기다리는 그리기 차례를 모두 돌린다 — 돌리는 동안 새로 잡은 차례도 */
async function runFrames() {
await flushPromises()
for (let round = 0; round < 5 && frames.length > 0; round += 1) {
for (const callback of frames.splice(0)) callback(0)
await flushPromises()
}
}
it('메일 한 통만 — 머리 줄(ARA SYSTEM 메일 · 인쇄한 직원) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜 · 첨부(이름 · 크기) — 숨은참조는 없다', async () => {
const { wrapper, requests } = await mountAt('/mail/print/m1')
expect(requests.map((request) => [request.url, request.params])).toEqual([['/messages/m1', {}]])
expect(wrapper.get('.print-sheet__brand').text()).toBe('ARA SYSTEM 메일김아라 <ara.kim@arasystem.it.kr>')
expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
const meta = wrapper.findAll('.print-sheet__meta dt').map((dt) => [dt.text(), dt.element.nextElementSibling?.textContent?.trim()])
expect(meta).toEqual([
['보낸 사람', '이도윤 <doyun.lee@arasystem.it.kr>'],
['받는 사람', '김아라 <ara.kim@arasystem.it.kr>'],
['참조', '인프라팀 <infra@arasystem.it.kr>'],
['날짜', expect.stringMatching(/^2026년 10월 26일 \(월\) (오전|오후) \d{1,2}:42$/)],
['첨부', '메일서버_전환_체크리스트.xlsx (48 KB)'],
])
expect(wrapper.text()).not.toContain('hidden@arasystem.it.kr')
expect(wrapper.find('iframe').attributes('srcdoc')).toContain('김아라님, 안녕하세요.')
expect(document.title).toBe('Re: 메일 서버 이전 일정 공유 · ARA SYSTEM')
})
it('본문 높이를 다 잰 뒤에 한 번 인쇄 창을 띄운다 — 다시 재도 또 띄우지 않고, [인쇄]는 언제든 다시 띄운다', async () => {
const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
const printButton = wrapper.get('[data-print-now]')
expect(printButton.attributes('disabled')).toBeDefined()
// 재기 전에는 그리기 차례를 다 돌려도 띄우지 않는다
await runFrames()
expect(printed).toBe(0)
await measure(wrapper)
// 잰 높이가 그려진 다음 차례에 띄운다
expect(printed).toBe(0)
await runFrames()
expect(printed).toBe(1)
expect(printButton.attributes('disabled')).toBeUndefined()
// 다시 재고(폭이 바뀜 등) 그다음 차례까지 돌려도 또 띄우지 않는다
await measure(wrapper)
await runFrames()
expect(printed).toBe(1)
await printButton.trigger('click')
await runFrames()
expect(printed).toBe(2)
})
it('읽기 화면에서 외부 이미지를 보이고 있었으면(?images=show) 그대로 받아 본문 틀이 그림을 그린다', async () => {
const shown = { ...MESSAGE, htmlBody: '<p>본문</p><img src="/api/mail-images/t0k">', imagesShown: true }
const { wrapper, requests } = await mountAt('/mail/print/m1?images=show', () => ok(shown))
expect(requests[0]?.params).toEqual({ images: 'show' })
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
})
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
expect(wrapper.text()).toContain('메일을 찾을 수 없습니다')
expect(wrapper.find('.print-sheet').exists()).toBe(false)
expect(wrapper.get('[data-print-now]').attributes('disabled')).toBeDefined()
// 화면이 직접 그린다 — 알림은 띄우지 않는다
expect(useToastStore().toasts).toEqual([])
await wrapper.get('.print-page__state-action').trigger('click')
await flushPromises()
expect(close).toHaveBeenCalledTimes(1)
// 스크립트가 연 탭이 아니어서 닫히지 않았으면 메일 화면으로
expect(router.currentRoute.value.name).toBe('mail')
expect(printed).toBe(0)
})
it('받지 못하면 사전 문구와 [다시 시도] — 인쇄 창은 띄우지 않고, 다시 받아 본문을 다 재면 그때 한 번 띄운다', async () => {
let reply: MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountAt('/mail/print/m1', () => reply, { stubFrame: true })
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
await runFrames()
expect(printed).toBe(0)
reply = ok(MESSAGE)
await wrapper.get('[data-print-retry]').trigger('click')
await flushPromises()
expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
await runFrames()
expect(printed).toBe(0)
await measure(wrapper)
await runFrames()
expect(printed).toBe(1)
})
it('인쇄 창을 기다리는 사이 화면을 내리면 띄우지 않는다', async () => {
const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
await measure(wrapper)
expect(frames).toHaveLength(1)
wrapper.unmount()
await runFrames()
expect(printed).toBe(0)
})
it('인쇄 창을 기다리는 사이 다른 메일로 바뀌면(다시 받음) 옛 차례는 띄우지 않는다', async () => {
const { wrapper, router } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
await measure(wrapper)
await router.push('/mail/print/m2')
await flushPromises()
await runFrames()
expect(printed).toBe(0)
})
it('[닫기] 는 이 탭을 닫는다', async () => {
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
const { wrapper } = await mountAt('/mail/print/m1')
await wrapper.get('[data-print-close]').trigger('click')
expect(close).toHaveBeenCalledTimes(1)
})
it('인쇄 폭 — 화면의 종이 안쪽 폭(210mm − 좌우 여백)과 인쇄 쪽의 안쪽 폭(@page A4 − 여백)이 같다 (2026-10-29 빈 쪽 10장 회귀)', () => {
const css = printPageSource.slice(printPageSource.indexOf('<style'))
const page = /@page\s*\{\s*size:\s*A4;\s*margin:\s*(\d+)mm\s+(\d+)mm;\s*\}/.exec(css)
const sheet = /\.print-sheet\s*\{[^}]*?width:\s*210mm;\s*padding:\s*(\d+)mm\s+(\d+)mm;/.exec(css)
expect(page).not.toBeNull()
expect(sheet).not.toBeNull()
// 좌우 여백이 같다 → 화면에서 잰 본문 줄바꿈 · 높이가 인쇄에서도 같다
expect(sheet?.[2]).toBe(page?.[2])
expect(sheet?.[1]).toBe(page?.[1])
// 인쇄에서는 종이 폭 · 여백을 쪽(@page)에 맡긴다 · 화면에만 보이는 줄은 숨긴다
const print = css.slice(css.indexOf('@media print'))
expect(print).toMatch(/\.print-sheet\s*\{[^}]*width:\s*auto;\s*padding:\s*0;/)
expect(print).toMatch(/\.print-bar\s*\{\s*display:\s*none;/)
})
it('앱의 라우트 표 — /mail/print/<id> 는 메일 레이아웃(앱 틀) 밖의 인쇄 화면 · 로그인 필요, /mail/scheduled 는 예약됨 (메일함 id 로 읽지 않는다)', () => {
const router = createRouter({ history: createMemoryHistory(), routes })
const print = router.resolve('/mail/print/m1?images=show')
expect(print.name).toBe('mail-print')
expect(print.matched).toHaveLength(1)
expect(print.meta).toMatchObject({ requiresAuth: true, title: '메일 인쇄' })
expect(print.query).toEqual({ images: 'show' })
expect(router.resolve('/mail/scheduled').name).toBe('mail-scheduled')
expect(router.resolve('/mail/scheduled/s1').name).toBe('mail-scheduled-message')
expect(router.resolve('/mail/scheduled').meta.title).toBe('예약됨')
// 다른 메일함 · 메일 주소는 그대로
expect(router.resolve('/mail/inbox/m1').name).toBe('mail-message')
})
})
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
// 인쇄 화면 (디자인 원본 MailPrint · README "9단계 › 인쇄 화면" · 계약 2절 "인쇄") — /mail/print/:messageId (?images=show), 새 탭
// 앱 틀(레일 · 메일함 · 목록 · 도구 줄) 없이 메일 한 통만 그린다. 읽기 화면 ⋯ › 인쇄 · Ctrl+P 가 연다(useMailPrint).
// - 화면에만 보이는 위 줄: "인쇄 화면" + [인쇄](다시 띄우기) [닫기](탭 닫기). 인쇄 창을 닫아도 탭은 남는다.
// - 인쇄하는 것: 머리 줄(「ARA SYSTEM 메일」 · 인쇄한 직원의 이름 <주소>) → 제목 → 보낸 사람 · 받는 사람 · 참조 · 날짜(숨은참조는 넣지 않는다) →
// 첨부(이름 · 크기만) → 가는 선 → 본문(읽기 화면과 같은 정화 — MailBodyFrame). 쪽 번호 · 날짜는 브라우저의 머리글 · 바닥글 설정을 따른다.
// 탭 제목은 메일 제목 — 브라우저가 인쇄 머리글 · PDF 파일 이름에 쓴다.
// - 본문은 인쇄 폭(A4 — @page)과 같은 폭의 종이 위에서 높이를 잰다 — 화면에서 잰 높이가 인쇄에서도 맞아야 긴 메일이 여러 쪽으로 이어지고
// 끝에 빈 쪽이 생기지 않는다 (2026-10-29 확인: 앱 화면을 그대로 인쇄하면 앱 틀까지 찍히고, 화면 폭에서 잰 본문 높이 때문에 끝에 빈 쪽이 10장 생겼다.
// Edge/Chromium 은 내용 높이로 키운 iframe 을 여러 쪽으로 이어 인쇄한다 — 58쪽 PDF 로 확인).
// 종이 폭(210mm)과 인쇄 여백(@page margin)은 아래 CSS 가 함께 정한다 — 둘을 함께 고친다.
// - 본문을 다 재면(그림까지 받은 뒤 — iframe 의 load) 글꼴을 기다려 한 번 인쇄 창을 띄운다. [인쇄]는 언제든 다시 띄운다.
// - 못 열 때: 메일을 찾을 수 없으면(RES_001) "메일을 찾을 수 없습니다" + [닫기], 받지 못하면 사전 문구 + [다시 시도] — 인쇄 창은 띄우지 않는다.
// 세션이 끝났으면 로그인 화면으로 (라우터 가드 · 세션 만료 대화상자)
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMail } from '@/composables/useMail'
import { MAIL_IMAGES_PARAM, MAIL_IMAGES_SHOW } from '@/shared/constants/mail'
import { MAIL_PRINT_TEXT } from '@/shared/constants/mail-print'
import type { MessageDetail } from '@/shared/types/mail'
import { documentTitle } from '@/shared/utils/document-title'
import { formatFileSize, formatPrintDateTime } from '@/shared/utils/format'
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
import { useAuthStore } from '@/stores/auth.store'
type PrintStatus = 'loading' | 'ready' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { getMessage } = useMail()
const message = ref<MessageDetail | null>(null)
const status = ref<PrintStatus>('loading')
const loadError = ref<ApiError | null>(null)
/** 본문 높이를 다 쟀다 — 그 전에는 인쇄하지 않는다 (재기 전의 iframe 은 최소 높이뿐이다) */
const measured = ref<boolean>(false)
/** 처음 다 그렸을 때 한 번만 저절로 인쇄 창을 띄운다 */
let printedOnce = false
let loadSeq = 0
/** 화면을 내렸다 — 기다리던 인쇄 창을 띄우지 않는다 */
let unmounted = false
const messageId = computed(() => {
const value = route.params.messageId
return typeof value === 'string' && value !== '' ? value : null
})
/** 읽기 화면에서 외부 이미지를 보이고 있었다 */
const showImages = computed(() => route.query[MAIL_IMAGES_PARAM] === MAIL_IMAGES_SHOW)
const printer = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
const subject = computed(() => message.value?.subject || MAIL_PRINT_TEXT.noSubject)
const from = computed(() => (message.value?.from ? formatAddress(message.value.from) : MAIL_PRINT_TEXT.noSender))
const attachments = computed(() =>
message.value
? listedAttachments(message.value.attachments).map((item) =>
MAIL_PRINT_TEXT.attachment(item.name, formatFileSize(item.size)),
)
: [],
)
async function load() {
const id = messageId.value
loadSeq += 1
const seq = loadSeq
measured.value = false
message.value = null
loadError.value = null
if (!id) {
status.value = 'notFound'
return
}
status.value = 'loading'
try {
// 화면이 상태를 직접 그린다 — 세션 만료 대화상자는 silent 와 상관없이 뜬다 (useApi)
const detail = await getMessage(id, { silent: true, showImages: showImages.value })
if (seq !== loadSeq) return
message.value = detail
status.value = 'ready'
document.title = documentTitle(detail.subject || MAIL_PRINT_TEXT.noSubject)
} catch (error) {
if (seq !== loadSeq) return
const apiError = toApiError(error)
status.value = apiError.code === 'RES_001' ? 'notFound' : 'error'
loadError.value = apiError
}
}
/** 인쇄할 수 있는 때 — 메일을 받았고 본문 높이를 다 쟀다 */
function canPrint(): boolean {
return !unmounted && status.value === 'ready' && measured.value
}
/**
* 글꼴까지 다 받은 뒤 그린 대로 인쇄 창을 띄운다 — 높이를 잰 값이 그려진 다음(다음 프레임)에.
* 기다리는 사이 화면을 내렸거나 다시 받기 시작했으면 띄우지 않는다
*/
async function printNow() {
if (!canPrint()) return
const seq = loadSeq
await nextTick()
await document.fonts?.ready
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
if (canPrint() && seq === loadSeq) window.print()
}
function onMeasured() {
measured.value = true
if (printedOnce) return
printedOnce = true
void printNow()
}
/** [닫기] — 이 탭을 닫는다. 스크립트가 연 탭이 아니어서 닫히지 않으면(주소로 바로 들어옴 등) 메일 화면으로 */
function close() {
window.close()
if (!window.closed) void router.replace({ name: 'mail' })
}
watch([messageId, showImages], () => void load(), { immediate: true })
onBeforeUnmount(() => {
unmounted = true
})
</script>
<template>
<div class="print-page">
<div
class="print-bar"
role="toolbar"
:aria-label="MAIL_PRINT_TEXT.bar"
>
<span class="print-bar__title">{{ MAIL_PRINT_TEXT.bar }}</span>
<button
type="button"
class="print-bar__print"
:disabled="status !== 'ready' || !measured"
data-print-now
@click="printNow"
>
<AppIcon
name="printer"
:size="15"
/>
<span>{{ MAIL_PRINT_TEXT.print }}</span>
</button>
<button
type="button"
class="print-bar__close"
data-print-close
@click="close"
>
{{ MAIL_PRINT_TEXT.close }}
</button>
</div>
<main
class="print-page__main"
:aria-busy="status === 'loading'"
:aria-label="status === 'ready' ? subject : MAIL_PRINT_TEXT.bar"
>
<article
v-if="status === 'ready' && message"
class="print-sheet"
aria-labelledby="print-subject"
>
<div class="print-sheet__brand">
<span>{{ MAIL_PRINT_TEXT.brand }}</span>
<span v-if="printer">{{ printer }}</span>
</div>
<h1
id="print-subject"
class="print-sheet__subject"
>
{{ subject }}
</h1>
<dl class="print-sheet__meta">
<dt>{{ MAIL_PRINT_TEXT.from }}</dt>
<dd>{{ from }}</dd>
<template v-if="message.to.length > 0">
<dt>{{ MAIL_PRINT_TEXT.to }}</dt>
<dd>{{ formatAddressList(message.to) }}</dd>
</template>
<template v-if="message.cc.length > 0">
<dt>{{ MAIL_PRINT_TEXT.cc }}</dt>
<dd>{{ formatAddressList(message.cc) }}</dd>
</template>
<dt>{{ MAIL_PRINT_TEXT.date }}</dt>
<dd>
<time :datetime="message.receivedAt">{{ formatPrintDateTime(message.receivedAt) }}</time>
</dd>
<template v-if="attachments.length > 0">
<dt>{{ MAIL_PRINT_TEXT.attachments }}</dt>
<dd>
<span
v-for="(attachment, index) in attachments"
:key="index"
class="print-sheet__attachment"
>{{ attachment }}</span>
</dd>
</template>
</dl>
<hr class="print-sheet__rule">
<MailBodyFrame
class="print-sheet__body"
:html-body="message.htmlBody"
:text-body="message.textBody"
:images-shown="message.imagesShown === true"
:title="`메일 본문: ${subject}`"
@measured="onMeasured"
/>
</article>
<StateMessage
v-else-if="status === 'notFound'"
icon="mail"
:title="MAIL_PRINT_TEXT.notFound"
>
<button
type="button"
class="print-page__state-action"
@click="close"
>
{{ MAIL_PRINT_TEXT.close }}
</button>
</StateMessage>
<StateMessage
v-else-if="status === 'error' && loadError"
tone="danger"
icon="alert-circle"
:title="loadError.message"
:trace-id="loadError.traceId"
>
<button
type="button"
class="print-page__state-action"
data-print-retry
@click="load"
>
{{ MAIL_PRINT_TEXT.retry }}
</button>
</StateMessage>
<p
v-else
class="print-page__loading"
>
{{ MAIL_PRINT_TEXT.loading }}
</p>
</main>
</div>
</template>
<style scoped>
/*
* 종이 — A4(210mm) · 인쇄 여백 16mm 15mm. 화면의 종이 폭(안쪽 180mm)과 인쇄의 쪽 폭(@page — 210mm 에서 여백을 뺀 180mm)이 같아야
* 화면에서 잰 본문 높이가 인쇄에서도 맞는다 (위 머리말). 인쇄 단위라 rem 이 아니라 mm 로 적는다 — 두 값을 함께 고친다.
*/
@page {
size: A4;
margin: 16mm 15mm;
}
.print-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-skeleton);
color: var(--color-text);
}
/* 화면에만 보이는 위 줄 (디자인 원본 MailPrint — 흰 바탕 · 아래 선 · 높이 52) */
.print-bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
.print-bar__title {
flex: 1 1 auto;
font-weight: 600;
color: var(--color-text-strong);
}
.print-bar__print,
.print-bar__close,
.print-page__state-action {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
font-weight: 600;
}
.print-bar__print {
background: var(--color-brand);
color: var(--color-on-brand);
}
.print-bar__print:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.print-bar__print:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.print-bar__close {
padding: 0 var(--space-12);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.print-bar__close:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.print-page__state-action {
background: transparent;
color: var(--color-brand);
}
.print-page__state-action:hover {
background: var(--color-brand-subtle);
}
/* 종이가 화면보다 넓으면 가로로 넘긴다 — 폭을 줄이면 잰 높이가 인쇄와 달라진다 */
.print-page__main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-28) var(--space-16) var(--space-36);
overflow-x: auto;
}
.print-page__loading {
margin: var(--space-80) 0 0;
color: var(--color-text-muted);
}
.print-sheet {
flex: 0 0 auto;
width: 210mm;
padding: 16mm 15mm;
display: flex;
flex-direction: column;
gap: var(--space-14);
background: var(--color-surface);
box-shadow: var(--shadow-toast);
color: var(--color-text);
}
.print-sheet__brand {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4) var(--space-12);
padding-bottom: var(--space-8);
border-bottom: var(--border-width) solid var(--color-border-strong);
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
.print-sheet__subject {
margin: var(--space-4) 0 0;
font-size: var(--font-size-heading);
line-height: var(--line-height-heading);
font-weight: 700;
overflow-wrap: anywhere;
}
/* 보낸 사람 · 받는 사람 · 참조 · 날짜 · 첨부 — 이름 칸 72 */
.print-sheet__meta {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: var(--space-3) var(--space-12);
margin: 0;
font-size: var(--font-size-label);
}
.print-sheet__meta dt {
color: var(--color-text-secondary);
}
.print-sheet__meta dd {
margin: 0;
overflow-wrap: anywhere;
}
.print-sheet__attachment {
display: block;
}
.print-sheet__rule {
width: 100%;
height: var(--border-width);
margin: var(--space-2) 0;
border: 0;
background: var(--color-border);
}
/* 본문 — 종이 폭을 다 쓴다(읽기 화면의 글줄 최대 폭을 쓰지 않는다 — 인쇄 폭이 곧 글줄 폭이다) */
.print-sheet .print-sheet__body {
max-width: none;
}
@media print {
.print-page {
min-height: 0;
display: block;
background: var(--color-surface);
}
.print-bar {
display: none;
}
.print-page__main {
display: block;
padding: 0;
overflow: visible;
}
/*
* 쪽의 안쪽 폭(@page 여백을 뺀 180mm)이 화면의 종이 안쪽 폭과 같다. 블록으로 쌓는다 — 긴 본문(iframe)이 쪽을 넘어 이어진다
* (줄 사이는 화면의 간격 14 를 바깥 여백으로)
*/
.print-sheet {
display: block;
width: auto;
padding: 0;
box-shadow: none;
}
.print-sheet > * + * {
margin-top: var(--space-14);
}
}
</style>
@@ -0,0 +1,245 @@
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 MailboxPanel from '@/components/mail/MailboxPanel.vue'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { formatScheduleShortTime, formatScheduleTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { mailViewModeStorageKey } from '@/stores/mail-view.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 MailboxPage from './MailboxPage.vue'
// 예약됨 (9단계 — 디자인 원본 MailSendLater · README "9단계 › 「예약됨」 · 보낸편지함") — /mail/scheduled · /mail/scheduled/<id>
// 목록(받는 사람 · 제목 · 보낼 시각 — 고르는 칸 · 별 없음) · 메일함 패널의 「예약됨」 · 보낸편지함의 "예약 · …" · "보내는 중"
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const FIRST_SEND_AT = '2026-10-30T00:00:00.000Z'
const SECOND_SEND_AT = '2026-11-02T00:00:00.000Z'
function scheduled(id: string, sendAt: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['sent'],
from: USER,
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
preview: '',
receivedAt: '2026-10-29T05:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: { kind: 'scheduled', sendAt },
...partial,
}
}
const S1 = scheduled('s1', FIRST_SEND_AT)
const S2 = scheduled('s2', SECOND_SEND_AT, {
subject: '[공지] 메일 서버 전환 완료 안내',
to: [
{ name: '인프라팀', email: 'infra@arasystem.it.kr' },
{ name: null, email: 'a@arasystem.it.kr' },
{ name: null, email: 'b@arasystem.it.kr' },
],
})
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 5 },
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 3, unreadCount: 0 },
{ id: 'drafts', name: '임시보관함', role: 'drafts', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
{ id: 'custom', name: '거래처', role: null, parentId: null, sortOrder: 3, totalCount: 0, unreadCount: 0 },
]
function detailOf(summary: MessageSummary): MessageDetail {
return {
...summary,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
}
describe('예약됨 · 보낸편지함 (9단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
requests = installMockApi((request) => {
if (request.url === '/scheduled-messages') {
const items = request.params.limit === 1 ? [S1] : [S1, S2]
return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
}
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
if (request.url === '/messages') {
const sending = scheduled('u1', '2026-10-29T06:00:10.000Z', { pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } })
const plain = scheduled('p1', FIRST_SEND_AT, { pendingSend: null })
return ok({ items: [S1, sending, plain], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
}
if (request.url.startsWith('/messages/')) return ok(detailOf(S1))
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
await useMailStore().loadMailboxes()
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
function rows(wrapper: VueWrapper) {
return wrapper.findAll('.scheduled-row').map((row) => ({
who: row.get('.scheduled-row__who').text(),
subject: row.get('.scheduled-row__subject').text(),
time: row.get('.scheduled-row__time').text(),
href: row.get('a').attributes('href'),
}))
}
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => {
const { wrapper } = await mountAt('/mail/scheduled')
expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 })
expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨')
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례')
expect(rows(wrapper)).toEqual([
{
who: '받는 사람: 이도윤',
subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
time: formatScheduleTime(FIRST_SEND_AT),
href: '/mail/scheduled/s1',
},
{
who: '받는 사람: 인프라팀 외 2명',
subject: '[공지] 메일 서버 전환 완료 안내',
time: formatScheduleTime(SECOND_SEND_AT),
href: '/mail/scheduled/s2',
},
])
// 고르는 칸 · 별 · 알림 권하기 줄은 없다
expect(wrapper.find('.mail-checkbox').exists()).toBe(false)
expect(wrapper.find('[aria-label="중요 표시"]').exists()).toBe(false)
expect(wrapper.find('.reader').exists()).toBe(true)
// 패널의 「예약됨」 수도 이 목록의 total 로 맞춘다
expect(useMailStore().scheduledCount).toBe(2)
})
it('목록 보기 — 한 줄 세 칸 · 메일을 열면 본문을 넓게(예약한 메일 — 청록 안내 줄)', async () => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const { wrapper } = await mountAt('/mail/scheduled')
expect(wrapper.findAll('.scheduled-row--list')).toHaveLength(2)
expect(wrapper.find('table').exists()).toBe(false)
const opened = await mountAt('/mail/scheduled/s1')
expect(opened.wrapper.find('.scheduled-row').exists()).toBe(false)
expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)} 에 보낼 메일입니다.`)
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
})
it('보낸편지함 — 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · 보내기 취소를 기다리는 동안 "보내는 중"', async () => {
const { wrapper } = await mountAt('/mail/sent')
const times = wrapper.findAll('.message-row__time')
expect(times.map((time) => time.text())).toEqual([
`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`,
'보내는 중',
expect.not.stringContaining('예약'),
])
expect(times.map((time) => time.classes().includes('is-pending'))).toEqual([true, true, false])
})
it('보낸편지함(목록 보기) — 날짜 칸도 같은 글, 칸보다 길면 전체를 title 로', async () => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const { wrapper } = await mountAt('/mail/sent')
const dates = wrapper.findAll('td.mail-table__date')
expect(dates[0]?.text()).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
expect(dates[0]?.attributes('title')).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
expect(dates[1]?.text()).toBe('보내는 중')
expect(dates[2]?.attributes('title')).toBeUndefined()
})
describe('메일함 패널의 「예약됨」 — 예약한 메일이 있을 때만 보낸편지함 아래 (시계 · 통 수)', () => {
async function mountPanel(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
await useMailStore().loadMailboxes()
const wrapper = mount(MailboxPanel, { global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const labels = (wrapper: VueWrapper) =>
wrapper.findAll('.mailbox-tree__list').at(0)?.findAll('li').map((item) => item.text()) ?? []
it('없으면 그리지 않고, 생기면 보낸편지함 바로 아래 — 오른쪽에 통 수', async () => {
const wrapper = await mountPanel('/mail/inbox')
expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
await useMailStore().loadScheduledCount()
await flushPromises()
expect(labels(wrapper)).toEqual(['받은편지함안 읽음 5', '보낸편지함', '예약됨2통', '임시보관함'])
const link = wrapper.get('[data-scheduled-mailbox]')
expect(link.attributes('href')).toBe('/mail/scheduled')
expect(link.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
expect(link.attributes('aria-current')).toBeUndefined()
})
it('예약됨을 보고 있으면 지금 메일함으로 표시한다 — 다른 메일함은 표시하지 않는다', async () => {
useMailStore().openScheduled()
const wrapper = await mountPanel('/mail/scheduled')
expect(wrapper.get('[data-scheduled-mailbox]').attributes('aria-current')).toBe('page')
expect(wrapper.findAll('[aria-current="page"]')).toHaveLength(1)
})
it('예약됨을 보는 동안은 0통이 되어도 줄이 남는다(숫자 없이) — 떠나면 사라진다 (리뷰 #12)', async () => {
const store = useMailStore()
const wrapper = await mountPanel('/mail/scheduled')
store.scheduledCount = 0
await flushPromises()
const link = wrapper.get('[data-scheduled-mailbox]')
expect(link.attributes('aria-current')).toBe('page')
expect(link.find('.scheduled-mailbox__count').exists()).toBe(false)
const router = wrapper.vm.$router
await router.push('/mail/inbox')
await flushPromises()
expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
})
})
})
+28 -5
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
// 메일함 · 검색 결과 · 예약됨 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?… ·
// /mail/scheduled · /mail/scheduled/:messageId — 9단계)
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -14,7 +16,9 @@ import MessageList from '@/components/mail/MessageList.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import MessageReader from '@/components/mail/MessageReader.vue'
import MessageTable from '@/components/mail/MessageTable.vue'
import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
@@ -28,11 +32,13 @@ function paramOf(value: string | string[] | undefined): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
/** 예약됨 · 예약됨에서 연 메일 (9단계) */
const isScheduled = computed(() => isScheduledRouteName(route.name))
const mailboxId = computed(() => (isSearching.value || isScheduled.value ? null : paramOf(route.params.mailboxId)))
const messageId = computed(() => paramOf(route.params.messageId))
const isListView = computed(() => viewStore.mode === 'list')
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
watch(
mailboxId,
@@ -42,6 +48,15 @@ watch(
{ immediate: true },
)
// 예약됨에 들어올 때 — 목록에서 메일을 열고 닫는 것으로는 다시 받지 않는다 (같은 목록으로 돌아오면 보이던 채로 새로 고친다)
watch(
isScheduled,
(scheduled) => {
if (scheduled) void mailStore.openScheduled()
},
{ immediate: true },
)
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
watch(
@@ -98,11 +113,19 @@ watch(
<ListReadNav :message-id="messageId" />
</template>
</MessageReader>
<ScheduledMessageList
v-else-if="isScheduled"
variant="list"
/>
<MessageTable v-else />
</template>
<template v-else-if="hasList">
<MessageList />
<ScheduledMessageList
v-if="isScheduled"
variant="split"
/>
<MessageList v-else />
<MessageReader />
</template>
@@ -0,0 +1,303 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { BlockedSender } from '@/shared/types/mail'
import { formatBlockedDay } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailBlocksPage from './MailBlocksPage.vue'
import blocksPageSource from './MailBlocksPage.vue?raw'
// 설정 › 메일 › 차단한 주소 (디자인 원본 SettingsSendRead · 계약 2절 "보낸 사람 차단")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const BLOCKS: BlockedSender[] = [
{ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T01:00:00.000Z' },
{ address: '@promo-mail.example', blockedAt: '2026-10-21T01:00:00.000Z' },
{ address: 'sales@unknown-shop.example', blockedAt: '2026-10-18T01:00:00.000Z' },
]
interface Server {
blocks?: BlockedSender[] | MockResult
/** 엔진에서 우리 규칙이 켜져 있는가 (처음 true) */
isActive?: boolean
add?: (request: MockRequest) => MockResult
remove?: (request: MockRequest) => MockResult
}
describe('MailBlocksPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
requests = installMockApi((request) => {
if (request.method === 'GET') {
const blocks = server.blocks ?? BLOCKS
return Array.isArray(blocks) ? ok({ senders: blocks, isActive: server.isActive ?? true }) : blocks
}
if (request.method === 'POST') {
if (server.add) return server.add(request)
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-10-29T06:00:00.000Z' }, 201)
}
if (server.remove) return server.remove(request)
return ok({ address: decodeURIComponent(request.url.slice('/mail-blocks/'.length)) })
})
const router = createTestRouter()
await router.push('/settings/mail/blocks')
await router.isReady()
const wrapper = mount(MailBlocksPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const rows = (wrapper: VueWrapper) =>
wrapper.findAll('.blocks-page__row').map((row) => [row.get('.blocks-page__address').text(), row.get('.blocks-page__day').text()])
const input = (wrapper: VueWrapper) => wrapper.get('#mail-block-input')
const message = (wrapper: VueWrapper) => wrapper.get('[data-block-message]').text()
async function add(wrapper: VueWrapper, value: string) {
await input(wrapper).setValue(value)
await wrapper.get('[data-block-add]').trigger('click')
await flushPromises()
}
it('머리 "차단한 주소" + "3 / 500" · 목록(주소 · "10월 29일 차단" | "도메인 전체 · …") · 칸 · [차단 풀기]', async () => {
const wrapper = await mountPage()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-blocks'])
expect(wrapper.get('#blocks-page-title').text()).toBe('차단한 주소')
expect(wrapper.get('[data-block-count]').text()).toBe('3 / 500')
expect(rows(wrapper)).toEqual([
['news@cloudbrief.example', `${formatBlockedDay(BLOCKS[0]?.blockedAt ?? '')} 차단`],
['@promo-mail.example', `도메인 전체 · ${formatBlockedDay(BLOCKS[1]?.blockedAt ?? '')} 차단`],
['sales@unknown-shop.example', `${formatBlockedDay(BLOCKS[2]?.blockedAt ?? '')} 차단`],
])
expect(input(wrapper).attributes()).toMatchObject({
placeholder: 'kim@example.com 또는 @example.com',
inputmode: 'email',
type: 'text',
})
expect(wrapper.get('label[for="mail-block-input"]').text()).toBe('차단할 주소')
expect(wrapper.findAll('[data-block-remove]').map((button) => button.attributes('aria-label'))).toEqual([
'news@cloudbrief.example 차단 풀기',
'@promo-mail.example 차단 풀기',
'sales@unknown-shop.example 차단 풀기',
])
})
it('칸 아래 안내(role=status)는 비어 있어도 그려 둔다 — 영역이 늘 있어야 차단 성공 글을 스크린리더가 읽는다 (리뷰 #8)', async () => {
const wrapper = await mountPage()
const region = wrapper.get('[data-block-message]')
expect(region.attributes('role')).toBe('status')
expect(region.text()).toBe('')
// 빈 동안 자리를 숨기는 규칙은 display:none 이 아니다 (영역째 접근성 트리에서 빠진다)
const css = blocksPageSource.slice(blocksPageSource.indexOf('<style'))
const empty = /\.blocks-page__add \.form-message:empty\s*\{([^}]*)\}/.exec(css)
expect(empty?.[1]).toBeDefined()
expect(empty?.[1]).not.toMatch(/display:\s*none/)
})
it('비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."', async () => {
const wrapper = await mountPage({ blocks: [] })
expect(wrapper.get('[data-block-empty]').text()).toBe(
'차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
)
expect(wrapper.get('[data-block-count]').text()).toBe('0 / 500')
})
it('[차단] — 소문자로 맞춰 막고 목록 맨 위에 넣는다 · 칸을 비우고 칸 아래 "…을(를) 차단했습니다." — Enter 도 같다', async () => {
const wrapper = await mountPage()
await add(wrapper, ' Spam@Evil.Example ')
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
{ address: 'spam@evil.example' },
])
expect(rows(wrapper)[0]?.[0]).toBe('spam@evil.example')
expect(wrapper.get('[data-block-count]').text()).toBe('4 / 500')
expect((input(wrapper).element as HTMLInputElement).value).toBe('')
expect(message(wrapper)).toBe('‘spam@evil.example’을(를) 차단했습니다.')
await input(wrapper).setValue('@evil-ads.example')
await input(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(rows(wrapper)[0]?.[0]).toBe('@evil-ads.example')
})
it.each([
['형식', 'kim@', '메일 주소나 @도메인 형식으로 써 주세요.'],
['이미 차단함', 'NEWS@cloudbrief.example', '이미 차단한 주소입니다.'],
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
['사내 도메인', '@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
[
'여러 사람이 쓰는 메일 서비스',
'@gmail.com',
'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
],
])('보내기 전 검사 — %s: 칸 아래 오류(빨강) · 잘못된 입력, 보내지 않는다', async (_label, value, text) => {
const wrapper = await mountPage()
await add(wrapper, value)
expect(message(wrapper)).toBe(text)
expect(wrapper.get('[data-block-message]').classes()).toContain('form-message--error')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
expect(input(wrapper).attributes('aria-describedby')).toBe('mail-block-message')
expect(requests.some((request) => request.method === 'POST')).toBe(false)
// 칸을 고치면 내린다
await input(wrapper).setValue(`${value}x`)
expect(message(wrapper)).toBe('')
expect(input(wrapper).attributes('aria-invalid')).toBeUndefined()
})
it('500개를 다 막았으면 "500개까지 차단할 수 있습니다."', async () => {
const full = Array.from({ length: 500 }, (_, index) => ({ address: `s${index}@spam.example`, blockedAt: '2026-10-21T01:00:00Z' }))
const wrapper = await mountPage({ blocks: full })
await add(wrapper, 'new@spam.example')
expect(message(wrapper)).toBe('500개까지 차단할 수 있습니다.')
expect(requests.some((request) => request.method === 'POST')).toBe(false)
})
it.each(['VAL_001', 'MAIL_013'] as const)('서버가 거절하면(%s) 칸 아래에 사전 문구 — 알림은 띄우지 않는다', async (code) => {
const wrapper = await mountPage({ add: () => fail(400, code) })
await add(wrapper, 'spam@evil.example')
expect(message(wrapper)).toBe(ERROR_CODE_MESSAGES[code])
expect(useToastStore().toasts).toEqual([])
expect((input(wrapper).element as HTMLInputElement).value).toBe('spam@evil.example')
})
it('[차단 풀기] — 바로 풀고 "‘…’ 차단을 풀었습니다." + [되돌리기](다시 막는다) · 초점은 다음 줄로', async () => {
const wrapper = await mountPage()
const second = wrapper.findAll('[data-block-remove]')[1]
;(second?.element as HTMLButtonElement).focus()
await second?.trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/mail-blocks/%40promo-mail.example',
])
expect(rows(wrapper).map(([address]) => address)).toEqual(['news@cloudbrief.example', 'sales@unknown-shop.example'])
expect(document.activeElement).toBe(wrapper.findAll('[data-block-remove]')[1]?.element)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘@promo-mail.example’ 차단을 풀었습니다.')
if (!toast?.action || !('run' in toast.action)) throw new Error('단추가 없다')
toast.action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'POST').map((request) => request.data)).toEqual([
{ address: '@promo-mail.example' },
])
expect(rows(wrapper)[0]?.[0]).toBe('@promo-mail.example')
expect(useToastStore().toasts.map((item) => item.message)).toContain('‘@promo-mail.example’을(를) 다시 차단했습니다.')
})
it('마지막 줄을 풀면 초점은 칸으로 · 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 — 오류 알림 없이', async () => {
const wrapper = await mountPage({ blocks: [BLOCKS[0] as BlockedSender], remove: () => fail(404, 'RES_001') })
const only = wrapper.get('[data-block-remove]')
;(only.element as HTMLButtonElement).focus()
await only.trigger('click')
await flushPromises()
expect(rows(wrapper)).toEqual([])
expect(wrapper.find('[data-block-empty]').exists()).toBe(true)
expect(document.activeElement).toBe(input(wrapper).element)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['success', '‘news@cloudbrief.example’ 차단을 풀었습니다.'],
])
})
it('풀지 못하면(메일 서버 장애) 목록 그대로 — 알림은 인터셉터', async () => {
const wrapper = await mountPage({ remove: () => fail(503, 'MAIL_001') })
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
await flushPromises()
expect(rows(wrapper)).toHaveLength(3)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
const INACTIVE = '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.'
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
const off = await mountPage({ isActive: false })
expect(off.get('[data-blocks-inactive]').text()).toBe(INACTIVE)
expect(off.get('[data-blocks-inactive]').attributes('role')).toBe('status')
expect(off.get('[data-blocks-inactive]').find('button').exists()).toBe(false)
off.unmount()
const empty = await mountPage({ isActive: false, blocks: [] })
expect(empty.find('[data-blocks-inactive]').exists()).toBe(false)
empty.unmount()
const on = await mountPage()
expect(on.find('[data-blocks-inactive]').exists()).toBe(false)
})
it('차단하거나 풀면 서버가 규칙을 다시 저장하므로 줄을 내린다 — 이미 풀려 있던 풀기(404)는 저장하지 않았으니 그대로', async () => {
const wrapper = await mountPage({ isActive: false, remove: () => fail(404, 'RES_001') })
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
await flushPromises()
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(true)
await add(wrapper, 'spam@evil.example')
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(false)
})
it('풀어서 저장했으면 줄을 내린다', async () => {
const wrapper = await mountPage({ isActive: false })
await wrapper.findAll('[data-block-remove]')[0]?.trigger('click')
await flushPromises()
expect(wrapper.find('[data-blocks-inactive]').exists()).toBe(false)
})
})
it('못 받으면 사전 문구와 "다시 시도" — 다시 받으면 그린다', async () => {
let reply: MockResult = fail(503, 'MAIL_001')
const wrapper = await mountPage({
get blocks() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
reply = ok({ senders: BLOCKS, isActive: true })
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(rows(wrapper)).toHaveLength(3)
})
})
@@ -0,0 +1,390 @@
<script setup lang="ts">
// 설정 › 메일 › 차단한 주소 (디자인 원본 SettingsSendRead · README "9단계 › 설정 · 차단한 주소" · 계약 2절 "보낸 사람 차단")
// — /settings/mail/blocks · 탭 제목 "차단한 주소"
// 머리 "차단한 주소" + "3 / 500" + 설명 → 더하기 줄(칸 "차단할 주소" + [차단]) → 목록(줄마다 주소 · "10월 29일 차단" | "도메인 전체 · …" + [차단 풀기] 36)
// 또는 빈 상태 글.
// - 들어올 때마다 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다 — mail-block.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - [차단]: 서버와 같은 규칙을 먼저 본다(mail-block.ts — 형식 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 이미 차단 · 500개) —
// 걸리면 칸 아래 오류(보내지 않는다). 서버의 거절(VAL_001 · MAIL_013 — 엔진 규칙 한도)도 칸 아래에 사전 문구로(알림 없음).
// 막으면 칸을 비우고 칸 아래에 "‘…’을(를) 차단했습니다." — 목록 맨 위에 생긴다. Enter 도 [차단]과 같다(한글 조합 중의 Enter 는 글자 확정)
// - [차단 풀기]: 바로 풀고 알림 "‘…’ 차단을 풀었습니다." + [되돌리기](다시 차단 — useSenderBlock). 이미 받은 메일은 옮기지 않는다.
// 누른 줄이 사라지면 초점을 다음 줄(없으면 앞 줄 · 칸)로 옮긴다
// - 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 부재중 화면과 같은 결(단추 없음, 차단하거나 풀면 다시 켜진다)
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useSenderBlock } from '@/composables/useSenderBlock'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_BLOCK_MAX, MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { formatBlockedDay, formatCount } from '@/shared/utils/format'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { isDomainBlock, mailBlockInputOf, type MailBlockInputProblem } from '@/shared/utils/mail-block'
import { objectParticle } from '@/shared/utils/korean'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockStore } from '@/stores/mail-block.store'
/** 칸 아래에 알리는 서버 거절 — 형식(VAL_001) · 엔진 규칙 한도(MAIL_013). 인터셉터 알림을 끈다 */
const INLINE_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013']
const INPUT_ID = 'mail-block-input'
const MESSAGE_ID = 'mail-block-message'
const PROBLEM_TEXT: Readonly<Record<MailBlockInputProblem, string>> = {
invalid: MAIL_BLOCK_TEXT.invalid,
duplicate: MAIL_BLOCK_TEXT.duplicate,
own: MAIL_BLOCK_TEXT.own,
publicDomain: MAIL_BLOCK_TEXT.publicDomain,
full: MAIL_BLOCK_TEXT.full,
}
const authStore = useAuthStore()
const blockStore = useMailBlockStore()
const senderBlock = useSenderBlock()
const inputRef = ref<HTMLInputElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const input = ref<string>('')
/** 칸 아래 오류 — 보내기 전 검사 · 서버 거절 */
const error = ref<string | null>(null)
/** 칸 아래 안내 — 막은 뒤 */
const notice = ref<string | null>(null)
const adding = ref<boolean>(false)
/** [차단 풀기]를 누르고 기다리는 줄 */
const removing = ref<string | null>(null)
const senders = computed<readonly BlockedSender[]>(() => blockStore.senders)
const countText = computed(() => MAIL_BLOCK_TEXT.count(formatCount(senders.value.length), formatCount(MAIL_BLOCK_MAX)))
const ready = computed(() => blockStore.status === 'ready' && blockStore.blockList !== null)
/** 차단한 주소가 있는데 지금은 동작하지 않는다 (엔진의 규칙이 꺼짐) */
const inactive = computed(() => blockStore.blockList?.isActive === false && senders.value.length > 0)
function dayText(sender: BlockedSender): string {
const day = formatBlockedDay(sender.blockedAt)
return isDomainBlock(sender.address) ? MAIL_BLOCK_TEXT.domainBlockedOn(day) : MAIL_BLOCK_TEXT.blockedOn(day)
}
function onInput() {
error.value = null
notice.value = null
}
async function add() {
if (adding.value || !ready.value) return
notice.value = null
const checked = mailBlockInputOf(input.value, authStore.user?.email ?? '', senders.value)
if ('problem' in checked) {
error.value = PROBLEM_TEXT[checked.problem]
inputRef.value?.focus()
return
}
adding.value = true
error.value = null
try {
const sender = await blockStore.add(checked.address, { silent: INLINE_CODES })
input.value = ''
notice.value = MAIL_BLOCK_TEXT.blocked(sender.address, objectParticle(sender.address))
} catch (caught) {
const apiError = toApiError(caught)
// 칸 아래에 — 그 밖의 실패(메일 서버 장애 등)는 인터셉터가 알렸다
if (INLINE_CODES.includes(apiError.code)) {
error.value = getErrorMessage(apiError.code)
inputRef.value?.focus()
}
} finally {
adding.value = false
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || isImeComposing(event)) return
event.preventDefault()
void add()
}
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 칸 */
async function focusAfterRemoval(index: number) {
await nextTick()
const active = document.activeElement
if (active !== null && active !== document.body) return
const buttons = Array.from(listRef.value?.querySelectorAll<HTMLButtonElement>('[data-block-remove]') ?? [])
const target = buttons[index] ?? buttons[index - 1]
if (target) target.focus()
else inputRef.value?.focus()
}
async function remove(sender: BlockedSender) {
if (removing.value) return
const index = senders.value.findIndex((item) => item.address === sender.address)
removing.value = sender.address
try {
if (await senderBlock.unblock(sender.address)) await focusAfterRemoval(index)
} finally {
removing.value = null
}
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void blockStore.load()
</script>
<template>
<SettingsPageFrame
title-id="blocks-page-title"
:title="MAIL_BLOCK_TEXT.title"
:description="MAIL_BLOCK_TEXT.description"
>
<template
v-if="ready"
#title-aside
>
<span
class="blocks-page__count"
data-block-count
>{{ countText }}</span>
</template>
<MailLoadError
v-if="blockStore.status === 'error' && blockStore.error"
:error="blockStore.error"
leave-label=""
@retry="blockStore.load()"
/>
<template v-else-if="ready">
<MailFilterNotice
v-if="inactive"
icon="alert-circle"
:message="MAIL_BLOCK_TEXT.inactive"
data-blocks-inactive
/>
<div class="blocks-page__add">
<label
:for="INPUT_ID"
class="blocks-page__label"
>{{ MAIL_BLOCK_TEXT.addLabel }}</label>
<div class="blocks-page__add-row">
<input
:id="INPUT_ID"
ref="inputRef"
v-model="input"
type="text"
inputmode="email"
autocomplete="off"
spellcheck="false"
class="form-input blocks-page__input"
:placeholder="MAIL_BLOCK_TEXT.addPlaceholder"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error || notice ? MESSAGE_ID : undefined"
@input="onInput"
@keydown="onKeydown"
>
<button
type="button"
class="blocks-page__add-button"
:aria-disabled="adding ? 'true' : undefined"
:aria-busy="adding ? 'true' : undefined"
data-block-add
@click="add"
>
{{ MAIL_BLOCK_TEXT.add }}
</button>
</div>
<p
:id="MESSAGE_ID"
:class="['form-message', { 'form-message--error': error }]"
role="status"
data-block-message
>
<template v-if="error">
<AppIcon
name="alert-circle"
:size="14"
/>
<span>{{ error }}</span>
</template>
<span v-else-if="notice">{{ notice }}</span>
</p>
</div>
<ul
v-if="senders.length > 0"
ref="listRef"
class="blocks-page__list"
:aria-label="MAIL_BLOCK_TEXT.title"
>
<li
v-for="sender in senders"
:key="sender.address"
class="blocks-page__row"
:data-block-row="sender.address"
>
<span class="blocks-page__text">
<span class="blocks-page__address">{{ sender.address }}</span>
<span class="blocks-page__day">{{ dayText(sender) }}</span>
</span>
<button
type="button"
class="blocks-page__remove"
:aria-label="`${sender.address} ${MAIL_BLOCK_TEXT.unblock}`"
:aria-disabled="removing ? 'true' : undefined"
:aria-busy="removing === sender.address ? 'true' : undefined"
data-block-remove
@click="remove(sender)"
>
{{ MAIL_BLOCK_TEXT.unblock }}
</button>
</li>
</ul>
<p
v-else
class="blocks-page__empty"
data-block-empty
>
{{ MAIL_BLOCK_TEXT.empty }}
</p>
</template>
<div
v-else
class="blocks-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
.blocks-page__count {
font-size: var(--font-size-label);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.blocks-page__add {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: var(--form-wide-max-width);
}
.blocks-page__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.blocks-page__add-row {
display: flex;
gap: var(--space-8);
}
.blocks-page__input {
flex: 1 1 auto;
min-width: 0;
}
.blocks-page__add-button {
flex: 0 0 auto;
min-height: var(--size-touch);
padding: 0 var(--space-18);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.blocks-page__add-button:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
.blocks-page__add-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
/*
* 비어 있어도 그린다 — role="status" 영역은 늘 배치에 남아 있어야 글이 생길 때 스크린리더가 읽는다(display:none 이면 영역째 사라진다 — 리뷰 #8).
* 빈 동안은 높이가 0 이고, 묶음의 칸 사이(gap)만큼 위로 당겨 자리를 차지하지 않는다
*/
.blocks-page__add .form-message:empty {
margin-top: calc(var(--space-6) * -1);
}
/* 목록 — 테두리 상자 안의 줄 52 (디자인 원본 SettingsSendRead) */
.blocks-page__list {
max-width: var(--form-wide-max-width);
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.blocks-page__row {
display: flex;
align-items: center;
gap: var(--space-12);
min-height: var(--size-header-min);
padding: 0 var(--space-8) 0 var(--space-14);
}
.blocks-page__row + .blocks-page__row {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.blocks-page__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.blocks-page__address {
font-weight: 600;
overflow-wrap: anywhere;
}
.blocks-page__day {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* [차단 풀기] 36 — 밀도 예외 (--size-notice-action, 노란 줄 안 단추와 같다) */
.blocks-page__remove {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.blocks-page__remove:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.blocks-page__remove[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.blocks-page__empty {
max-width: var(--note-max-width);
margin: 0;
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
color: var(--color-text-secondary);
}
.blocks-page__placeholder {
min-height: 16rem;
}
</style>
@@ -0,0 +1,303 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailSendReadPage from './MailSendReadPage.vue'
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
interface Server {
settings?: MailSettings
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
load?: MockResult
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MailSendReadPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
let stored: MailSettings = { ...DEFAULTS, ...server.settings }
requests = installMockApi((request) => {
if (request.method === 'GET') return server.load ?? ok(stored)
if (server.patch) return server.patch(request)
stored = { ...stored, ...(request.data as Partial<MailSettings>) }
return ok(stored)
})
const router = createTestRouter()
await router.push('/settings/mail/send-read')
await router.isReady()
const wrapper = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const pressed = (wrapper: VueWrapper) =>
wrapper.findAll('[data-undo-seconds]').map((button) => [button.text(), button.attributes('aria-pressed')])
const checkedImages = (wrapper: VueWrapper) =>
wrapper.findAll('[data-external-images]').filter((input) => (input.element as HTMLInputElement).checked).map((input) => input.attributes('value'))
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('들어올 때 받은 값으로 그린다 — 보내기 취소(끔 · 5초 · 10초 · 20초 · 30초) · 외부 이미지 라디오 셋 · 안내 상자', async () => {
const wrapper = await mountPage({ settings: { undoSendSeconds: 20, externalImages: 'trusted' } })
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-settings'])
expect(wrapper.get('#send-read-page-title').text()).toBe('보내기 · 읽기')
expect(pressed(wrapper)).toEqual([
['끔', 'false'],
['5초', 'false'],
['10초', 'false'],
['20초', 'true'],
['30초', 'false'],
])
expect(wrapper.get('#send-read-undo-hint').text()).toBe(
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
)
expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([
'늘 묻기',
'주소록 · 사내 직원의 메일은 늘 보기',
'늘 보기',
])
expect(checkedImages(wrapper)).toEqual(['trusted'])
expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다')
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
it('고르는 대로 그 칸만 PATCH 하고 "저장했습니다"(role=status) — 다시 고르면 내렸다가 저장되면 다시', async () => {
let release: () => void = () => undefined
const wrapper = await mountPage({
patch: (request) =>
new Promise<MockResult>((resolve) => {
release = () => resolve(ok({ ...DEFAULTS, ...(request.data as Partial<MailSettings>) }))
}),
})
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
// 화면을 먼저 바꾼다
expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('30초')
expect(wrapper.get('[data-settings-status]').text()).toBe('')
release()
await flushPromises()
expect(wrapper.get('[data-settings-status]').attributes('role')).toBe('status')
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
await wrapper.get('[data-external-images="always"]').setValue(true)
expect(wrapper.get('[data-settings-status]').text()).toBe('')
release()
await flushPromises()
expect(patches()).toEqual([{ undoSendSeconds: 30 }, { externalImages: 'always' }])
expect(checkedImages(wrapper)).toEqual(['always'])
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('지금 값을 다시 누르면 보내지 않는다', async () => {
const wrapper = await mountPage()
await wrapper.get('[data-undo-seconds="10"]').trigger('click')
await flushPromises()
expect(patches()).toEqual([])
})
it('저장하지 못하면 고르기 전 값으로 되돌린다 — 알림은 인터셉터', async () => {
const wrapper = await mountPage({ patch: () => fail(400, 'VAL_001') })
await wrapper.get('[data-undo-seconds="0"]').trigger('click')
await flushPromises()
await wrapper.get('[data-external-images="always"]').setValue(true)
await flushPromises()
expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('10초')
expect(checkedImages(wrapper)).toEqual(['ask'])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
describe('연달아 고르면 — 칸마다 저장은 한 번에 하나, 앞 저장이 끝난 뒤 마지막으로 고른 것만 보낸다 (2026-10-29 리뷰 #3)', () => {
/** 보낸 PATCH 를 차례로 잡아 둔다 — 테스트가 응답을 정한다. 서버는 받은 차례대로 저장한다(stored) */
async function mountHeld() {
const held: { data: Partial<MailSettings>; resolve: (result: MockResult) => void }[] = []
let stored: MailSettings = { ...DEFAULTS }
const wrapper = await mountPage({
patch: (request) =>
new Promise<MockResult>((resolve) => {
const data = request.data as Partial<MailSettings>
held.push({ data, resolve })
}),
})
/** n 번째 PATCH 를 서버가 저장하고 답한다 */
function save(index: number) {
const request = held[index]
if (!request) throw new Error(`PATCH ${index} 가 없다`)
stored = { ...stored, ...request.data }
request.resolve(ok(stored))
}
function reject(index: number, result: MockResult) {
const request = held[index]
if (!request) throw new Error(`PATCH ${index} 가 없다`)
request.resolve(result)
}
return { wrapper, held, save, reject, stored: () => stored }
}
const pressedLabel = (wrapper: VueWrapper) => pressed(wrapper).find(([, value]) => value === 'true')?.[0]
it('기다리는 동안 고른 것은 보내지 않고 기억했다가, 앞 저장이 끝나면 마지막 것 하나만 보낸다 — 화면 · 서버가 같다', async () => {
const { wrapper, save, stored } = await mountHeld()
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
await wrapper.get('[data-undo-seconds="20"]').trigger('click')
// 화면은 마지막으로 고른 값 · 요청은 하나
expect(pressedLabel(wrapper)).toBe('20초')
expect(patches()).toEqual([{ undoSendSeconds: 5 }])
save(0)
await flushPromises()
expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 20 }])
expect(pressedLabel(wrapper)).toBe('20초')
expect(wrapper.get('[data-settings-status]').text()).toBe('')
save(1)
await flushPromises()
expect(stored().undoSendSeconds).toBe(20)
expect(pressedLabel(wrapper)).toBe('20초')
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('외부 이미지 「늘 보기」 → 「늘 묻기」를 빠르게 — 늦게 끝난 앞 저장 뒤에 「늘 묻기」를 보내 DB 도 늘 묻기다 (화면 늘 묻기 · DB 늘 보기로 갈리지 않는다)', async () => {
const { wrapper, save, stored } = await mountHeld()
await wrapper.get('[data-external-images="always"]').setValue(true)
await wrapper.get('[data-external-images="ask"]').setValue(true)
expect(checkedImages(wrapper)).toEqual(['ask'])
expect(patches()).toEqual([{ externalImages: 'always' }])
save(0)
await flushPromises()
save(1)
await flushPromises()
expect(patches()).toEqual([{ externalImages: 'always' }, { externalImages: 'ask' }])
expect(stored().externalImages).toBe('ask')
expect(checkedImages(wrapper)).toEqual(['ask'])
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('기다리는 동안 처음 값으로 되돌려 고르면 — 앞 저장이 끝난 뒤 처음 값을 다시 보낸다 (서버는 앞 값을 저장했으므로)', async () => {
const { wrapper, save, stored } = await mountHeld()
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
await wrapper.get('[data-undo-seconds="10"]').trigger('click')
save(0)
await flushPromises()
save(1)
await flushPromises()
expect(patches()).toEqual([{ undoSendSeconds: 30 }, { undoSendSeconds: 10 }])
expect(stored().undoSendSeconds).toBe(10)
expect(pressedLabel(wrapper)).toBe('10초')
})
it('앞 저장이 실패해도 그사이 고른 것을 보낸다 — 마지막이 성공하면 그 값 · "저장했습니다"', async () => {
const { wrapper, save, reject } = await mountHeld()
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
reject(0, fail(503, 'MAIL_001'))
await flushPromises()
expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 30 }])
expect(pressedLabel(wrapper)).toBe('30초')
save(1)
await flushPromises()
expect(pressedLabel(wrapper)).toBe('30초')
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('마지막으로 고른 것의 저장이 실패하면 서버가 마지막으로 확인해 준 값으로 되돌린다 — "저장했습니다"는 없다', async () => {
const { wrapper, save, reject } = await mountHeld()
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
await wrapper.get('[data-undo-seconds="30"]').trigger('click')
save(0)
await flushPromises()
// 같은 코드의 알림은 3초 동안 한 번이다(인터셉터) — 이 파일에서 아직 쓰지 않은 코드로 실패시킨다
reject(1, fail(500, 'SYS_001'))
await flushPromises()
// 서버가 확인한 값은 5초 (앞 저장)
expect(pressedLabel(wrapper)).toBe('5초')
expect(wrapper.get('[data-settings-status]').text()).toBe('')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
})
it('두 칸은 따로 저장한다 — 둘 다 끝나야 "저장했습니다"', async () => {
const { wrapper, save } = await mountHeld()
await wrapper.get('[data-undo-seconds="0"]').trigger('click')
await wrapper.get('[data-external-images="trusted"]').setValue(true)
expect(patches()).toEqual([{ undoSendSeconds: 0 }, { externalImages: 'trusted' }])
save(0)
await flushPromises()
expect(wrapper.get('[data-settings-status]').text()).toBe('')
save(1)
await flushPromises()
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('저장하는 중에 떠났다가 돌아오면(다시 받음) 남은 저장을 세지 않는다 — 그 뒤 저장하면 "저장했습니다" (리뷰 #7)', async () => {
const { wrapper, save } = await mountHeld()
await wrapper.get('[data-undo-seconds="5"]').trigger('click')
wrapper.unmount()
// 다시 들어온다 — 화면이 설정을 새로 받는다
const again = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
mounted.push(again)
await flushPromises()
save(0)
await flushPromises()
await again.get('[data-undo-seconds="20"]').trigger('click')
save(1)
await flushPromises()
expect(again.get('[data-settings-status]').text()).toBe('저장했습니다')
})
})
it('못 받으면 사전 문구와 "다시 시도"', async () => {
const wrapper = await mountPage({ load: fail(503, 'MAIL_001') })
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(wrapper.find('[data-undo-seconds]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
})
@@ -0,0 +1,243 @@
<script setup lang="ts">
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · README "9단계 › 설정 · 보내기 · 읽기" · 계약 2절 "보내기 · 읽기 설정")
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { EXTERNAL_IMAGES_OPTIONS, MAIL_SETTINGS_TEXT, UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
const settingsStore = useMailSettingsStore()
function undoLabel(seconds: UndoSendSeconds): string {
return seconds === 0 ? MAIL_SETTINGS_TEXT.undoOff : MAIL_SETTINGS_TEXT.undoSeconds(seconds)
}
function chooseUndo(seconds: UndoSendSeconds) {
void settingsStore.update('undoSendSeconds', seconds)
}
function chooseImages(option: ExternalImagesSetting) {
void settingsStore.update('externalImages', option)
}
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
void settingsStore.load()
</script>
<template>
<SettingsPageFrame
title-id="send-read-page-title"
:title="MAIL_SETTINGS_TEXT.title"
:description="MAIL_SETTINGS_TEXT.description"
>
<template #actions>
<p
class="send-read__status"
role="status"
data-settings-status
>
<template v-if="settingsStore.saved">
<AppIcon
name="check"
:size="14"
:stroke-width="2.2"
/>
<span>{{ MAIL_SETTINGS_TEXT.saved }}</span>
</template>
</p>
</template>
<MailLoadError
v-if="settingsStore.status === 'error' && settingsStore.error"
:error="settingsStore.error"
leave-label=""
@retry="settingsStore.load()"
/>
<template v-else-if="settingsStore.status === 'ready' && settingsStore.settings">
<section
class="send-read__group"
aria-labelledby="send-read-undo-title"
>
<h2
id="send-read-undo-title"
class="send-read__title"
>
{{ MAIL_SETTINGS_TEXT.undoTitle }}
</h2>
<div
role="group"
class="segmented segmented--outline send-read__undo"
aria-labelledby="send-read-undo-title"
aria-describedby="send-read-undo-hint"
>
<button
v-for="seconds in UNDO_SEND_OPTIONS"
:key="seconds"
type="button"
class="segmented__item"
:aria-pressed="settingsStore.settings.undoSendSeconds === seconds"
:data-undo-seconds="seconds"
@click="chooseUndo(seconds)"
>
{{ undoLabel(seconds) }}
</button>
</div>
<p
id="send-read-undo-hint"
class="form-message"
>
{{ MAIL_SETTINGS_TEXT.undoHint }}
</p>
</section>
<hr class="send-read__rule">
<fieldset class="send-read__group send-read__images">
<legend class="send-read__title">
{{ MAIL_SETTINGS_TEXT.imagesTitle }}
</legend>
<label
v-for="option in EXTERNAL_IMAGES_OPTIONS"
:key="option"
class="send-read__option"
>
<input
type="radio"
name="send-read-images"
class="send-read__radio"
:value="option"
:checked="settingsStore.settings.externalImages === option"
:aria-describedby="`send-read-images-${option}-hint`"
:data-external-images="option"
@change="chooseImages(option)"
>
<span class="send-read__option-text">
<span class="send-read__option-label">{{ MAIL_SETTINGS_TEXT.images[option].label }}</span>
<span
:id="`send-read-images-${option}-hint`"
class="send-read__option-hint"
>{{ MAIL_SETTINGS_TEXT.images[option].hint }}</span>
</span>
</label>
<p
class="send-read__note"
data-images-note
>
{{ MAIL_SETTINGS_TEXT.imagesNote }}
</p>
</fieldset>
</template>
<div
v-else
class="send-read__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* "저장했습니다" — 초록 12.5px + ✓ (디자인 원본 SettingsSendRead · 서명 화면과 같은 색) */
.send-read__status {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-success-text);
}
.send-read__group {
display: flex;
flex-direction: column;
gap: var(--space-10);
margin: 0;
padding: 0;
border: 0;
}
.send-read__title {
margin: 0;
padding: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.send-read__undo {
align-self: flex-start;
}
.send-read__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
/* 외부 이미지 — 줄마다 라디오 18 · 이름(굵게) + 아래 설명 (디자인 원본 SettingsSendRead) */
.send-read__images {
gap: var(--space-6);
}
.send-read__images .send-read__title {
margin-bottom: var(--space-6);
}
.send-read__option {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding: var(--space-8) 0;
cursor: pointer;
}
.send-read__radio {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.send-read__option-text {
display: flex;
flex-direction: column;
}
.send-read__option-label {
font-weight: 600;
}
.send-read__option-hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 회색 안내 상자 (디자인 원본 SettingsSendRead — 바탕 #F6F7F9) */
.send-read__note {
max-width: var(--note-max-width);
margin: var(--space-4) 0 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 받는 동안의 자리 */
.send-read__placeholder {
min-height: 16rem;
}
</style>
@@ -9,7 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -34,7 +35,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -47,6 +48,8 @@ describe('SettingsLayout', () => {
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
['필터', '/settings/mail/filters'],
['보내기 · 읽기', '/settings/mail/send-read'],
['차단한 주소', '/settings/mail/blocks'],
['알림', '/settings/mail/notify'],
],
])
@@ -82,6 +85,27 @@ describe('SettingsLayout', () => {
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
})
it('설정 › 메일 › 보내기 · 읽기 · 차단한 주소 (9단계) — 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/mail/send-read')
expect(router.currentRoute.value.name).toBe('settings-mail-send-read')
expect(router.currentRoute.value.meta).toMatchObject({ title: '보내기 · 읽기', requiresAuth: true })
await router.push('/settings/mail/blocks')
expect(router.currentRoute.value.name).toBe('settings-mail-blocks')
expect(router.currentRoute.value.meta).toMatchObject({ title: '차단한 주소', requiresAuth: true })
})
it('차단한 주소 메뉴는 원 + 빗금 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/blocks')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('차단한 주소')
expect(current.find('circle').attributes()).toMatchObject({ cx: '12', cy: '12', r: '10' })
expect(current.find('line').attributes()).toMatchObject({ x1: '4.93', y1: '4.93', x2: '19.07', y2: '19.07' })
})
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/filters')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
+38 -2
View File
@@ -1,8 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
import {
MAIL_SCHEDULED_MESSAGE_ROUTE,
MAIL_SCHEDULED_ROUTE,
MAIL_SEARCH_MESSAGE_ROUTE,
MAIL_SEARCH_ROUTE,
} from '@/shared/utils/mail-list-source'
// 앱의 라우트 표 — 라우터(index.ts)와 라우트 테스트가 함께 쓴다 (이 파일은 라우터를 만들지 않는다)
@@ -25,6 +31,15 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/LoginPage.vue'),
meta: { guestOnly: true, title: '로그인' },
},
{
// 인쇄 화면 (9단계 — 디자인 README "9단계 › 인쇄 화면") — 앱 틀 없이 메일 한 통. 읽기 화면 ⋯ › 인쇄 · Ctrl+P 가 새 탭으로 연다.
// 읽기 화면에서 외부 이미지를 보이고 있었으면 ?images=show. /mail 의 하위가 아니다(메일 레이아웃 · 앱 틀을 그리지 않는다) —
// 고정 경로라 /mail/:mailboxId/:messageId 보다 앞선다
path: '/mail/print/:messageId',
name: MAIL_PRINT_ROUTE,
component: () => import('@/pages/mail/MailPrintPage.vue'),
meta: { requiresAuth: true, title: '메일 인쇄' },
},
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
@@ -47,6 +62,15 @@ export const routes: RouteRecordRaw[] = [
component: MailboxPage,
meta: { title: '메일 검색' },
},
// 예약됨 /mail/scheduled · 예약됨에서 연 메일 /mail/scheduled/<메일 id> (9단계 — 계약 2절 "보내기 취소 · 예약 발송")
// 검색과 같이 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
{ path: 'scheduled', name: MAIL_SCHEDULED_ROUTE, component: MailboxPage, meta: { title: '예약됨' } },
{
path: 'scheduled/:messageId',
name: MAIL_SCHEDULED_MESSAGE_ROUTE,
component: MailboxPage,
meta: { title: '예약됨' },
},
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
@@ -71,7 +95,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림) + 하위 화면.
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -103,6 +127,18 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailFilterPage.vue'),
meta: { title: '메일 필터' },
},
{
path: 'mail/send-read',
name: SETTINGS_ROUTES.sendRead,
component: () => import('@/pages/settings/MailSendReadPage.vue'),
meta: { title: '보내기 · 읽기' },
},
{
path: 'mail/blocks',
name: SETTINGS_ROUTES.blocks,
component: () => import('@/pages/settings/MailBlocksPage.vue'),
meta: { title: '차단한 주소' },
},
{
path: 'mail/notify',
name: SETTINGS_ROUTES.notify,
+12
View File
@@ -283,6 +283,18 @@ export const ICONS = {
],
/** 연락처 ⋯ 「주소 복사」 */
copy: [rect(9, 9, 13, 13, 2), path('M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1')],
// ── 9단계: 보내기 취소 · 예약 발송 · 인쇄 · 보낸 사람 차단 (디자인 원본 MailReadTools · MailPrint · MailSendLater) ──
/** 읽기 화면 ⋯ 「인쇄」 · 인쇄 화면의 [인쇄] */
printer: [
polyline('6 9 6 2 18 2 18 9'),
path('M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'),
rect(6, 14, 12, 8),
],
/** 「예약 발송…」 · 메일함 패널의 「예약됨」 · 예약 안내 줄 · 예약 알림 */
clock: [circle(12, 12, 10), polyline('12 6 12 12 16 14')],
/** 「보낸 사람 차단」 · 차단한 보낸 사람의 메일 안내 줄 · 설정의 차단한 주소 (원 + 빗금) */
block: [circle(12, 12, 10), line(4.93, 4.93, 19.07, 19.07)],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
+112
View File
@@ -0,0 +1,112 @@
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면" · "설정 · 차단한 주소", 원본 MailReadTools · SettingsSendRead)
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 서버도 400 VAL_001 */
export const MAIL_BLOCK_MAX = 500
/** 도메인 차단의 앞 글자 — "@spam.example" 은 그 도메인에서 오는 메일 모두 */
export const MAIL_BLOCK_DOMAIN_PREFIX = '@'
/** 도메인 길이 — 서버와 같다 (253자) */
export const MAIL_BLOCK_DOMAIN_MAX_LENGTH = 253
/**
* 여러 사람이 쓰는 메일 서비스 — 도메인으로는 막을 수 없다(주소로는 된다).
* ⚠️ 짝: backend `src/modules/mail/mail.constants.ts` 의 `PUBLIC_MAIL_DOMAINS`(계약 2절 "보낸 사람 차단"의 "검사") — 한쪽을 고치면 둘 다, 이 차례 그대로
* (서버의 mail-blocks.spec.ts 가 글자 · 차례를 그대로 본다).
*/
export const PUBLIC_MAIL_DOMAINS: readonly string[] = [
'gmail.com',
'googlemail.com',
'naver.com',
'daum.net',
'hanmail.net',
'kakao.com',
'nate.com',
'empas.com',
'hanmir.com',
'korea.com',
'dreamwiz.com',
'outlook.com',
'hotmail.com',
'live.com',
'msn.com',
'yahoo.com',
'yahoo.co.kr',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'proton.me',
'protonmail.com',
'gmx.com',
'gmx.net',
'mail.com',
'zoho.com',
'yandex.com',
'qq.com',
'163.com',
]
export const MAIL_BLOCK_TEXT = {
// ── 읽기 화면 ⋯ ──
menuBlock: '보낸 사람 차단',
menuUnblock: '보낸 사람 차단 풀기',
/** 500개를 다 막았을 때 항목 아래 까닭 (보드에 없다) */
fullNote: `차단한 주소는 ${MAIL_BLOCK_MAX}개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.`,
// ── 차단 확인 창 ──
dialogTitle: '보낸 사람 차단',
dialogMessage: '앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.',
scopeGroup: '차단할 범위',
scopeAddress: '이 주소만',
scopeDomain: '이 도메인 전체',
/** "@cloudbrief.example 에서 오는 메일 모두" */
scopeDomainHint: (domain: string) => `${domain} 에서 오는 메일 모두`,
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
settingsHint: '차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.',
confirm: '차단',
cancel: '취소',
// ── 알림 (차단 · 풀기 · 되돌리기) ──
/** particle 은 korean.ts 의 objectParticle — 영문 주소는 "을(를)" */
blockedAndMoved: (address: string, particle: string) =>
`‘${address}’${particle} 차단하고 이 메일을 스팸메일함으로 옮겼습니다.`,
/** 옮기지 않았을 때 (「이 메일도 …」를 껐다 · 이미 스팸메일함) — 보드에 없다 */
blocked: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다.`,
unblocked: (address: string) => `‘${address}’ 차단을 풀었습니다.`,
/** [되돌리기] 뒤 — 보드에 없다 */
blockRestored: (address: string) => `‘${address}’ 차단을 풀고 메일을 되돌렸습니다.`,
unblockRestored: (address: string, particle: string) => `‘${address}’${particle} 다시 차단했습니다.`,
undo: '되돌리기',
// ── 스팸메일함에서 읽을 때 ──
senderNotice: '차단한 보낸 사람의 메일입니다.',
unblock: '차단 풀기',
// ── 설정 › 메일 › 차단한 주소 ──
title: '차단한 주소',
/** "3 / 500" */
count: (count: string, max: string) => `${count} / ${max}`,
description:
'이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.',
addLabel: '차단할 주소',
addPlaceholder: 'kim@example.com 또는 @example.com',
add: '차단',
/** 칸 아래 오류 */
invalid: '메일 주소나 @도메인 형식으로 써 주세요.',
duplicate: '이미 차단한 주소입니다.',
own: '내 주소 · 사내 도메인은 차단할 수 없습니다.',
publicDomain: 'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
full: `${MAIL_BLOCK_MAX}개까지 차단할 수 있습니다.`,
/** 목록 줄 아래 작은 글 — "10월 29일 차단" · "도메인 전체 · 10월 21일 차단" */
blockedOn: (day: string) => `${day} 차단`,
domainBlockedOn: (day: string) => `도메인 전체 · ${day} 차단`,
empty: '차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
/**
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
*/
inactive: '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.',
} as const
@@ -0,0 +1,32 @@
// 인쇄 화면 (계약 2절 "인쇄" · 디자인 README "9단계 › 인쇄 화면", 원본 MailPrint)
import { APP_NAME } from '@/shared/constants/brand'
/** 인쇄 화면의 라우트 이름 — 앱 틀 없이 메일 한 통 (router/routes.ts) */
export const MAIL_PRINT_ROUTE = 'mail-print'
/** 인쇄 단축키 — 읽기 화면에서 Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 연다 */
export const MAIL_PRINT_SHORTCUT_KEYS: readonly string[] = ['Mod', 'P']
export const MAIL_PRINT_TEXT = {
/** 읽기 화면 ⋯ 의 첫 항목 */
menu: '인쇄',
/** 화면에만 보이는 위 줄 */
bar: '인쇄 화면',
print: '인쇄',
close: '닫기',
/** 인쇄되는 머리 줄 왼쪽 */
brand: `${APP_NAME} 메일`,
from: '보낸 사람',
to: '받는 사람',
cc: '참조',
date: '날짜',
attachments: '첨부',
/** 첨부 한 줄 — "체크리스트.xlsx (48 KB)" */
attachment: (name: string, size: string) => `${name} (${size})`,
noSubject: '(제목 없음)',
noSender: '(보낸 사람 없음)',
loading: '메일을 불러오는 중',
notFound: '메일을 찾을 수 없습니다',
retry: '다시 시도',
} as const
@@ -0,0 +1,113 @@
// 보내기 취소 · 예약 발송 · 예약됨 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
/** 예약할 수 있는 가장 이른 때 — 지금부터 5분 뒤 (서버도 같은 한도로 400 VAL_001) */
export const SCHEDULE_MIN_LEAD_MS = 5 * 60_000
/** 예약할 수 있는 가장 늦은 때 — 7일 안 (엔진 한도 — 계약 2절) */
export const SCHEDULE_MAX_AHEAD_MS = 7 * 24 * 60 * 60_000
/** 「직접 고르기」의 시각 단위 — 30분 */
export const SCHEDULE_SLOT_MINUTES = 30
/** 하루의 분 */
export const MINUTES_PER_DAY = 24 * 60
/** 고르기의 정해 둔 시각(브라우저 시간대) — 오늘 오후 6:00 · 내일 오전 9:00 · 내일 오후 1:00 · 다음 주 월요일 오전 9:00 */
export const SCHEDULE_PRESET_HOURS = {
todayEvening: 18,
tomorrowMorning: 9,
tomorrowAfternoon: 13,
nextMonday: 9,
} as const
export type SchedulePresetId = keyof typeof SCHEDULE_PRESET_HOURS
/** 「직접 고르기」를 처음 열 때의 시각 — 내일 오전 9:00 (보드의 처음 값) */
export const SCHEDULE_CUSTOM_DEFAULT_HOUR = 9
/** 월요일 — Date.getDay() */
export const MONDAY = 1
export const DAYS_PER_WEEK = 7
/** 예약됨 목록 · 패널 개수를 물을 때의 한 쪽 — 패널은 total 만 쓴다 (limit=1) */
export const SCHEDULED_COUNT_PAGE_SIZE = 1
export const SEND_LATER_TEXT = {
// ── 쓰기 화면의 [보내기 ▾] ──
/** ▾ 단추의 이름 */
menuButton: '보내기 방법 더 보기',
menuLabel: '보내기 방법',
scheduleItem: '예약 발송…',
// ── 예약 창 ──
dialogTitle: '예약 발송',
presetGroup: '보낼 시각',
presets: {
todayEvening: '오늘 오후',
tomorrowMorning: '내일 오전',
tomorrowAfternoon: '내일 오후',
nextMonday: '다음 주 월요일',
} satisfies Record<SchedulePresetId, string>,
custom: '직접 고르기',
customDate: '날짜',
customTime: '시각',
/** zone 은 브라우저가 알려 주는 시간대 이름(Intl — "한국 표준시" 등) */
note: (zone: string) => `이 PC 의 시간(${zone})으로 보냅니다 · 7일 안에서 고를 수 있습니다.`,
/** 시간대 이름을 모를 때 */
noteWithoutZone: '이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.',
tooSoon: '지금부터 5분 뒤 이후의 시각을 고르세요.',
tooLate: '7일 안의 시각만 고를 수 있습니다.',
confirm: '예약',
cancel: '취소',
// ── 보낸 뒤 알림 ──
/** [보내기 취소] — 붙잡아 둔 동안만 (남은 시간 막대) */
undo: '보내기 취소',
undone: '보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.',
/** 쓰기 화면으로 가지 못했을 때(지금 화면의 나가기 확인에서 머묾 등 — 보드에 없다) — 단추는 [고치기](edit) */
undoneStayed: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
/** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00 에 보냅니다." (보드 그대로 띄어 쓴다) */
scheduled: (time: string) => `${time} 에 보냅니다.`,
viewScheduled: '예약됨 보기',
// ── 예약됨 (메일함 패널 · 목록 · 읽기 화면) ──
/** 메일함 패널의 「예약됨」 · 목록 머리 */
mailbox: '예약됨',
/** 목록 머리의 개수 — "2통 · 보낼 시각 차례" */
listCount: (count: string) => `${count}통 · 보낼 시각 차례`,
/** 목록이 비었을 때 (보드에 없다 — 보낸 · 취소한 뒤 그 자리에서 비면) */
emptyTitle: '예약한 메일이 없습니다',
emptyHint: '쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.',
/** 표 · 목록의 보낼 시각 칸 이름 */
sendAtColumn: '보낼 시각',
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
notice: (time: string) => `${time} 에 보낼 메일입니다.`,
edit: '고치기',
cancelSchedule: '예약 취소',
canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
/** [고치기] 뒤 — 쓰기 화면이 그 메일로 열린다 (보드에 없다) */
editing: '예약을 취소했습니다. 고친 뒤 다시 보내 주세요.',
/**
* 아직 보내지 않은 메일(보내는 중 · 예약)의 [삭제] 확인 창 (보드에 없다 — 되돌릴 수 없는 발송 취소라 처음 초점은 「취소」).
* 보내기 취소를 기다리는 메일도 같은 창이다 — 취소하지 않고 옮기면 엔진은 그대로 보낸다
*/
deleteTitle: '보내지 않은 메일을 삭제할까요?',
deleteMessage: '보내지 않고 휴지통으로 옮깁니다.',
deleteConfirm: '삭제',
deleteCancel: '취소',
deleted: '보내지 않고 휴지통으로 옮겼습니다.',
// ── 보낸편지함 · 검색 결과의 줄 ──
/** time 은 formatScheduleShortTime — "예약 · 10월 30일 오전 9:00" */
rowScheduled: (time: string) => `예약 · ${time}`,
/** 보내기 취소를 기다리는 동안 */
rowSending: '보내는 중',
/** 아직 보내지 않은 메일의 고르기 칸(꺼짐)의 까닭 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (보드에 없다) */
heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
} as const
/** 보낼 시각 뒤 이만큼 기다렸다 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다 — 엔진이 풀어도 새 메일 확인 · 실시간 알림은 바뀌지 않는다 */
export const PENDING_RELEASE_GRACE_MS = 2_000
/** 다시 본 뒤에도 시각이 지난 채 붙잡힌 것으로 보이면(서버가 늦음 · 이 PC 시계가 빠름) 이 간격으로 다시 본다 */
export const PENDING_RELEASE_RECHECK_MS = 5_000
/** 그렇게 다시 보는 횟수 — 넘으면 그 발송은 그만 본다 (다음 새 메일 확인 · 화면 이동이 맞춘다) */
export const PENDING_RELEASE_MAX_RECHECKS = 3
/**
* [보내기 취소] 알림의 마감을 설정한 초로 셀 때 빼는 몫 — 서버가 붙잡기 시작한 뒤 응답이 오기까지 지난 시간.
* 마감이 서버보다 늦으면 늦게 누른 [보내기 취소]가 "이미 보낸 메일"(MAIL_014)이 된다
*/
export const UNDO_SEND_DEADLINE_MARGIN_MS = 1_000
@@ -0,0 +1,34 @@
// 설정 › 메일 › 보내기 · 읽기 (계약 2절 "보내기 · 읽기 설정" · 디자인 README "9단계 › 설정 · 보내기 · 읽기", 원본 SettingsSendRead)
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
/** 보내기 취소 시간 — 고르는 차례 그대로 (끔 · 5 · 10 · 20 · 30초) */
export const UNDO_SEND_OPTIONS: readonly UndoSendSeconds[] = [0, 5, 10, 20, 30]
/** 외부 이미지 — 고르는 차례 그대로 */
export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask', 'trusted', 'always']
export const MAIL_SETTINGS_TEXT = {
title: '보내기 · 읽기',
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
description: '고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.',
saved: '저장했습니다',
undoTitle: '보내기 취소',
undoOff: '끔',
undoSeconds: (seconds: number) => `${seconds}초`,
undoHint:
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
imagesTitle: '외부 이미지',
images: {
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
trusted: {
label: '주소록 · 사내 직원의 메일은 늘 보기',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
},
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
} as const
+23
View File
@@ -151,6 +151,13 @@ export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
export const MAIL_PARTIAL_SEND_TEXT = {
/** count 는 formatCount 로 만든 글자, recipients 는 아래 recipient 를 separator 로 이은 것 (+ others) */
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다 — ${recipients}`,
/**
* 엔진이 붙잡아 둔 발송(보내기 취소를 기다리는 중 · 9단계)이면 아직 나가지 않았다 — "받지 못합니다" + [보내기 취소]
* (디자인 원본 MailSendLater: "보냈지만 1명은 받지 못합니다 — 주소 (까닭)")
*/
pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다 — ${recipients}`,
/** 예약 발송이면 — "예약했지만 N명은 받지 못합니다 — …" + [예약됨 보기] (보드에 없다) */
scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다 — ${recipients}`,
/** 주소와 까닭 사이를 띄운다 — 긴 주소 뒤에서 줄을 바꿔 까닭 낱말이 쪼개지지 않게 */
recipient: (email: string, reason: string) => `${email} (${reason})`,
separator: ', ',
@@ -321,6 +328,22 @@ export const MAIL_READER_MORE_TEXT = {
createFilter: '이런 메일로 필터 만들기',
} as const
// ── 외부 이미지 보기 (계약 2절 "외부 이미지 보기" · 디자인 README "9단계 › 읽기 화면") ─────────
/** GET /messages/{id} 의 쿼리 — images=show 면 외부 이미지를 중계 주소로 바꿔 넣은 본문 (인쇄 화면 주소도 같은 쿼리를 쓴다) */
export const MAIL_IMAGES_PARAM = 'images'
export const MAIL_IMAGES_SHOW = 'show'
/**
* 서버가 바꿔 넣는 중계 주소의 앞부분 — 본문 틀(MailBodyFrame)은 외부 이미지를 보일 때만 이 주소로 시작하는 그림을 남긴다
* (서버의 전역 prefix /api 와 같다 — 화면과 API 는 같은 주소, 계약 머리말).
* 짝: backend `src/modules/mail/mail.constants.ts` 의 `MAIL_IMAGES_ROUTE`(라우트 경로) — 경로를 바꾸면 둘 다
*/
export const MAIL_IMAGE_PROXY_PATH = '/api/mail-images/'
/** 읽기 화면의 노란 안내 줄 + [이미지 보기](36 — 밀도 예외 --size-notice-action) */
export const MAIL_IMAGES_TEXT = {
blocked: '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.',
show: '이미지 보기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
@@ -1,4 +1,5 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터")
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
@@ -10,6 +11,10 @@ export const SETTINGS_ROUTES = {
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
filters: 'settings-mail-filters',
/** 보내기 취소 · 외부 이미지 (9단계) */
sendRead: 'settings-mail-send-read',
/** 차단한 주소 (9단계) */
blocks: 'settings-mail-blocks',
notify: 'settings-mail-notify',
} as const
@@ -39,6 +44,8 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
{ routeName: SETTINGS_ROUTES.filters, label: '필터', icon: 'filter' },
{ routeName: SETTINGS_ROUTES.sendRead, label: '보내기 · 읽기', icon: 'send' },
{ routeName: SETTINGS_ROUTES.blocks, label: '차단한 주소', icon: 'block' },
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
+68 -1
View File
@@ -22,6 +22,18 @@ export interface Mailbox {
unreadCount: number
}
/**
* 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계).
* undo: [보내기]를 누른 뒤 보내기 취소를 기다리는 중(설정한 초) · scheduled: 예약 발송
*/
export type PendingSendKind = 'undo' | 'scheduled'
export interface PendingSend {
/** 엔진이 보낼 시각(UTC ISO) — 화면은 남은 시간을 설정한 초가 아니라 이 시각으로 센다 */
sendAt: string
kind: PendingSendKind
}
export interface MessageSummary {
id: string
threadId: string
@@ -37,6 +49,11 @@ export interface MessageSummary {
hasAttachment: boolean
/** 바이트 */
size: number
/**
* 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 보낸편지함 날짜 칸의 "보내는 중" · "예약 · 시각",
* 읽기 화면의 예약 안내 줄 (9단계). 그 밖에는 null. 필드가 없으면(9단계 앞 서버) null 로 본다
*/
pendingSend?: PendingSend | null
}
export interface Attachment {
@@ -66,8 +83,13 @@ export interface MessageDetail extends MessageSummary {
/** 서버가 정화한 HTML — 그래도 격리된 iframe 에만 그린다 (MailBodyFrame) */
htmlBody: string | null
textBody: string | null
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 + [이미지 보기] (보이게 했으면 false) */
hasBlockedImages: boolean
/**
* 외부 이미지를 중계 주소(/api/mail-images/…)로 바꿔 넣었으면 true — `images=show` 로 물었거나 설정이 보이게 했다 (9단계 "외부 이미지 보기").
* 본문 틀이 이때만 그 주소의 그림을 그린다(MailBodyFrame). 필드가 없으면(9단계 앞 서버) false 로 본다
*/
imagesShown?: boolean
/**
* HTML 이 있었지만 서버의 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true
* → textBody 를 서식 없이 보이며 안내한다 (textBody 도 없으면 "표시할 수 없음"). 필드가 없으면(이전 서버) false 로 본다
@@ -170,6 +192,8 @@ export interface SendMessagePayload {
inReplyTo?: string
/** 이어 쓴 임시 저장본 id — 보낸 뒤 서버가 그 초안을 지운다 (계약 2절 "임시 저장") */
draftId?: string
/** 예약 발송(UTC ISO) — 지금부터 5분 뒤 ~ 7일 안. 밖이면 400 VAL_001 (9단계 "보내기 취소 · 예약 발송") */
sendAt?: string
}
/** 엔진이 받는 사람을 받지 않은 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등) */
@@ -190,6 +214,49 @@ export interface SentMessage {
id: string
/** 엔진이 받지 않은 사람 — 없으면 빈 배열 */
rejectedRecipients: RejectedRecipient[]
/**
* 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null.
* useMail().sendMessage 가 늘 값(또는 null)으로 맞춘다 — 필드가 없는 옛 서버면 null
*/
pendingSend: PendingSend | null
}
// ── 보내기 취소 · 예약 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) ─────────────
/** DELETE /scheduled-messages/{id} 응답 — 붙잡은 발송을 취소하고 임시보관함으로 옮긴 그 메일(같은 id) */
export interface ScheduledMessageCancel {
draftId: string
}
// ── 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) ─────────────
/** 보내기 취소 시간(초) — 0 이면 바로 보낸다 */
export type UndoSendSeconds = 0 | 5 | 10 | 20 | 30
/** 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 */
export type ExternalImagesSetting = 'ask' | 'trusted' | 'always'
/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 */
export interface MailSettings {
undoSendSeconds: UndoSendSeconds
externalImages: ExternalImagesSetting
}
// ── 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) ─────────────
export interface BlockedSender {
/** 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로) */
address: string
/** 차단한 시각(UTC ISO) */
blockedAt: string
}
/** GET /mail-blocks */
export interface MailBlockList {
/** 최근에 차단한 것부터, 500개까지 */
senders: BlockedSender[]
/** 엔진에서 우리 규칙(필터 · 부재중과 같은 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다 */
isActive: boolean
}
// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
+30
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
formatBlockedDay,
formatDraftSavedTime,
formatPrintDateTime,
formatScheduleShortTime,
formatScheduleTime,
formatFileSize,
formatMailDateTime,
formatMailListTime,
@@ -159,3 +163,29 @@ describe('initialOf', () => {
expect(initialOf(null, 'ara.kim@arasystem.test')).toBe('A')
})
})
describe('9단계 — 예약 시각 · 인쇄 날짜 · 차단한 날', () => {
/** 2026-10-30(금) 오전 9시 (서울) */
const FRIDAY_NINE = '2026-10-30T00:00:00.000Z'
it('예약 시각은 "10월 30일 (금) 오전 9:00" — 숫자(ms)로도 받는다', () => {
expect(formatScheduleTime(FRIDAY_NINE, SEOUL)).toBe('10월 30일 (금) 오전 9:00')
expect(formatScheduleTime(Date.parse(FRIDAY_NINE), SEOUL)).toBe('10월 30일 (금) 오전 9:00')
expect(formatScheduleTime('나중에', SEOUL)).toBe('')
})
it('보낸편지함 날짜 칸의 예약 시각은 요일 없이 "10월 30일 오전 9:00"', () => {
expect(formatScheduleShortTime(FRIDAY_NINE, SEOUL)).toBe('10월 30일 오전 9:00')
})
it('인쇄 화면의 날짜는 "2026년 10월 26일 (월) 오전 10:42" (보드 그대로 — 요일은 괄호 안)', () => {
expect(formatPrintDateTime('2026-10-26T01:42:00Z', SEOUL)).toBe('2026년 10월 26일 (월) 오전 10:42')
expect(formatPrintDateTime('', SEOUL)).toBe('')
})
it('차단한 날은 "10월 29일", 올해가 아니면 연도를 붙인다', () => {
const now = new Date('2026-10-29T06:00:00Z')
expect(formatBlockedDay('2026-10-29T01:00:00Z', now, SEOUL)).toBe('10월 29일')
expect(formatBlockedDay('2025-12-31T01:00:00Z', now, SEOUL)).toBe('2025년 12월 31일')
})
})
+60
View File
@@ -225,6 +225,66 @@ export function formatMailDateTime(value: string, timeZone?: string): string {
}).format(date)
}
function dateOf(value: string | number): Date | null {
return typeof value === 'number' ? (Number.isFinite(value) ? new Date(value) : null) : parseDate(value)
}
/**
* 예약 발송 시각 (디자인 원본 MailSendLater: "10월 30일 (금) 오전 9:00") — 예약 창의 고르기 · 예약 알림 · 예약됨 목록 · 읽기 화면의 예약 안내 줄.
* 예약은 7일 안이라 연도를 넣지 않는다
*/
export function formatScheduleTime(value: string | number, timeZone?: string): string {
const date = dateOf(value)
if (!date) return ''
return new Intl.DateTimeFormat('ko-KR', {
timeZone,
month: 'long',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
}).format(date)
}
/** 보낸편지함 날짜 칸의 예약 시각 — 요일 없이 (디자인 원본 MailSendLater: "예약 · 10월 30일 오전 9:00") */
export function formatScheduleShortTime(value: string | number, timeZone?: string): string {
const date = dateOf(value)
if (!date) return ''
return new Intl.DateTimeFormat('ko-KR', {
timeZone,
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date)
}
/**
* 인쇄 화면의 날짜 (디자인 원본 MailPrint: "2026년 10월 26일 (월) 오전 10:42").
* Intl 은 연도를 넣으면 요일 괄호를 빼므로("… 26일 월 …") 날짜 · 요일 · 시각을 따로 만들어 잇는다
*/
export function formatPrintDateTime(value: string, timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const day = new Intl.DateTimeFormat('ko-KR', { timeZone, year: 'numeric', month: 'long', day: 'numeric' }).format(date)
const weekday = new Intl.DateTimeFormat('ko-KR', { timeZone, weekday: 'short' }).format(date)
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
return `${day} (${weekday}) ${time}`
}
/** 차단한 날 (디자인 원본 SettingsSendRead: "10월 29일 차단") — 올해가 아니면 연도를 붙인다 */
export function formatBlockedDay(value: string, now: Date = new Date(), timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const sameYear = calendarDayOf(date, timeZone).year === calendarDayOf(now, timeZone).year
return new Intl.DateTimeFormat('ko-KR', {
timeZone,
year: sameYear ? undefined : 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
export const TRACE_CODE_LENGTH = 8
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { buildEditorShortcuts } from '@/shared/constants/mail-editor'
import { formatShortcut, isApplePlatform } from './keyboard-shortcut'
import { formatShortcut, isApplePlatform, isShortcutEvent } from './keyboard-shortcut'
describe('isApplePlatform — TipTap(prosemirror-keymap)의 Mod 판정과 같다', () => {
it.each(['MacIntel', 'iPhone', 'iPad', 'iPod'])('맥 · 아이폰 · 아이패드: %s', (platform) => {
@@ -44,3 +44,36 @@ describe('buildEditorShortcuts — 편집기 단축키 (디자인 README "단축
expect(Object.values(shortcuts).every((shortcut) => !shortcut.label.includes('Ctrl'))).toBe(true)
})
})
describe('isShortcutEvent — 누른 키가 그 단축키인가 (9단계 읽기 화면의 Ctrl+P)', () => {
const press = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init)
const PRINT = ['Mod', 'P']
it('윈도우 · 리눅스는 Ctrl, 맥은 ⌘ — 대소문자는 가리지 않는다', () => {
expect(isShortcutEvent(press({ key: 'p', ctrlKey: true }), PRINT, false)).toBe(true)
expect(isShortcutEvent(press({ key: 'P', ctrlKey: true }), PRINT, false)).toBe(true)
expect(isShortcutEvent(press({ key: 'p', metaKey: true }), PRINT, true)).toBe(true)
expect(isShortcutEvent(press({ key: 'p', metaKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'p', ctrlKey: true }), PRINT, true)).toBe(false)
})
it('적지 않은 수정 글쇠를 함께 눌렀거나 다른 글쇠면 아니다', () => {
expect(isShortcutEvent(press({ key: 'p', ctrlKey: true, shiftKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'p', ctrlKey: true, altKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'p' }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'o', ctrlKey: true }), PRINT, false)).toBe(false)
})
it('라틴 글자가 아닌 배열(러시아어 · 그리스어 등)은 자판의 자리(code)로 본다 — 라틴 배열(드보락)은 글자 그대로 (리뷰 #12)', () => {
// 러시아어 배열의 Ctrl+P 는 'з', 그리스어는 'π' 로 온다 — 브라우저 인쇄처럼 P 자리면 인쇄다
expect(isShortcutEvent(press({ key: 'з', code: 'KeyP', ctrlKey: true }), PRINT, false)).toBe(true)
expect(isShortcutEvent(press({ key: 'π', code: 'KeyP', metaKey: true }), PRINT, true)).toBe(true)
// 드보락의 P 자리는 'l' — 그 배열의 Ctrl+L 이지 인쇄가 아니다. 드보락의 'p'(R 자리)는 인쇄다
expect(isShortcutEvent(press({ key: 'l', code: 'KeyP', ctrlKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'p', code: 'KeyR', ctrlKey: true }), PRINT, false)).toBe(true)
// 다른 자리 · 수정 글쇠 규칙은 그대로
expect(isShortcutEvent(press({ key: 'з', code: 'KeyO', ctrlKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'з', code: 'KeyP', ctrlKey: true, shiftKey: true }), PRINT, false)).toBe(false)
expect(isShortcutEvent(press({ key: 'з', code: 'KeyP' }), PRINT, false)).toBe(false)
})
})
@@ -46,6 +46,27 @@ export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText
return { label: [...modifiers, ...rest].join(''), aria }
}
/** 라틴 글자 하나 — 글자 단축키의 키 이름 · 라틴 배열에서 누른 글자 */
const LATIN_LETTER = /^[a-z]$/i
/**
* 누른 키가 그 단축키인가 — 'Mod' 는 맥에서 ⌘(metaKey), 그 밖에서 Ctrl. 적지 않은 수정 글쇠(Shift · Alt)를 함께 눌렀으면 아니다.
* 마지막 글쇠는 대소문자를 가리지 않는다 (예: ['Mod', 'P'] — 읽기 화면의 인쇄).
* 라틴 글자가 아닌 배열(러시아어 · 그리스어 등 — Ctrl+P 가 'з' · 'π' 로 온다)이면 자판의 자리(event.code 'KeyP')로 본다 — 브라우저의 인쇄 단축키와 같다.
* 라틴 배열(드보락 등)은 글자 그대로 본다 — 자리로 보면 그 배열의 Ctrl+L 이 인쇄가 된다
*/
export function isShortcutEvent(event: KeyboardEvent, keys: ShortcutKeys, apple: boolean): boolean {
const wantsMod = keys.includes('Mod')
const modPressed = apple ? event.metaKey : event.ctrlKey
const otherMod = apple ? event.ctrlKey : event.metaKey
if (wantsMod !== modPressed || otherMod) return false
if (keys.includes('Shift') !== event.shiftKey || keys.includes('Alt') !== event.altKey) return false
const key = keys.find((name) => name !== 'Mod' && name !== 'Shift' && name !== 'Alt')
if (key === undefined) return false
if (event.key.toLowerCase() === key.toLowerCase()) return true
return LATIN_LETTER.test(key) && !LATIN_LETTER.test(event.key) && event.code === `Key${key.toUpperCase()}`
}
/** 입력기가 처리 중인 키의 keyCode — Safari · macOS 한글 입력은 조합을 끝내는 Enter 를 isComposing 없이 이 값으로 보낸다 */
const IME_PROCESS_KEY_CODE = 229
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import {
blockOfSender,
domainBlockOf,
emailDomainOf,
isInternalDomain,
isPublicMailDomain,
isUnblockableSender,
mailBlockInputOf,
} from './mail-block'
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단") — 차단 값 · 검사 · 찾기
const OWN = 'ara.kim@arasystem.it.kr'
const blocked = (address: string): BlockedSender => ({ address, blockedAt: '2026-10-29T01:00:00.000Z' })
describe('여러 사람이 쓰는 메일 서비스 — 서버와 같은 목록 (짝)', () => {
it('목록은 서버 상수와 같은 30개, 이 차례 그대로', () => {
expect(PUBLIC_MAIL_DOMAINS).toEqual([
'gmail.com',
'googlemail.com',
'naver.com',
'daum.net',
'hanmail.net',
'kakao.com',
'nate.com',
'empas.com',
'hanmir.com',
'korea.com',
'dreamwiz.com',
'outlook.com',
'hotmail.com',
'live.com',
'msn.com',
'yahoo.com',
'yahoo.co.kr',
'icloud.com',
'me.com',
'mac.com',
'aol.com',
'proton.me',
'protonmail.com',
'gmx.com',
'gmx.net',
'mail.com',
'zoho.com',
'yandex.com',
'qq.com',
'163.com',
])
})
it('대소문자 · 앞뒤 공백을 가리지 않는다 — 하위 도메인은 따로다', () => {
expect(isPublicMailDomain('Naver.COM ')).toBe(true)
expect(isPublicMailDomain('mail.naver.com')).toBe(false)
expect(isPublicMailDomain('cloudbrief.example')).toBe(false)
})
})
describe('사내 · 내 주소 — 막을 수 없다 (서버의 MAIL_DOMAIN 과 그 하위)', () => {
it('내 주소의 도메인과 그 하위는 사내다', () => {
expect(isInternalDomain('arasystem.it.kr', OWN)).toBe(true)
expect(isInternalDomain('dev.arasystem.it.kr', OWN)).toBe(true)
expect(isInternalDomain('notarasystem.it.kr', OWN)).toBe(false)
})
it('내 주소 · 사내 직원은 차단 항목이 없다', () => {
expect(isUnblockableSender('ARA.KIM@arasystem.it.kr', OWN)).toBe(true)
expect(isUnblockableSender('doyun.lee@arasystem.it.kr', OWN)).toBe(true)
expect(isUnblockableSender('news@cloudbrief.example', OWN)).toBe(false)
})
})
describe('blockOfSender — 주소가 같은 것 먼저, 그다음 그 도메인', () => {
const blocks = [blocked('@promo-mail.example'), blocked('news@cloudbrief.example')]
it('대소문자를 가리지 않고 주소 · 도메인을 찾는다', () => {
expect(blockOfSender(blocks, 'News@CloudBrief.example')?.address).toBe('news@cloudbrief.example')
expect(blockOfSender(blocks, 'sale@promo-mail.example')?.address).toBe('@promo-mail.example')
expect(blockOfSender([...blocks, blocked('@cloudbrief.example')], 'news@cloudbrief.example')?.address).toBe(
'news@cloudbrief.example',
)
})
it('하위 도메인 · 다른 주소 · 빈 주소는 막지 않는다', () => {
expect(blockOfSender(blocks, 'x@mail.promo-mail.example')).toBeNull()
expect(blockOfSender(blocks, 'other@cloudbrief.example')).toBeNull()
expect(blockOfSender(blocks, '')).toBeNull()
})
it('도메인 값 · 도메인 고르기', () => {
expect(emailDomainOf('News@CloudBrief.example')).toBe('cloudbrief.example')
expect(domainBlockOf('CloudBrief.example')).toBe('@cloudbrief.example')
})
})
describe('mailBlockInputOf — 설정 › 차단한 주소의 칸 (서버보다 좁게)', () => {
it('주소 · @도메인을 소문자로 맞춰 받는다', () => {
expect(mailBlockInputOf(' Kim@Spam.Example ', OWN, [])).toEqual({ address: 'kim@spam.example' })
expect(mailBlockInputOf('@Spam.Example', OWN, [])).toEqual({ address: '@spam.example' })
})
it.each([
['@ 만', '@'],
['@ 앞뒤가 빈 주소', 'kim@'],
['점이 없는 도메인', '@localhost'],
['IP 주소', '@192.168.0.1'],
['@ 가 둘', 'a@b@c.example'],
['도메인 안의 @', '@a@b.example'],
['빈 글', ' '],
])('형식이 틀리면 invalid — %s', (_label, input) => {
expect(mailBlockInputOf(input, OWN, [])).toEqual({ problem: 'invalid' })
})
it('내 주소 · 사내 도메인(하위 포함) · 사내 주소는 own', () => {
expect(mailBlockInputOf(OWN, OWN, [])).toEqual({ problem: 'own' })
expect(mailBlockInputOf('@arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
expect(mailBlockInputOf('@dev.arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
expect(mailBlockInputOf('doyun.lee@arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
})
it('여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다 — 주소로는 된다', () => {
expect(mailBlockInputOf('@gmail.com', OWN, [])).toEqual({ problem: 'publicDomain' })
expect(mailBlockInputOf('spammer@gmail.com', OWN, [])).toEqual({ address: 'spammer@gmail.com' })
})
it('이미 차단한 값은 duplicate, 500개면 full', () => {
expect(mailBlockInputOf('News@cloudbrief.example', OWN, [blocked('news@cloudbrief.example')])).toEqual({
problem: 'duplicate',
})
const full = Array.from({ length: MAIL_BLOCK_MAX }, (_, index) => blocked(`s${index}@spam.example`))
expect(mailBlockInputOf('new@spam.example', OWN, full)).toEqual({ problem: 'full' })
})
})
+82
View File
@@ -0,0 +1,82 @@
// 보낸 사람 차단 — 차단할 값 · 검사 · 찾기 (계약 2절 "보낸 사람 차단" — 순수 함수만 둔다)
// 차단 값은 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두, 하위 도메인은 따로).
// 내 주소와 사내 도메인(내 주소의 도메인과 그 하위 — 서버의 MAIL_DOMAIN)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다.
// 검사는 서버보다 좁게 본다(isStrictEmail) — 화면이 통과시킨 값은 서버도 받는다.
import { MAIL_BLOCK_DOMAIN_PREFIX, MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { isStrictEmail, normalizeEmail } from '@/shared/utils/mail-address'
/** 칸에 쓴 값의 문제 — 형식 · 이미 차단함 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 500개 */
export type MailBlockInputProblem = 'invalid' | 'duplicate' | 'own' | 'publicDomain' | 'full'
/** 주소의 도메인(소문자) — @ 가 없으면 빈 글 */
export function emailDomainOf(email: string): string {
const at = email.lastIndexOf('@')
return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase()
}
/** 도메인 차단 값인가 ("@spam.example") */
export function isDomainBlock(address: string): boolean {
return address.startsWith(MAIL_BLOCK_DOMAIN_PREFIX)
}
/** 도메인 차단 값 — "@" + 도메인 */
export function domainBlockOf(domain: string): string {
return `${MAIL_BLOCK_DOMAIN_PREFIX}${domain.trim().toLowerCase()}`
}
/** 여러 사람이 쓰는 메일 서비스인가 (gmail.com · naver.com 등 — 서버와 같은 목록) */
export function isPublicMailDomain(domain: string): boolean {
return PUBLIC_MAIL_DOMAINS.includes(domain.trim().toLowerCase())
}
/** 사내 도메인인가 — 내 주소의 도메인 또는 그 하위 (서버의 MAIL_DOMAIN 과 그 하위) */
export function isInternalDomain(domain: string, ownEmail: string): boolean {
const own = emailDomainOf(ownEmail)
const target = domain.trim().toLowerCase()
return own !== '' && (target === own || target.endsWith(`.${own}`))
}
/** 차단할 수 없는 보낸 사람인가 — 내 주소 · 사내 직원(사내 도메인). 읽기 화면 ⋯ 에 차단 항목을 두지 않는다 */
export function isUnblockableSender(email: string, ownEmail: string): boolean {
if (normalizeEmail(email) === normalizeEmail(ownEmail)) return true
return isInternalDomain(emailDomainOf(email), ownEmail)
}
/**
* 그 보낸 사람을 막고 있는 차단 — 주소가 같은 것 먼저, 없으면 그 도메인(하위 도메인은 따로 — 서버와 같다). 없으면 null.
* 대소문자는 가리지 않는다
*/
export function blockOfSender(blocks: readonly BlockedSender[], email: string): BlockedSender | null {
const address = normalizeEmail(email)
if (!address) return null
const byAddress = blocks.find((block) => block.address === address)
if (byAddress) return byAddress
const domain = emailDomainOf(address)
if (!domain) return null
return blocks.find((block) => block.address === domainBlockOf(domain)) ?? null
}
/**
* 설정 › 차단한 주소의 칸 — 앞뒤 공백을 빼고 소문자로 맞춘 차단 값, 또는 그 값의 문제.
* 차례: 형식 → 내 주소 · 사내 도메인 → 여러 사람이 쓰는 메일 서비스의 도메인 → 이미 차단함 → 500개
*/
export function mailBlockInputOf(
input: string,
ownEmail: string,
blocks: readonly BlockedSender[],
): { address: string } | { problem: MailBlockInputProblem } {
const value = input.trim().toLowerCase()
const domainOnly = isDomainBlock(value)
const domain = domainOnly ? value.slice(MAIL_BLOCK_DOMAIN_PREFIX.length) : emailDomainOf(value)
// 도메인은 주소의 @ 뒤와 같은 규칙으로 본다(isStrictEmail 에 "a@" 를 붙여 — 서버의 253자보다 조금 좁다) —
// 점이 하나 이상 · 칸마다 앞뒤 하이픈 없음 · 마지막 칸은 글자 2개 이상(IP 주소가 아니다)
const wellFormed = domainOnly ? domain !== '' && !domain.includes('@') && isStrictEmail(`a@${domain}`) : isStrictEmail(value)
if (!wellFormed) return { problem: 'invalid' }
if (normalizeEmail(value) === normalizeEmail(ownEmail) || isInternalDomain(domain, ownEmail)) return { problem: 'own' }
if (domainOnly && isPublicMailDomain(domain)) return { problem: 'publicDomain' }
if (blocks.some((block) => block.address === value)) return { problem: 'duplicate' }
if (blocks.length >= MAIL_BLOCK_MAX) return { problem: 'full' }
return { address: value }
}
+88 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FRAME_CSP, buildMailDocument, displayedHtmlBody, escapeHtml, sanitizeMailHtml } from './mail-html'
import {
MAIL_FRAME_CSP,
buildMailDocument,
displayedHtmlBody,
escapeHtml,
mailFrameCspImagesShown,
sanitizeMailHtml,
} from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -145,3 +152,83 @@ describe('escapeHtml', () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;')
})
})
describe('외부 이미지 보기 — 중계 주소의 그림만 보일 때 남긴다 (9단계 · 계약 2절 "외부 이미지 보기")', () => {
/** 서버의 표 모양 — base64url (backend `mail-image-token.ts`) */
const PROXIED = '/api/mail-images/AQx9-Zb_k3Lm0Qr7TuVw'
const srcsOf = (html: string) =>
Array.from(new DOMParser().parseFromString(html, 'text/html').querySelectorAll('img')).map((img) =>
img.getAttribute('src'),
)
it('보이게 했으면(imagesShown) 중계 주소의 그림을 남긴다 — data: 그림도 그대로', () => {
const html = sanitizeMailHtml(`<img src="${PROXIED}"><img src="data:image/png;base64,AAAA">`, { imagesShown: true })
expect(srcsOf(html)).toEqual([PROXIED, 'data:image/png;base64,AAAA'])
})
it('막아 둔 본문(기본)에서는 중계 주소의 그림도 지운다', () => {
expect(srcsOf(sanitizeMailHtml(`<img src="${PROXIED}">`))).toEqual([null])
})
it('보이게 했어도 그 밖의 주소는 지운다 — 바깥 주소 · 다른 API · 경로 넘기 · 쿼리 · 인코딩한 점 · base64url 밖의 글자', () => {
const html = sanitizeMailHtml(
[
'<img src="https://tracker.example/pixel.gif">',
'<img src="//tracker.example/pixel.gif">',
'<img src="/api/auth/logout">',
'<img src="/api/mail-images/../auth/logout">',
'<img src="/api/mail-images/a/b">',
'<img src="/api/mail-images/.hidden">',
'<img src="/api/mail-images/abc.def">',
'<img src="/api/mail-images/abc=">',
'<img src="/api/mail-images/abc~">',
'<img src="/api/mail-images/abc?x=1">',
'<img src="/api/mail-images/%2e%2e">',
'<img src="/api/mail-images/">',
'<img src="https://portal.arasystem.it.kr/api/mail-images/abc">',
].join(''),
{ imagesShown: true },
)
expect(srcsOf(html).every((src) => src === null)).toBe(true)
})
it('보이게 했으면 문서의 CSP 그림만 우리 출처의 중계 경로를 더 연다 — 같은 출처 전체(self)가 아니다 · 나머지는 그대로', () => {
const shown = buildMailDocument({
htmlBody: `<img src="${PROXIED}">`,
textBody: null,
imagesShown: true,
origin: 'https://portal.arasystem.it.kr',
})
const blocked = buildMailDocument({ htmlBody: `<img src="${PROXIED}">`, textBody: null })
const csp = "default-src 'none'; img-src data: https://portal.arasystem.it.kr/api/mail-images/; style-src 'unsafe-inline'"
expect(mailFrameCspImagesShown('https://portal.arasystem.it.kr')).toBe(csp)
expect(shown).toContain(`content="${csp}"`)
expect(shown).not.toContain("'self'")
expect(shown).toContain(`src="${PROXIED}"`)
expect(blocked).toContain(`content="${MAIL_FRAME_CSP}"`)
expect(blocked).not.toContain(PROXIED)
})
it('CSP 의 출처는 주지 않으면 이 화면의 출처(location.origin) — 개발 서버의 포트도 그대로', () => {
const shown = buildMailDocument({ htmlBody: `<img src="${PROXIED}">`, textBody: null, imagesShown: true })
expect(shown).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
expect(mailFrameCspImagesShown('http://localhost:5173')).toBe(
"default-src 'none'; img-src data: http://localhost:5173/api/mail-images/; style-src 'unsafe-inline'",
)
})
it('CSP 에 적을 수 없는 출처(IPv6 주소 · 빈 글 · 글자를 섞은 값)면 같은 출처(self)로 연다 — CSP 문법을 깨거나 다른 지시를 끼워 넣지 못한다', () => {
const fallback = "default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'"
expect(mailFrameCspImagesShown('http://[::1]:5173')).toBe(fallback)
expect(mailFrameCspImagesShown('')).toBe(fallback)
expect(mailFrameCspImagesShown('null')).toBe(fallback)
expect(mailFrameCspImagesShown("https://a.example; img-src *")).toBe(fallback)
expect(mailFrameCspImagesShown('https://a.example/path')).toBe(fallback)
expect(mailFrameCspImagesShown('javascript:alert(1)')).toBe(fallback)
})
})
+62 -8
View File
@@ -9,6 +9,8 @@
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
import { MAIL_IMAGE_PROXY_PATH } from '@/shared/constants/mail'
/**
* iframe 문서 안에만 적용하는 CSP.
* 스크립트·글꼴·네트워크 요청을 전부 막고 data: 이미지(본문 안 인라인 이미지)와 인라인 스타일만 허용한다.
@@ -16,11 +18,33 @@ import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
*/
export const MAIL_FRAME_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'"
/** CSP host-source 로 적을 수 있는 출처 — http(s)://호스트(글자 · 숫자 · - · .)[:포트] */
const CSP_HOST_ORIGIN = /^https?:\/\/[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?::\d{1,5})?$/
/**
* 외부 이미지를 보일 때(서버가 중계 주소로 바꿔 넣은 본문 — imagesShown, 9단계)의 CSP — 그림만 우리 서버의 **중계 경로**(/api/mail-images/)를 더 연다.
* srcdoc 문서는 부모(우리 앱)의 출처를 물려받는다 (sandbox 의 allow-same-origin — MailBodyFrame). 바깥 주소는 여전히 막힌다 — 그림은 서버가 대신 받아 준다
* (계약 2절 "외부 이미지 보기"). 'self' 로 열면 서버 정화를 빠져나온 CSS url() 이 같은 출처의 아무 API(/api/mail-events 등)나 부를 수 있다 —
* 셋째 겹(CSP)이 그것도 막게 경로까지 좁힌다 (2026-10-29 리뷰). 경로가 / 로 끝나 그 아래 주소만 맞는다(CSP host-source 의 경로 규칙).
* origin 이 CSP 에 적을 수 없는 모양이면(IPv6 주소 등 — host-source 는 [ ] 를 받지 않는다) 'self' 로 연다 — 정화 두 겹은 그대로다
*/
export function mailFrameCspImagesShown(origin: string): string {
const source = CSP_HOST_ORIGIN.test(origin) ? `${origin}${MAIL_IMAGE_PROXY_PATH}` : "'self'"
return `default-src 'none'; img-src data: ${source}; style-src 'unsafe-inline'`
}
/** 링크로 남기는 주소 — 계약과 같다 (http · https · mailto). 상대 경로·javascript: 등은 지운다 */
const SAFE_LINK_PATTERN = /^(?:https?|mailto):/i
/** 이미지는 서버가 넣어 준 data: 이미지만 남긴다 (외부 이미지는 추적 픽셀이 될 수 있다) */
const DATA_IMAGE_PATTERN = /^data:image\//i
/**
* 외부 이미지를 보일 때만 더 남기는 그림 — 서버의 중계 주소(/api/mail-images/<표>) 그대로.
* 표는 서버가 암호화한 불투명 글(base64url — 채움 `=` 없음)이다 — 한 칸(점 · 빗금 · 쿼리 · 조각 · % 없음)이라
* ../ · %2e%2e 로 다른 API 를 부를 수 없다. 짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` (표 모양을 바꾸면 둘 다)
*/
const PROXIED_IMAGE_PATTERN = new RegExp(`^${MAIL_IMAGE_PROXY_PATH.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}[A-Za-z0-9_-]+$`)
/** 서버 제거 목록(스크립트·스타일·폼·iframe·object·embed·svg)에 입력·미디어 요소를 더했다 */
const FORBIDDEN_TAGS = [
'style',
@@ -65,8 +89,14 @@ const SANITIZE_CONFIG: Config = {
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
}
function createPurifier(): Purifier {
// 이 모듈 전용 인스턴스 — 아래 훅이 다른 곳의 DOMPurify 사용에 번지지 않게 한다
/** 남길 그림 주소인가 — 늘 data: 그림, 외부 이미지를 보일 때만 서버의 중계 주소도 */
function isAllowedImageSource(src: string, imagesShown: boolean): boolean {
if (DATA_IMAGE_PATTERN.test(src)) return true
return imagesShown && PROXIED_IMAGE_PATTERN.test(src)
}
function createPurifier(imagesShown: boolean): Purifier {
// 이 모듈 전용 인스턴스 — 아래 훅이 다른 곳의 DOMPurify 사용에 번지지 않게 한다 (그림을 보일 때 · 막을 때 따로 둔다)
const purifier = DOMPurify(window)
purifier.addHook('afterSanitizeAttributes', (node) => {
const tag = node.tagName
@@ -83,17 +113,29 @@ function createPurifier(): Purifier {
}
if (tag === 'IMG') {
const src = node.getAttribute('src')?.trim() ?? ''
if (!DATA_IMAGE_PATTERN.test(src)) node.removeAttribute('src')
if (isAllowedImageSource(src, imagesShown)) node.setAttribute('src', src)
else node.removeAttribute('src')
}
})
return purifier
}
let purifier: Purifier | null = null
const purifiers: { blocked: Purifier | null; shown: Purifier | null } = { blocked: null, shown: null }
export interface SanitizeMailOptions {
/**
* 서버가 외부 이미지를 중계 주소로 바꿔 넣은 본문이다(MessageDetail.imagesShown) — 그 주소(/api/mail-images/…)의 그림만 더 남긴다.
* 그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다
*/
imagesShown?: boolean
}
/** 메일 HTML 정화 — 결과는 <body> 안에 넣을 조각 */
export function sanitizeMailHtml(html: string): string {
purifier ??= createPurifier()
export function sanitizeMailHtml(html: string, options: SanitizeMailOptions = {}): string {
const shown = options.imagesShown === true
const purifier = shown
? (purifiers.shown ??= createPurifier(true))
: (purifiers.blocked ??= createPurifier(false))
return purifier.sanitize(html, SANITIZE_CONFIG)
}
@@ -128,6 +170,15 @@ const MAIL_FRAME_STYLE = [
export interface MailBodySource {
htmlBody: string | null
textBody: string | null
/** 외부 이미지를 보이는 본문(서버의 imagesShown) — 문서의 CSP 와 정화가 중계 주소의 그림을 남긴다 (9단계) */
imagesShown?: boolean
/** 중계 주소를 여는 CSP 의 출처 — 주지 않으면 이 화면의 출처(location.origin). 그림이 그 출처의 /api/mail-images/ 로 풀린다 */
origin?: string
}
/** 이 화면의 출처 — 브라우저가 아니면 빈 글 (CSP 는 'self' 로 연다 — mailFrameCspImagesShown) */
function currentOrigin(): string {
return typeof window === 'undefined' ? '' : window.location.origin
}
/**
@@ -144,19 +195,22 @@ export function displayedHtmlBody(htmlBody: string | null | undefined): string |
* 본문 → iframe srcdoc 문서.
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
* 외부 이미지를 보이는 본문(imagesShown)이면 CSP 의 그림만 우리 서버의 중계 경로를 열고 중계 주소의 그림을 남긴다 — 그 밖은 늘 같다
*/
export function buildMailDocument(source: MailBodySource): string {
const imagesShown = source.imagesShown === true
const html = displayedHtmlBody(source.htmlBody)
const content =
html !== null
? sanitizeMailHtml(html.trim())
? sanitizeMailHtml(html.trim(), { imagesShown })
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
const csp = imagesShown ? mailFrameCspImagesShown(source.origin ?? currentOrigin()) : MAIL_FRAME_CSP
return [
'<!doctype html>',
'<html lang="ko"><head>',
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${MAIL_FRAME_CSP}">`,
`<meta http-equiv="Content-Security-Policy" content="${csp}">`,
'<meta name="referrer" content="no-referrer">',
'<base target="_blank">',
`<style>${MAIL_FRAME_STYLE}</style>`,
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { isRowUnread, mailboxOfMessage, rowWho, whoColumnLabel } from './mail-list-row'
import { isRowUnread, mailboxOfMessage, pendingSendLabel, rowWho, whoColumnLabel } from './mail-list-row'
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 },
@@ -69,3 +69,18 @@ describe('mail-list-row (목록 행의 표시)', () => {
expect(mailboxOfMessage({ mailboxIds: ['unknown'] }, MAILBOXES)).toBeNull()
})
})
describe('pendingSendLabel — 아직 보내지 않은 내 메일의 날짜 칸 (9단계)', () => {
it('예약은 "예약 · 10월 30일 오전 9:00", 보내기 취소를 기다리는 동안은 "보내는 중"', () => {
expect(pendingSendLabel({ pendingSend: { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' } }, 'Asia/Seoul')).toBe(
'예약 · 10월 30일 오전 9:00',
)
expect(pendingSendLabel({ pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } })).toBe('보내는 중')
})
it('붙잡힌 발송이 아니면(null · 필드가 없는 옛 서버 · 모양이 틀림) null — 받은 시각을 보인다', () => {
expect(pendingSendLabel({ pendingSend: null })).toBeNull()
expect(pendingSendLabel({})).toBeNull()
expect(pendingSendLabel({ pendingSend: { kind: 'undo', sendAt: '곧' } })).toBeNull()
})
})
@@ -2,14 +2,18 @@
// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않고, 누르면 쓰기 화면에서 이어 쓴다
// - 보낸편지함: 받는 사람 — 분할 보기는 "받는 사람: …", 표는 머리가 "받는 사람"이라 이름만
// - 전체 메일함 검색 결과는 행마다 메일함이 다르다 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" (디자인 README "3단계 › 검색")
// - 아직 보내지 않은 내 메일(9단계 — pendingSend)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(보내기 취소를 기다리는 동안)
import {
DRAFT_NO_RECIPIENTS,
MAIL_SEARCH_TEXT,
OUTGOING_MAILBOX_ROLES,
} from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatScheduleShortTime } from '@/shared/utils/format'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
import { messagePendingSend } from '@/shared/utils/pending-send'
const NO_RECIPIENTS = '받는 사람 없음'
const NO_SENDER = '(보낸 사람 없음)'
@@ -75,3 +79,15 @@ export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: Ro
export function whoColumnLabel(role: MailboxRole | null, mixed: boolean): string {
return !mixed && role && OUTGOING_MAILBOX_ROLES.includes(role) ? '받는 사람' : '보낸 사람'
}
/**
* 아직 보내지 않은 내 메일의 날짜 칸 (디자인 README "9단계 › 보낸편지함") — 예약 "예약 · 10월 30일 오전 9:00",
* 보내기 취소를 기다리는 동안 "보내는 중". 붙잡힌 발송이 아니면 null (받은 시각을 보인다)
*/
export function pendingSendLabel(message: Pick<MessageSummary, 'pendingSend'>, timeZone?: string): string | null {
const pending = messagePendingSend(message)
if (!pending) return null
return pending.kind === 'scheduled'
? SEND_LATER_TEXT.rowScheduled(formatScheduleShortTime(pending.sendAt, timeZone))
: SEND_LATER_TEXT.rowSending
}
+34 -8
View File
@@ -1,5 +1,5 @@
// 보이는 메일 목록의 출처 — 메일함 하나 또는 검색 결과 (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색").
// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
@@ -19,32 +19,50 @@ export interface SearchListSource {
scope: string
}
export type MailListSource = MailboxListSource | SearchListSource
/** 예약한 메일 — GET /scheduled-messages (보낼 시각 차례). 고르기 · 여러 통 처리는 없다 (디자인 README "9단계") */
export interface ScheduledListSource {
kind: 'scheduled'
}
/** GET /messages 로 받는 목록 — 메일함 · 검색 */
export type MessageListSource = MailboxListSource | SearchListSource
export type MailListSource = MessageListSource | ScheduledListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
export const MAIL_SEARCH_MESSAGE_ROUTE = 'mail-search-message'
/** 예약됨 목록 · 예약됨에서 연 메일의 라우트 이름 — /mail/scheduled · /mail/scheduled/<메일 id> */
export const MAIL_SCHEDULED_ROUTE = 'mail-scheduled'
export const MAIL_SCHEDULED_MESSAGE_ROUTE = 'mail-scheduled-message'
/** 예약됨 출처 — 하나뿐이라 늘 같은 값 */
export const SCHEDULED_LIST_SOURCE: ScheduledListSource = { kind: 'scheduled' }
export function isSearchRouteName(name: unknown): boolean {
return name === MAIL_SEARCH_ROUTE || name === MAIL_SEARCH_MESSAGE_ROUTE
}
export function isScheduledRouteName(name: unknown): boolean {
return name === MAIL_SCHEDULED_ROUTE || name === MAIL_SCHEDULED_MESSAGE_ROUTE
}
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
if (!a || !b) return a === b
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
if (a.kind === 'search' && b.kind === 'search') return a.q === b.q && a.scope === b.scope
return false
return a.kind === 'scheduled' && b.kind === 'scheduled'
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
export function listQueryOf(source: MailListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
export function listQueryOf(source: MessageListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
}
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색이면 null */
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
if (!source) return null
if (!source || source.kind === 'scheduled') return null
if (source.kind === 'mailbox') return source.mailboxId
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
}
@@ -54,8 +72,14 @@ export function isAllMailboxSearch(source: MailListSource | null): boolean {
return source?.kind === 'search' && source.scope === SEARCH_SCOPE_ALL
}
/** 예약됨 목록인가 */
export function isScheduledSource(source: MailListSource | null): boolean {
return source?.kind === 'scheduled'
}
/** 목록마다 다른 키 — 표 위치 기억 등 */
export function listKeyOf(source: MailListSource): string {
if (source.kind === 'scheduled') return 'scheduled'
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
}
@@ -67,14 +91,16 @@ export function searchRouteQuery(source: SearchListSource): Record<string, strin
/** 목록 화면 주소 */
export function listLocationOf(source: MailListSource): RouteLocationRaw {
if (source.kind === 'mailbox') return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_ROUTE }
return { name: MAIL_SEARCH_ROUTE, query: searchRouteQuery(source) }
}
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=… */
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=…, 예약됨은 /mail/scheduled/<메일 id> */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
if (source.kind === 'mailbox') {
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
}
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_MESSAGE_ROUTE, params: { messageId } }
return { name: MAIL_SEARCH_MESSAGE_ROUTE, params: { messageId }, query: searchRouteQuery(source) }
}
@@ -197,3 +197,19 @@ describe('buildQuoteHtml', () => {
)
})
})
describe('originalBodyHtml — 외부 이미지를 보인 원문 (9단계 "외부 이미지 보기")', () => {
it('답장 · 전달 인용에는 중계 주소(/api/mail-images/…)의 그림도 실리지 않는다 — 설정으로 처음부터 보인 본문이어도', () => {
const html = originalBodyHtml({
htmlBody: '<p>이번 주 소식</p><img src="/api/mail-images/t0ken" alt="표지"><p>끝</p>',
textBody: '이번 주 소식',
})
expect(html).toBe('<p>이번 주 소식</p><p>끝</p>')
expect(html).not.toContain('mail-images')
})
it('그림뿐이던 원문이면 글자 본문을 인용한다 (지금과 같다)', () => {
expect(originalBodyHtml({ htmlBody: '<img src="/api/mail-images/t0ken">', textBody: '글자 본문' })).toBe('<p>글자 본문</p>')
})
})
@@ -47,3 +47,25 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr')
})
})
describe('partialSendMessage — 엔진이 붙잡아 둔 발송 (9단계 "보내기 취소 · 예약 발송")', () => {
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
it('보내기 취소를 기다리는 중이면 아직 나가지 않았다 — "받지 못합니다" (디자인 원본 MailSendLater)', () => {
expect(partialSendMessage([NOBODY], { pendingKind: 'undo' })).toBe(
'보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
)
})
it('예약이면 "예약했지만 … 받지 못합니다"', () => {
expect(partialSendMessage([NOBODY], { pendingKind: 'scheduled' })).toBe(
'예약했지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
)
})
it('바로 보냈으면(null) 지금처럼 "받지 못했습니다"', () => {
expect(partialSendMessage([NOBODY], { pendingKind: null })).toBe(
'보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
)
})
})
+14 -3
View File
@@ -7,7 +7,7 @@ import {
MAIL_PARTIAL_SEND_TEXT,
MAIL_REJECTED_REASON_TEXT,
} from '@/shared/constants/mail'
import type { RejectedRecipient } from '@/shared/types/mail'
import type { PendingSendKind, RejectedRecipient } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
/** 한 사람 — "주소 (까닭)". 계약에 없는 까닭(나중에 생긴 값 등)이면 주소만 적는다 */
@@ -20,8 +20,13 @@ function recipientText(recipient: RejectedRecipient): string {
* 받는 사람 일부가 받지 못한 채 보냈을 때의 알림 문장 — 받지 못한 사람이 없으면 null (보냈다는 알림을 띄운다).
* 두 명까지 "주소 (까닭)"를 ", " 로 잇고, 더 있으면 " 외 N명":
* "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
* pendingKind — 엔진이 붙잡아 둔 발송(9단계)이면 아직 나가지 않았다: 보내기 취소를 기다리는 중 "보냈지만 … 받지 못합니다",
* 예약 "예약했지만 … 받지 못합니다"
*/
export function partialSendMessage(rejected: readonly RejectedRecipient[]): string | null {
export function partialSendMessage(
rejected: readonly RejectedRecipient[],
options: { pendingKind?: PendingSendKind | null } = {},
): string | null {
if (rejected.length === 0) return null
const listed = rejected
.slice(0, MAIL_PARTIAL_SEND_LISTED)
@@ -29,5 +34,11 @@ export function partialSendMessage(rejected: readonly RejectedRecipient[]): stri
.join(MAIL_PARTIAL_SEND_TEXT.separator)
const othersCount = rejected.length - MAIL_PARTIAL_SEND_LISTED
const others = othersCount > 0 ? MAIL_PARTIAL_SEND_TEXT.others(formatCount(othersCount)) : ''
return MAIL_PARTIAL_SEND_TEXT.message(formatCount(rejected.length), `${listed}${others}`)
const message =
options.pendingKind === 'scheduled'
? MAIL_PARTIAL_SEND_TEXT.scheduledMessage
: options.pendingKind === 'undo'
? MAIL_PARTIAL_SEND_TEXT.pendingMessage
: MAIL_PARTIAL_SEND_TEXT.message
return message(formatCount(rejected.length), `${listed}${others}`)
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import {
isHeldMessage,
isScheduledMessage,
messagePendingSend,
nextPendingCheckAt,
overduePendingWatches,
pendingWatchesOf,
samePendingSend,
undoToastDeadline,
} from './pending-send'
// 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수
const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
const at = (ms: number) => new Date(NOW + ms).toISOString()
const TIMING = { graceMs: 2_000, recheckMs: 5_000 }
describe('붙잡은 발송 읽기', () => {
it('모양이 맞을 때만 붙잡은 발송이다 — 없거나 틀린 값(9단계 앞 서버 · 게이트웨이)은 없는 것', () => {
expect(messagePendingSend({ pendingSend: { kind: 'undo', sendAt: at(10_000) } })).toEqual({ kind: 'undo', sendAt: at(10_000) })
expect(messagePendingSend({ pendingSend: null })).toBeNull()
expect(messagePendingSend({})).toBeNull()
expect(messagePendingSend({ pendingSend: { kind: 'later', sendAt: at(0) } as never })).toBeNull()
expect(messagePendingSend({ pendingSend: { kind: 'undo', sendAt: '언젠가' } })).toBeNull()
})
it('아직 보내지 않은 메일은 두 가지 다 — 예약은 그 가운데 예약뿐', () => {
const undo = { pendingSend: { kind: 'undo' as const, sendAt: at(10_000) } }
const scheduled = { pendingSend: { kind: 'scheduled' as const, sendAt: at(60_000) } }
expect([isHeldMessage(undo), isHeldMessage(scheduled), isHeldMessage({ pendingSend: null })]).toEqual([true, true, false])
expect([isScheduledMessage(undo), isScheduledMessage(scheduled)]).toEqual([false, true])
})
it('같은 붙잡은 발송인가 — 종류 · 시각이 같아야 같다 (둘 다 없음 포함)', () => {
const undo = { kind: 'undo' as const, sendAt: at(10_000) }
expect(samePendingSend(undo, { ...undo })).toBe(true)
expect(samePendingSend(undo, { ...undo, sendAt: at(11_000) })).toBe(false)
expect(samePendingSend(undo, { ...undo, kind: 'scheduled' })).toBe(false)
expect(samePendingSend(undo, null)).toBe(false)
expect(samePendingSend(null, null)).toBe(true)
})
})
describe('엔진이 풀 때 다시 보기 (리뷰 #2)', () => {
it('지켜볼 것 — 붙잡은 발송이 있는 메일만, 같은 메일 · 같은 시각은 하나 (목록의 줄 · 열린 메일 · 가장 이른 예약이 겹친다)', () => {
const held = { id: 's1', pendingSend: { kind: 'scheduled' as const, sendAt: at(60_000) } }
const watches = pendingWatchesOf([held, { id: 'p1', pendingSend: null }, null, undefined, { ...held }])
expect(watches).toEqual([{ key: `s1\n${at(60_000)}`, at: NOW + 60_000 }])
})
it('다음에 볼 때 — 아직 오지 않은 것은 그 시각 + 2초, 지난 것은 지금 + 5초 가운데 이른 때 · 없으면 null', () => {
const soon = { key: 'a', at: NOW + 10_000 }
const later = { key: 'b', at: NOW + 60_000 }
const past = { key: 'c', at: NOW - 30_000 }
expect(nextPendingCheckAt([later, soon], NOW, TIMING)).toBe(NOW + 12_000)
expect(nextPendingCheckAt([later, past], NOW, TIMING)).toBe(NOW + 5_000)
expect(nextPendingCheckAt([], NOW, TIMING)).toBeNull()
})
it('다시 본 뒤에도 시각(+ 2초)이 지난 채 남은 것', () => {
const watches = [
{ key: 'a', at: NOW - 3_000 },
{ key: 'b', at: NOW - 1_000 },
{ key: 'c', at: NOW + 1_000 },
]
expect(overduePendingWatches(watches, NOW, TIMING.graceMs).map((watch) => watch.key)).toEqual(['a'])
})
})
describe('보낸 뒤 알림의 [보내기 취소] 마감 (리뷰 #11)', () => {
const base = { now: NOW, marginMs: 1_000, maxMs: 30_000 }
it('설정한 초를 알면 지금 + 초 − 1초 — 이 PC 시계가 늦어 sendAt 이 멀리 보여도 늘어나지 않는다', () => {
expect(undoToastDeadline({ ...base, sendAt: at(600_000), undoSendSeconds: 5 })).toBe(NOW + 4_000)
// 이 PC 시계가 빨라 sendAt 이 이미 지나 보여도 설정한 만큼은 보인다
expect(undoToastDeadline({ ...base, sendAt: at(-3_000), undoSendSeconds: 10 })).toBe(NOW + 9_000)
})
it('설정을 모르거나 0 인데 서버가 붙잡았으면(다른 PC 에서 바꿈) sendAt 까지 — 다만 30초를 넘지 않는다', () => {
expect(undoToastDeadline({ ...base, sendAt: at(10_000), undoSendSeconds: null })).toBe(NOW + 10_000)
expect(undoToastDeadline({ ...base, sendAt: at(600_000), undoSendSeconds: null })).toBe(NOW + 30_000)
expect(undoToastDeadline({ ...base, sendAt: at(20_000), undoSendSeconds: 0 })).toBe(NOW + 20_000)
})
})
+128
View File
@@ -0,0 +1,128 @@
// 엔진이 붙잡아 둔 내 발송 — 보내기 취소를 기다리는 중 · 예약 (계약 2절 "보내기 취소 · 예약 발송" — 순수 함수만 둔다)
// 서버 응답의 pendingSend 는 이 함수들로만 읽는다 — 필드가 없거나(9단계 앞 서버) 모양이 틀리면(응답을 바꾼 게이트웨이 등) 없는 것으로 본다.
import type { MessageSummary, PendingSend } from '@/shared/types/mail'
const PENDING_KINDS: readonly string[] = ['undo', 'scheduled']
/** 계약의 모양인가 — kind 가 undo · scheduled 이고 sendAt 이 시각으로 읽힌다 */
export function isPendingSend(value: unknown): value is PendingSend {
if (typeof value !== 'object' || value === null) return false
const candidate = value as { sendAt?: unknown; kind?: unknown }
return (
typeof candidate.kind === 'string' &&
PENDING_KINDS.includes(candidate.kind) &&
typeof candidate.sendAt === 'string' &&
!Number.isNaN(Date.parse(candidate.sendAt))
)
}
/** 응답의 값 → PendingSend 또는 null */
export function pendingSendOf(value: unknown): PendingSend | null {
return isPendingSend(value) ? value : null
}
/** 그 메일의 붙잡아 둔 발송 — 없으면 null */
export function messagePendingSend(message: Pick<MessageSummary, 'pendingSend'> | null | undefined): PendingSend | null {
return pendingSendOf(message?.pendingSend)
}
/**
* 아직 보내지 않은 메일인가(어느 쪽이든 — 보내기 취소를 기다리는 중 · 예약). 이 메일은 취소해야만 보낸편지함을 떠날 수 있다 —
* 옮기거나 지워도 엔진은 그 시각에 보낸다(2026-10-29 리뷰). 그래서 목록에서 고를 수 없고, 읽기 화면에 옮기기 · 스팸 신고가 없으며
* [삭제]는 확인 뒤 취소하고 휴지통으로 옮긴다
*/
export function isHeldMessage(message: Pick<MessageSummary, 'pendingSend'> | null | undefined): boolean {
return messagePendingSend(message) !== null
}
/** 예약한 메일인가 — 읽기 화면의 예약 안내 줄 · 답장 · 전달을 빼는 기준 */
export function isScheduledMessage(message: Pick<MessageSummary, 'pendingSend'> | null | undefined): boolean {
return messagePendingSend(message)?.kind === 'scheduled'
}
/** 두 붙잡은 발송이 같은가 (둘 다 없음 포함) */
export function samePendingSend(a: PendingSend | null, b: PendingSend | null): boolean {
if (a === null || b === null) return a === b
return a.kind === b.kind && a.sendAt === b.sendAt
}
// ── 엔진이 풀 때 다시 보기 (usePendingSendRefresh) ─────────────────────────────
/** 지켜보는 붙잡은 발송 하나 — 같은 메일 · 같은 시각은 하나로 본다(목록의 줄 · 열린 메일 · 가장 이른 예약이 같은 메일일 수 있다) */
export interface PendingWatch {
key: string
/** 보낼 시각 (ms) */
at: number
}
/** 메일들의 붙잡은 발송 → 지켜볼 것 (없거나 모양이 틀린 것은 뺀다) */
export function pendingWatchesOf(
messages: readonly (Pick<MessageSummary, 'id' | 'pendingSend'> | null | undefined)[],
): PendingWatch[] {
const watches = new Map<string, PendingWatch>()
for (const message of messages) {
const pending = messagePendingSend(message)
if (!message || !pending) continue
const key = `${message.id}\n${pending.sendAt}`
if (!watches.has(key)) watches.set(key, { key, at: Date.parse(pending.sendAt) })
}
return [...watches.values()]
}
export interface PendingCheckTiming {
/** 보낼 시각 뒤 이만큼 기다렸다 본다 — 엔진이 풀고 목록에 반영할 틈 */
graceMs: number
/** 시각이 지났는데 아직 붙잡힌 것으로 보이면(서버가 늦음 · 이 PC 시계가 빠름) 이 간격으로 다시 본다 */
recheckMs: number
}
/** 시각(+ 여유)이 지났는데 아직 붙잡힌 것으로 보이는 발송 */
export function overduePendingWatches(watches: readonly PendingWatch[], now: number, graceMs: number): PendingWatch[] {
return watches.filter((watch) => watch.at + graceMs <= now)
}
/**
* 다음에 다시 볼 시각 (ms) — 아직 오지 않은 것은 그 시각 + 여유, 이미 지난 것은 지금 + 다시 보기 간격 가운데 가장 이른 때.
* 지켜볼 것이 없으면 null
*/
export function nextPendingCheckAt(
watches: readonly PendingWatch[],
now: number,
timing: PendingCheckTiming,
): number | null {
let next: number | null = null
for (const watch of watches) {
const due = watch.at + timing.graceMs
const at = due > now ? due : now + timing.recheckMs
if (next === null || at < next) next = at
}
return next
}
// ── 보낸 뒤 알림의 [보내기 취소] 마감 (쓰기 화면) ─────────────────────────────
export interface UndoDeadlineInput {
/** 서버가 보낼 시각 (pendingSend.sendAt) */
sendAt: string
/** 지금 (ms — 응답을 받은 때) */
now: number
/** 알고 있는 보내기 취소 시간(초) — 설정을 받아 두지 않았으면 null */
undoSendSeconds: number | null
/** 설정한 초에서 뺄 몫 — 서버가 붙잡기 시작한 뒤 응답이 오기까지 지난 시간 */
marginMs: number
/** 설정을 모를 때의 가장 긴 시간 — 설정의 가장 긴 값(30초) */
maxMs: number
}
/**
* [보내기 취소] 알림이 닫힐 시각 (ms — 이 PC 시계).
* 설정한 초를 알면 지금 + 초 − 몫 — 이 PC 시계가 서버와 어긋나도(늦으면 sendAt 이 멀리 보인다) 알림이 설정보다 오래 남지 않는다.
* 모르면(또는 0 인데 서버가 붙잡았다 — 다른 PC 에서 바꿈) sendAt 까지, 다만 지금 + 가장 긴 시간을 넘지 않게.
* 이미 지났으면(지금 이하) 부르는 쪽이 [보내기 취소] 없이 알린다
*/
export function undoToastDeadline(input: UndoDeadlineInput): number {
const seconds = input.undoSendSeconds
if (seconds !== null && seconds > 0) return input.now + seconds * 1000 - input.marginMs
return Math.min(Date.parse(input.sendAt), input.now + input.maxMs)
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
browserTimeZoneName,
customScheduleDayRange,
customScheduleOf,
customScheduleTime,
defaultCustomSchedule,
formatTimeSlot,
rejectedScheduleProblem,
schedulePresets,
scheduleProblem,
scheduleTimeSlots,
sendAtOf,
} from './schedule-send'
// 예약 발송의 시각 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 예약 창") — 모두 브라우저 시간대
// 시각은 지역 시각으로 만든다(new Date(연, 월, 일, 시, 분)) — 테스트가 어느 시간대에서 돌아도 같은 결과다
const at = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime()
const MINUTE = 60_000
describe('scheduleProblem — 지금부터 5분 뒤 이상 · 7일 안 (서버와 같은 한도)', () => {
const now = at(10, 29, 15, 0)
it('5분 뒤 · 7일 뒤 그 시각까지는 된다', () => {
expect(scheduleProblem(now + 5 * MINUTE, now)).toBeNull()
expect(scheduleProblem(now + 7 * 24 * 60 * MINUTE, now)).toBeNull()
})
it('5분 안 · 지난 시각 · 읽을 수 없는 시각은 tooSoon, 7일을 넘으면 tooLate', () => {
expect(scheduleProblem(now + 5 * MINUTE - 1, now)).toBe('tooSoon')
expect(scheduleProblem(now - MINUTE, now)).toBe('tooSoon')
expect(scheduleProblem(Number.NaN, now)).toBe('tooSoon')
expect(scheduleProblem(now + 7 * 24 * 60 * MINUTE + 1, now)).toBe('tooLate')
})
it('서버가 받지 않은 시각(VAL_001)의 까닭 — 이 화면의 검사로 고르고, 검사로는 맞으면(시계가 어긋남) 가까운 쪽 한도 (리뷰 #11)', () => {
// 보낸 사이 5분 안이 됐다 · 7일을 넘었다
expect(rejectedScheduleProblem(now + 4 * MINUTE, now)).toBe('tooSoon')
expect(rejectedScheduleProblem(now + 8 * 24 * 60 * MINUTE, now)).toBe('tooLate')
// 이 PC 로는 맞다 — 오늘 저녁은 아래 한도에, 6일 뒤는 위 한도에 가깝다
expect(rejectedScheduleProblem(now + 3 * 60 * MINUTE, now)).toBe('tooSoon')
expect(rejectedScheduleProblem(now + 6 * 24 * 60 * MINUTE, now)).toBe('tooLate')
})
})
describe('schedulePresets — 오늘 오후(남았을 때만) · 내일 오전 · 내일 오후 · 다음 주 월요일', () => {
it('목요일 오후 3시 — 오늘 오후 6:00 이 첫 줄, 다음 주 월요일은 11월 2일 오전 9:00 (보드 그대로)', () => {
expect(schedulePresets(at(10, 29, 15))).toEqual([
{ id: 'todayEvening', at: at(10, 29, 18) },
{ id: 'tomorrowMorning', at: at(10, 30, 9) },
{ id: 'tomorrowAfternoon', at: at(10, 30, 13) },
{ id: 'nextMonday', at: at(11, 2, 9) },
])
})
it('오늘 오후 6:00 이 5분 안이거나 지났으면 「오늘 오후」를 두지 않는다', () => {
expect(schedulePresets(at(10, 29, 17, 56)).map((preset) => preset.id)).toEqual([
'tomorrowMorning',
'tomorrowAfternoon',
'nextMonday',
])
expect(schedulePresets(at(10, 29, 17, 55)).map((preset) => preset.id)[0]).toBe('todayEvening')
expect(schedulePresets(at(10, 29, 21)).map((preset) => preset.id)[0]).toBe('tomorrowMorning')
})
it('월요일 아침 — 다음 주 월요일 오전 9:00 은 7일을 넘어 두지 않는다 (9시가 지나면 7일 안이라 둔다)', () => {
// 2026-11-02 은 월요일
expect(schedulePresets(at(11, 2, 8)).map((preset) => preset.id)).not.toContain('nextMonday')
expect(schedulePresets(at(11, 2, 10))).toContainEqual({ id: 'nextMonday', at: at(11, 9, 9) })
})
it('일요일 — 다음 주 월요일 오전 9:00 이 내일 오전과 같으면 한 줄만 둔다', () => {
// 2026-11-01 은 일요일
const presets = schedulePresets(at(11, 1, 10))
expect(presets.map((preset) => preset.id)).toEqual(['todayEvening', 'tomorrowMorning', 'tomorrowAfternoon'])
expect(presets[1]?.at).toBe(at(11, 2, 9))
})
it('달이 바뀌어도 날짜를 맞춘다 (10월 31일 토요일 → 내일 11월 1일, 다음 주 월요일 11월 2일)', () => {
expect(schedulePresets(at(10, 31, 20))).toEqual([
{ id: 'tomorrowMorning', at: at(11, 1, 9) },
{ id: 'tomorrowAfternoon', at: at(11, 1, 13) },
{ id: 'nextMonday', at: at(11, 2, 9) },
])
})
})
describe('직접 고르기 — 날짜 + 30분 단위 시각', () => {
it('시각은 0:00 부터 23:30 까지 30분마다 48칸 — 글은 "오전 9:00" · "오후 1:30"', () => {
const slots = scheduleTimeSlots()
expect(slots).toHaveLength(48)
expect(slots[0]).toBe(0)
expect(slots.at(-1)).toBe(23 * 60 + 30)
expect(formatTimeSlot(9 * 60)).toBe('오전 9:00')
expect(formatTimeSlot(13 * 60 + 30)).toBe('오후 1:30')
})
it('처음 값은 내일 오전 9:00, 날짜 칸은 오늘부터 7일 뒤까지', () => {
const now = at(10, 29, 15)
expect(defaultCustomSchedule(now)).toEqual({ day: '2026-10-30', minutes: 9 * 60 })
expect(customScheduleDayRange(now)).toEqual({ min: '2026-10-29', max: '2026-11-05' })
})
it('날짜 · 시각 → 보낼 시각, 비었거나 없는 날짜 · 범위 밖의 시각이면 null', () => {
expect(customScheduleTime({ day: '2026-10-30', minutes: 9 * 60 + 30 })).toBe(at(10, 30, 9, 30))
expect(customScheduleTime({ day: '', minutes: 9 * 60 })).toBeNull()
expect(customScheduleTime({ day: '2026-02-30', minutes: 0 })).toBeNull()
expect(customScheduleTime({ day: '2026-10-30', minutes: 24 * 60 })).toBeNull()
expect(customScheduleTime({ day: '2026-10-30', minutes: -30 })).toBeNull()
})
it('보낼 시각 → 직접 고르기의 칸 (서버가 받지 않은 시각으로 창을 다시 열 때) — 30분 단위가 아니면 null', () => {
expect(customScheduleOf(at(10, 29, 18))).toEqual({ day: '2026-10-29', minutes: 18 * 60 })
expect(customScheduleOf(at(11, 2, 9, 30))).toEqual({ day: '2026-11-02', minutes: 9 * 60 + 30 })
expect(customScheduleOf(at(10, 29, 18, 10))).toBeNull()
expect(customScheduleOf(at(10, 29, 18) + 1_000)).toBeNull()
expect(customScheduleOf(Number.NaN)).toBeNull()
})
})
describe('보낼 시각 · 시간대 이름', () => {
it('서버에는 UTC ISO 8601 로 보낸다', () => {
expect(sendAtOf(Date.UTC(2026, 9, 30, 0, 0))).toBe('2026-10-30T00:00:00.000Z')
})
it('시간대 이름은 브라우저가 알려 주는 대로 — 한국 시간대는 "…표준시"', () => {
expect(browserTimeZoneName(Date.UTC(2026, 9, 29), 'Asia/Seoul')).toMatch(/표준시$/)
expect(browserTimeZoneName(Date.UTC(2026, 9, 29), 'Not/AZone')).toBeNull()
})
})
+157
View File
@@ -0,0 +1,157 @@
// 예약 발송의 시각 — 고르기(프리셋) · 직접 고르기 · 검사 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계" — 순수 함수만 둔다)
// 시각은 모두 브라우저 시간대로 계산하고, 서버에는 UTC(ISO 8601)로 보낸다 (toISOString).
// 예약할 수 있는 때는 지금부터 5분 뒤 이상 · 7일 안 — 서버와 같은 한도(밖이면 400 VAL_001).
import {
DAYS_PER_WEEK,
MINUTES_PER_DAY,
MONDAY,
SCHEDULE_CUSTOM_DEFAULT_HOUR,
SCHEDULE_MAX_AHEAD_MS,
SCHEDULE_MIN_LEAD_MS,
SCHEDULE_PRESET_HOURS,
SCHEDULE_SLOT_MINUTES,
type SchedulePresetId,
} from '@/shared/constants/mail-send-later'
import { formatDate } from '@/shared/utils/format'
const MINUTES_PER_HOUR = 60
/** 고르기 한 줄 */
export interface SchedulePreset {
id: SchedulePresetId
/** 보낼 시각 (ms) */
at: number
}
/** 예약할 수 없는 까닭 — 5분 안(지난 시각 포함) · 7일 넘음 */
export type ScheduleProblem = 'tooSoon' | 'tooLate'
/** 서버가 받지 않은 예약 — 그 시각(ms)과 까닭. 쓰기 화면이 예약 창을 다시 열며 넘긴다 */
export interface ScheduleRejection {
at: number
problem: ScheduleProblem
}
/** 「직접 고르기」의 칸 — 날짜(<input type="date"> 값 YYYY-MM-DD)와 그날 0시부터의 분(30분 단위) */
export interface CustomSchedule {
day: string
minutes: number
}
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
/** base 가 든 날(브라우저 시간대)에서 dayOffset 날 뒤의 hour:minute (ms) */
function localTimeOf(base: number, dayOffset: number, hour: number, minute = 0): number {
const date = new Date(base)
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + dayOffset, hour, minute).getTime()
}
/** 예약할 수 있는 시각인가 — 지금부터 5분 뒤 이상 · 7일 안 (서버와 같다) */
export function scheduleProblem(at: number, now: number): ScheduleProblem | null {
if (!Number.isFinite(at) || at < now + SCHEDULE_MIN_LEAD_MS) return 'tooSoon'
if (at > now + SCHEDULE_MAX_AHEAD_MS) return 'tooLate'
return null
}
/**
* 서버가 받지 않은 예약 시각(400 VAL_001)의 까닭 — 이 화면의 검사로 고른다(보낸 사이 5분 안이 됐으면 tooSoon).
* 이 화면의 검사로는 맞는데 서버가 받지 않았으면(이 PC 시계와 서버 시계가 어긋남) 가까운 쪽 한도로 본다
*/
export function rejectedScheduleProblem(at: number, now: number): ScheduleProblem {
const problem = scheduleProblem(at, now)
if (problem) return problem
return at - now < (SCHEDULE_MIN_LEAD_MS + SCHEDULE_MAX_AHEAD_MS) / 2 ? 'tooSoon' : 'tooLate'
}
/** 다음 주 월요일까지의 날 수 — 오늘 다음의 첫 월요일 (오늘이 월요일이면 7일 뒤) */
function daysUntilNextMonday(now: number): number {
const diff = (MONDAY - new Date(now).getDay() + DAYS_PER_WEEK) % DAYS_PER_WEEK
return diff === 0 ? DAYS_PER_WEEK : diff
}
/**
* 예약 창의 고르기 — 오늘 오후 6:00(남았을 때만) · 내일 오전 9:00 · 내일 오후 1:00 · 다음 주 월요일 오전 9:00.
* 예약할 수 없는 시각(5분 안 · 7일 넘음 — 월요일 아침의 "다음 주 월요일" 등)은 뺀다.
* 같은 시각이 겹치면(일요일의 "다음 주 월요일" = 내일 오전) 앞의 것만 둔다
*/
export function schedulePresets(now: number): SchedulePreset[] {
const candidates: SchedulePreset[] = [
{ id: 'todayEvening', at: localTimeOf(now, 0, SCHEDULE_PRESET_HOURS.todayEvening) },
{ id: 'tomorrowMorning', at: localTimeOf(now, 1, SCHEDULE_PRESET_HOURS.tomorrowMorning) },
{ id: 'tomorrowAfternoon', at: localTimeOf(now, 1, SCHEDULE_PRESET_HOURS.tomorrowAfternoon) },
{ id: 'nextMonday', at: localTimeOf(now, daysUntilNextMonday(now), SCHEDULE_PRESET_HOURS.nextMonday) },
]
const seen = new Set<number>()
return candidates.filter((preset) => {
if (scheduleProblem(preset.at, now) !== null || seen.has(preset.at)) return false
seen.add(preset.at)
return true
})
}
/** 「직접 고르기」의 시각 고르기 — 0:00 부터 23:30 까지 30분마다 (그날 0시부터의 분) */
export function scheduleTimeSlots(): number[] {
const slots: number[] = []
for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += SCHEDULE_SLOT_MINUTES) slots.push(minutes)
return slots
}
/** 「직접 고르기」의 처음 값 — 내일 오전 9:00 (보드의 처음 값) */
export function defaultCustomSchedule(now: number): CustomSchedule {
return {
day: formatDate(new Date(localTimeOf(now, 1, 0))),
minutes: SCHEDULE_CUSTOM_DEFAULT_HOUR * MINUTES_PER_HOUR,
}
}
/** 날짜 칸이 고를 수 있는 범위 — 오늘부터 7일 뒤까지 (<input type="date"> 의 min · max) */
export function customScheduleDayRange(now: number): { min: string; max: string } {
return { min: formatDate(new Date(now)), max: formatDate(new Date(now + SCHEDULE_MAX_AHEAD_MS)) }
}
/**
* 보낼 시각(ms) → 「직접 고르기」의 칸 — 서버가 받지 않은 시각으로 예약 창을 다시 열 때 그 시각을 칸에 채운다.
* 30분 단위가 아니면(고르기 · 직접 고르기로는 생기지 않는다) null
*/
export function customScheduleOf(at: number): CustomSchedule | null {
if (!Number.isFinite(at)) return null
const date = new Date(at)
if (date.getSeconds() !== 0 || date.getMilliseconds() !== 0) return null
const minutes = date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
if (minutes % SCHEDULE_SLOT_MINUTES !== 0) return null
return { day: formatDate(date), minutes }
}
/** 「직접 고르기」 → 보낼 시각(ms). 날짜가 비었거나 없는 날짜(2월 30일 등) · 시각이 범위 밖이면 null */
export function customScheduleTime(schedule: CustomSchedule): number | null {
const match = DAY_PATTERN.exec(schedule.day)
if (!match) return null
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
const { minutes } = schedule
if (!Number.isInteger(minutes) || minutes < 0 || minutes >= MINUTES_PER_DAY) return null
const date = new Date(year, month - 1, day, Math.floor(minutes / MINUTES_PER_HOUR), minutes % MINUTES_PER_HOUR)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
return date.getTime()
}
/** 시각 고르기의 글 — "오전 9:00" · "오후 1:30" (브라우저 시간대의 그 시각 그대로) */
export function formatTimeSlot(minutes: number): string {
const date = new Date(2000, 0, 1, Math.floor(minutes / MINUTES_PER_HOUR), minutes % MINUTES_PER_HOUR)
return new Intl.DateTimeFormat('ko-KR', { hour: 'numeric', minute: '2-digit' }).format(date)
}
/** 브라우저가 알려 주는 시간대 이름 — "한국 표준시" 등. 모르면 null (안내 글이 이름 없이 쓴다) */
export function browserTimeZoneName(now: number, timeZone?: string): string | null {
try {
const parts = new Intl.DateTimeFormat('ko-KR', { timeZone, timeZoneName: 'long' }).formatToParts(new Date(now))
return parts.find((part) => part.type === 'timeZoneName')?.value?.trim() || null
} catch {
return null
}
}
/** 보낼 시각(ms) → 서버의 sendAt (UTC ISO 8601) */
export function sendAtOf(at: number): string {
return new Date(at).toISOString()
}
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { BlockedSender } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
import { useMailBlockStore } from './mail-block.store'
// 보낸 사람 차단 스토어 (계약 2절 "보낸 사람 차단" — 9단계)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const SPAM: BlockedSender = { address: 'kim@spam.example', blockedAt: '2026-10-29T00:00:00.000Z' }
const DOMAIN: BlockedSender = { address: '@ads.example', blockedAt: '2026-10-28T00:00:00.000Z' }
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('mail-block.store', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
restoreApiAdapter()
useToastStore().clear()
})
it('다시 받는 중에 풀기가 끝나면 받던 목록을 버리고 "ready" 로 돌린다 — 화면이 빈 자리에 멈추지 않는다 (리뷰 #6)', async () => {
let reload: ReturnType<typeof deferred> | null = null
installMockApi((request) => {
if (request.method === 'DELETE') return ok({ address: SPAM.address })
if (reload) return reload.promise
return ok({ senders: [SPAM, DOMAIN], isActive: true })
})
const store = useMailBlockStore()
await store.load()
// 설정 › 차단한 주소에 들어와 다시 받는 중 — 그사이 앞서 띄운 알림의 [되돌리기]로 푼다
reload = deferred()
const loading = store.load()
expect(store.status).toBe('loading')
await store.remove(SPAM.address)
expect(store.status).toBe('ready')
expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
// 늦게 온 옛 목록(푼 주소가 아직 든)은 버린다
reload.resolve(ok({ senders: [SPAM, DOMAIN], isActive: true }))
await loading
expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
expect(store.status).toBe('ready')
})
it('받아 둔 목록이 없을 때 받는 중에 풀었으면 푼 뒤의 목록으로 다시 받는다', async () => {
let first: ReturnType<typeof deferred> | null = deferred()
const requests = installMockApi((request) => {
if (request.method === 'DELETE') return ok({ address: SPAM.address })
if (first) {
const pending = first
first = null
return pending.promise
}
return ok({ senders: [DOMAIN], isActive: true })
})
const store = useMailBlockStore()
const stale = first
const loading = store.load()
await store.remove(SPAM.address)
stale?.resolve(ok({ senders: [SPAM, DOMAIN], isActive: true }))
await loading
await flushPromises()
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2)
expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
expect(store.status).toBe('ready')
})
it('이미 풀려 있던 풀기(404)는 알리지 않고 목록에서만 뺀다 — 규칙을 저장하지 않았으니 isActive 는 그대로', async () => {
installMockApi((request) =>
request.method === 'DELETE' ? fail(404, 'RES_001') : ok({ senders: [SPAM], isActive: false }),
)
const store = useMailBlockStore()
await store.load()
await store.remove(SPAM.address)
expect(store.senders).toEqual([])
expect(store.blockList?.isActive).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
})
+127
View File
@@ -0,0 +1,127 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMailSettings, type MailSettingsRequestOptions } from '@/composables/useMailSettings'
import { MAIL_BLOCK_MAX } from '@/shared/constants/mail-block'
import type { BlockedSender, MailBlockList } from '@/shared/types/mail'
import { blockOfSender } from '@/shared/utils/mail-block'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 보낸 사람 차단 스토어 — 계약 2절 "보낸 사람 차단" (9단계 — 직원마다 500개까지, 엔진의 규칙 스크립트 맨 앞에 둔다)
// - 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded) — 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람" 줄이 본다.
// 읽기 화면 ⋯ 를 열 때 받아 둔 것이 없으면(못 받았으면) 다시 받아 본다. 못 받으면 알리지 않고 지난 값을 둔다(모르는 동안은 "차단" 항목)
// - 설정 › 차단한 주소는 들어올 때마다 새로 받는다(load) — 다른 탭 · PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
// - 더하기 · 풀기는 응답을 받은 뒤 목록에 넣고 뺀다(최근에 차단한 것이 위). 차단은 필터의 version 을 묻지 않는다 (주소 하나를 더하고 뺀다 — 계약)
// 서버가 저장하면 엔진의 규칙이 다시 켜지므로(필터 · 부재중 · 차단 가운데 무엇이든 — 계약) isActive 도 켠다. 이미 풀려 있던 풀기(404)는 저장하지 않았으니 그대로
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset · 바꾸기가 올린다),
// 더하기 · 풀기는 세션 세대(authStore.generation)로 가린다 (필터 · 부재중 스토어와 같은 결)
export const useMailBlockStore = defineStore('mail-block', () => {
const settingsApi = useMailSettings()
const authStore = useAuthStore()
const blockList = shallowRef<MailBlockList | null>(null)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
const senders = computed<readonly BlockedSender[]>(() => blockList.value?.senders ?? [])
/** 500개를 다 막았다 — 읽기 화면 ⋯ 의 「보낸 사람 차단」을 끄고 까닭을 보인다 */
const isFull = computed<boolean>(() => senders.value.length >= MAIL_BLOCK_MAX)
let seq = 0
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 둔다) */
async function load(): Promise<MailBlockList | null> {
seq += 1
const current = seq
status.value = 'loading'
error.value = null
try {
const result = await settingsApi.getBlocks({ silent: true })
if (current !== seq) return blockList.value
blockList.value = result
status.value = 'ready'
return result
} catch (caught) {
if (current !== seq) return blockList.value
status.value = 'error'
error.value = toApiError(caught)
return null
}
}
/** 받아 둔 것이 없거나 못 받았으면 받는다 — 메일 화면에 들어올 때 · 읽기 화면 ⋯ 를 열 때 (알리지 않는다) */
function ensureLoaded(): void {
if (status.value === 'idle' || status.value === 'error') void load()
}
/** 그 보낸 사람을 막는 차단 — 주소가 같은 것 먼저, 그다음 그 도메인. 없거나 아직 모르면 null */
function blockOf(email: string | null | undefined): BlockedSender | null {
return email ? blockOfSender(senders.value, email) : null
}
/** 응답의 차단을 목록 맨 위에 둔다 (같은 값이 있으면 그 자리를 뺀다 — 이미 있으면 서버가 200 으로 같은 것을 준다) */
function withSender(list: MailBlockList, sender: BlockedSender): MailBlockList {
return { ...list, senders: [sender, ...list.senders.filter((item) => item.address !== sender.address)] }
}
/**
* 차단 (POST /mail-blocks) — 받은 값을 목록 맨 위에 넣는다. 실패는 예외가 그대로 올라간다(칸 안 안내 · 알림은 부르는 쪽 · 인터셉터).
* 기다리는 사이 세션이 바뀌었으면 결과를 돌려주기만 하고 목록에는 쓰지 않는다
*/
async function add(address: string, options?: MailSettingsRequestOptions): Promise<BlockedSender> {
const session = authStore.generation
const sender = await settingsApi.addBlock(address, options)
if (authStore.generation !== session) return sender
seq += 1
blockList.value = { ...withSender(blockList.value ?? { senders: [], isActive: true }, sender), isActive: true }
if (status.value !== 'ready') status.value = 'ready'
return sender
}
/**
* 차단 풀기 (DELETE /mail-blocks/{address}) — 목록에서 뺀다. 없으면(404 — 다른 곳에서 이미 풀었다) 푼 것과 같다 — 알리지 않고 목록에서 뺀다.
* 그 밖의 실패는 예외가 그대로 올라간다(알림은 인터셉터). 기다리는 사이 세션이 바뀌었으면 목록에는 쓰지 않는다.
* 받는 중인 목록(load)은 푼 것보다 옛것일 수 있어 버린다 — 상태도 'ready' 로 돌린다(더하기와 같다 — 버린 받기가 'loading' 에 멈춰 두지 않게).
* 받아 둔 목록이 아직 없으면 고칠 목록이 없다 — 받는 중이었으면 푼 뒤의 목록으로 다시 받는다
*/
async function remove(address: string): Promise<void> {
const session = authStore.generation
/** 서버가 규칙을 저장했다 (이미 풀려 있던 404 는 아니다) */
let saved = true
try {
await settingsApi.removeBlock(address, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') throw caught
saved = false
}
if (authStore.generation !== session) return
if (!blockList.value) {
if (status.value === 'loading') void load()
return
}
seq += 1
blockList.value = {
senders: blockList.value.senders.filter((item) => item.address !== address),
isActive: saved || blockList.value.isActive,
}
status.value = 'ready'
error.value = null
}
function reset() {
seq += 1
blockList.value = null
status.value = 'idle'
error.value = null
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 차단 목록이 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return { blockList, senders, status, error, isFull, load, ensureLoaded, blockOf, add, remove, reset }
})
+173
View File
@@ -0,0 +1,173 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMailSettings } from '@/composables/useMailSettings'
import type { MailSettings, UndoSendSeconds } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
type SettingKey = keyof MailSettings
// 보내기 · 읽기 설정 스토어 — 계약 2절 "보내기 · 읽기 설정" (9단계 — 직원마다 서버 DB 에 하나, 어느 PC 에서나 같다)
// - 설정 › 메일 › 보내기 · 읽기는 들어올 때마다 새로 받는다(load) — 다른 PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다
// - 고르는 대로 그 칸만 PATCH 한다(저장 단추 없음). 화면은 늘 마지막으로 고른 값을 보인다.
// **칸마다 저장은 한 번에 하나**(6단계 필터의 saving 과 같은 결) — 보내는 동안 다시 고르면 마지막으로 고른 것만 기억해 두고,
// 앞 저장이 끝난 뒤 그것이 서버의 답과 다르면 보낸다. 두 저장을 함께 보내면 서버가 나중에 처리한 쪽이 남는데, 응답 차례로는 그것을 알 수 없어
// 화면("늘 묻기")과 DB("늘 보기")가 갈렸다(2026-10-29 리뷰 — 외부 이미지는 개인정보 영향).
// 마지막으로 고른 것의 저장이 실패하면 그 칸을 서버가 마지막으로 확인해 준 값으로 되돌린다(오류 알림은 인터셉터)
// - 기다리는 저장이 모두 끝났고 마지막으로 끝난 저장이 성공이면 saved — 화면이 "저장했습니다"를 보인다. 다시 고르면 내린다
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) · 다시 받은 뒤(load) 늦게 온 응답은 쓰지 않는다 (authStore.generation · 순번)
export const useMailSettingsStore = defineStore('mail-settings', () => {
const settingsApi = useMailSettings()
const authStore = useAuthStore()
/** 보이는 값 — 고르면 저장이 끝나기 전에 먼저 바뀐다 (마지막으로 고른 값) */
const settings = ref<MailSettings | null>(null)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
/** 기다리는 저장이 모두 끝났고 마지막이 성공했다 — "저장했습니다" */
const saved = ref<boolean>(false)
/** 저장하는 중인 칸 수 */
const pending = ref<number>(0)
/**
* 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
* 쓰기 화면이 [보내기 취소] 알림의 마감을 이 초로 센다 (이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게 — 계약 2절)
*/
const knownUndoSendSeconds = computed<UndoSendSeconds | null>(() =>
status.value === 'ready' && settings.value ? settings.value.undoSendSeconds : null,
)
/** 서버가 마지막으로 확인해 준 값 — 마지막으로 고른 것의 저장이 실패하면 그 칸을 이 값으로 되돌린다 */
let confirmed: MailSettings | null = null
/** 칸마다 마지막으로 고른 값 — 보내는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
const wanted = new Map<SettingKey, MailSettings[SettingKey]>()
/** 칸마다 도는 저장 — 한 칸에 하나. 끝나면 지운다 */
const chains = new Map<SettingKey, Promise<boolean>>()
/** 마지막으로 끝난 저장 요청이 성공했다 — 모두 끝났을 때 "저장했습니다"를 보일지 */
let lastSucceeded = false
let loadSeq = 0
/** 기다리던 저장을 모두 잊는다 — 받은 값을 새로 쓰기 전(load) · 세션이 바뀔 때(reset). 늦게 끝나는 저장은 순번으로 버린다 */
function forgetSaves() {
wanted.clear()
chains.clear()
pending.value = 0
lastSucceeded = false
}
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
async function load(): Promise<MailSettings | null> {
loadSeq += 1
const current = loadSeq
status.value = 'loading'
error.value = null
saved.value = false
// 저장하던 중에 떠났다 돌아왔으면 그 저장은 이 화면의 것이 아니다 — 세던 수가 남으면 "저장했습니다"가 다시 뜨지 않는다 (리뷰 #7)
forgetSaves()
try {
const result = await settingsApi.getSettings({ silent: true })
if (current !== loadSeq) return settings.value
settings.value = { ...result }
confirmed = { ...result }
status.value = 'ready'
return result
} catch (caught) {
if (current !== loadSeq) return settings.value
status.value = 'error'
error.value = toApiError(caught)
return null
}
}
/** 받아 둔 것이 없거나 못 받았으면 받는다 — 메일 화면에 들어올 때 (알리지 않는다) */
function ensureLoaded(): void {
if (status.value === 'idle' || status.value === 'error') void load()
}
function setKey<K extends SettingKey>(key: K, value: MailSettings[K]) {
if (settings.value) settings.value = { ...settings.value, [key]: value }
}
/** 그 칸의 저장을 보낸다 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
async function flush<K extends SettingKey>(key: K): Promise<boolean> {
const session = authStore.generation
const sessionLoad = loadSeq
const stillCurrent = () => authStore.generation === session && loadSeq === sessionLoad
pending.value += 1
let succeeded = false
try {
let value = wanted.get(key) as MailSettings[K]
for (;;) {
let next: MailSettings[K] | undefined
try {
const result = await settingsApi.updateSettings({ [key]: value } as Partial<MailSettings>)
if (!stillCurrent()) return false
succeeded = true
confirmed = { ...(confirmed ?? result), [key]: result[key] }
const latest = wanted.get(key) as MailSettings[K]
// 그사이 다른 값을 골랐고 그것이 서버의 답과 다르면 이어 보낸다 — 아니면 서버의 답으로 맞추고 끝낸다
if (latest !== value && latest !== result[key]) next = latest
else setKey(key, result[key])
} catch {
if (!stillCurrent()) return false
succeeded = false
const latest = wanted.get(key) as MailSettings[K]
// 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값으로 되돌린다
if (latest !== value && latest !== confirmed?.[key]) next = latest
else if (confirmed) setKey(key, confirmed[key])
}
if (next === undefined) break
value = next
}
return succeeded
} finally {
if (stillCurrent()) {
wanted.delete(key)
chains.delete(key)
lastSucceeded = succeeded
pending.value = Math.max(0, pending.value - 1)
if (pending.value === 0) saved.value = lastSucceeded
}
}
}
/**
* 한 칸을 고른다 — 화면을 먼저 바꾸고 PATCH { key: value }. 그 칸의 저장이 이미 돌고 있으면 보내지 않고 고른 값만 기억한다
* (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받은 설정이 없으면 보내지 않는다(false).
* 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
*/
function update<K extends SettingKey>(key: K, value: MailSettings[K]): Promise<boolean> {
if (!settings.value || status.value !== 'ready') return Promise.resolve(false)
const running = chains.get(key)
// 지금 값을 다시 눌렀다 — 보낼 것이 없다
if (!running && settings.value[key] === value) return Promise.resolve(true)
setKey(key, value)
wanted.set(key, value)
saved.value = false
if (running) return running
const chain = flush(key)
chains.set(key, chain)
return chain
}
function reset() {
loadSeq += 1
settings.value = null
confirmed = null
forgetSaves()
status.value = 'idle'
error.value = null
saved.value = false
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 설정이 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return { settings, status, error, saved, pending, knownUndoSendSeconds, load, ensureLoaded, update, reset }
})
@@ -0,0 +1,456 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { MessageDetail, MessageSummary, PendingSend } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
import { useMailStore } from './mail.store'
// 메일 스토어의 9단계 — 외부 이미지 보기 · 예약됨 목록 · 예약한 메일 수 · 붙잡은 발송 취소
// (계약 2절 "외부 이미지 보기" · "보내기 취소 · 예약 발송")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const SCHEDULED: PendingSend = { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-10-29T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(id: string, partial: Partial<MessageDetail> = {}): MessageDetail {
return {
...summary(id, { mailboxIds: ['inbox'], from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' } }),
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: '<p>안녕하세요</p>',
textBody: '안녕하세요',
hasBlockedImages: true,
imagesShown: false,
attachments: [],
...partial,
}
}
function page(items: MessageSummary[], total = items.length, nextCursor: string | null = null) {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } })
}
/** 돌려줄 응답을 나중에 정하는 요청 */
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
describe('mail.store — 9단계', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
restoreApiAdapter()
useToastStore().clear()
})
describe('외부 이미지 보기 — showImages', () => {
const SHOWN = detail('m1', {
htmlBody: '<p>안녕하세요</p><img src="/api/mail-images/tok">',
hasBlockedImages: false,
imagesShown: true,
isRead: false,
})
it('열린 메일을 images=show 로 다시 받아 본문만 바꾼다 — 그사이 바뀐 읽음 · 중요 표시는 지금 값 그대로', async () => {
const requests = installMockApi((request) => (request.params.images === 'show' ? ok(SHOWN) : ok({})))
const store = useMailStore()
store.selectedId = 'm1'
store.selected = detail('m1', { isFlagged: true })
store.selectedStatus = 'ready'
await expect(store.showImages()).resolves.toBe(true)
expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
['GET', '/messages/m1', { images: 'show' }],
])
expect(store.selected).toMatchObject({
htmlBody: SHOWN.htmlBody,
hasBlockedImages: false,
imagesShown: true,
isFlagged: true,
isRead: true,
})
expect(store.imagesLoadingId).toBeNull()
})
it('받는 사이 다른 메일을 열었으면 늦은 응답을 버린다', async () => {
const late = deferred()
installMockApi((request) => {
if (request.params.images === 'show') return late.promise
return ok(detail('m2', { hasBlockedImages: false }))
})
const store = useMailStore()
store.selectedId = 'm1'
store.selected = detail('m1')
store.selectedStatus = 'ready'
const showing = store.showImages()
expect(store.imagesLoadingId).toBe('m1')
await store.openMessage('m2')
late.resolve(ok(SHOWN))
await expect(showing).resolves.toBe(false)
expect(store.selected?.id).toBe('m2')
expect(store.selected?.imagesShown).toBe(false)
})
it('이미 보이는 메일 · 받는 중에 또 누르면 다시 받지 않고, 실패하면 그대로 둔다(알림은 인터셉터)', async () => {
const requests = installMockApi(() => fail(503, 'MAIL_001'))
const store = useMailStore()
store.selectedId = 'm1'
store.selected = detail('m1')
store.selectedStatus = 'ready'
const first = store.showImages()
await expect(store.showImages()).resolves.toBe(false)
await expect(first).resolves.toBe(false)
expect(requests).toHaveLength(1)
expect(store.selected?.hasBlockedImages).toBe(true)
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
store.selected = { ...SHOWN }
await expect(store.showImages()).resolves.toBe(false)
expect(requests).toHaveLength(1)
})
})
describe('예약됨 목록 — GET /scheduled-messages (보낼 시각 차례)', () => {
it('예약됨을 열면 그 목록을 받고, 전체 수(total)로 패널의 「예약됨」 수를 맞춘다 — 더 보기도 같은 길', async () => {
const requests = installMockApi((request) => {
if (request.url !== '/scheduled-messages') return ok({})
if (request.params.cursor === 'c2') return page([summary('s3', { pendingSend: SCHEDULED })], 3)
return page([summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })], 3, 'c2')
})
const store = useMailStore()
await store.openScheduled()
expect(store.listSource).toEqual({ kind: 'scheduled' })
expect(store.currentMailboxId).toBeNull()
expect(store.messages.map((message) => message.id)).toEqual(['s1', 's2'])
expect(store.scheduledCount).toBe(3)
await store.loadMore()
expect(store.messages.map((message) => message.id)).toEqual(['s1', 's2', 's3'])
expect(requests.map((request) => [request.url, request.params])).toEqual([
['/scheduled-messages', { limit: 20 }],
['/scheduled-messages', { cursor: 'c2', limit: 20 }],
])
})
it('새 메일 확인으로 맞출 때 보낸 메일은 빠지고 — "새 메일 N통" 줄은 띄우지 않는다', async () => {
let items = [summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
installMockApi((request) => (request.url === '/scheduled-messages' ? page(items) : ok({})))
const store = useMailStore()
await store.openScheduled()
items = [summary('s0', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
await store.syncVisibleList()
expect(store.messages.map((message) => message.id)).toEqual(['s0', 's2'])
expect(store.listSync?.addedAbove).toBe(0)
expect(store.scheduledCount).toBe(2)
})
it('예약한 메일 수는 limit=1 의 total — 못 받으면 알리지 않고 지난 값을 둔다', async () => {
let reply: MockResult = page([summary('s1', { pendingSend: SCHEDULED })], 4)
const requests = installMockApi(() => reply)
const store = useMailStore()
await store.loadScheduledCount()
expect(store.scheduledCount).toBe(4)
expect(requests[0]?.params).toEqual({ limit: 1 })
reply = fail(503, 'MAIL_001')
await store.loadScheduledCount()
expect(store.scheduledCount).toBe(4)
expect(useToastStore().toasts).toEqual([])
})
it('늦게 온 개수 응답은 그 뒤에 받은 예약됨 목록의 total 을 덮지 않는다', async () => {
const late = deferred()
installMockApi((request) => (request.params.limit === 1 ? late.promise : page([summary('s1')], 1)))
const store = useMailStore()
const counting = store.loadScheduledCount()
await store.openScheduled()
late.resolve(page([], 9))
await counting
expect(store.scheduledCount).toBe(1)
})
})
describe('붙잡은 발송 취소 — DELETE /scheduled-messages/{id}', () => {
it('취소하면 예약됨 목록에서 빼고 개수 · 예약한 메일 수를 다시 맞춘다 — draftId 를 돌려준다', async () => {
let scheduled = [summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
const requests = installMockApi((request) => {
if (request.method === 'DELETE') {
scheduled = scheduled.filter((item) => item.id !== 's1')
return ok({ draftId: 's1' })
}
if (request.url === '/scheduled-messages') return page(scheduled)
if (request.url === '/mailboxes') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
})
const store = useMailStore()
await store.openScheduled()
store.selectedId = 's1'
store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
store.selectedStatus = 'ready'
await expect(store.cancelPendingSend('s1')).resolves.toEqual({ draftId: 's1' })
await flushPromises()
expect(store.messages.map((message) => message.id)).toEqual(['s2'])
expect(store.selected?.pendingSend).toBeNull()
expect(store.scheduledCount).toBe(1)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(
expect.arrayContaining(['DELETE /scheduled-messages/s1', 'GET /mailboxes', 'GET /mail-quota']),
)
})
it('이미 보냈으면(409 MAIL_014) 빼지 않고 예외가 올라간다 — 알림은 인터셉터, 보이는 목록 · 예약됨 수는 다시 본다', async () => {
installMockApi((request) =>
request.method === 'DELETE' ? fail(409, 'MAIL_014') : page([summary('s1', { pendingSend: SCHEDULED })]),
)
const store = useMailStore()
await store.openScheduled()
await expect(store.cancelPendingSend('s1')).rejects.toMatchObject({ status: 409 })
expect(store.messages.map((message) => message.id)).toEqual(['s1'])
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
})
})
describe('아직 보내지 않은 메일(붙잡은 발송)은 고를 수 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (리뷰 #1)', () => {
const UNDO: PendingSend = { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' }
function sentList() {
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = [
summary('a'),
summary('held-1', { pendingSend: UNDO }),
summary('b'),
summary('held-2', { pendingSend: SCHEDULED }),
summary('c'),
]
store.listStatus = 'ready'
return store
}
it('하나 고르기 · 범위 · 모두 고르기가 붙잡은 발송을 건너뛴다 — 머리 상자도 고를 수 있는 메일 기준', () => {
const store = sentList()
store.toggleCheck('held-1')
expect([...store.checkedIds]).toEqual([])
store.toggleCheck('a')
store.checkRangeTo('c')
expect([...store.checkedIds].sort()).toEqual(['a', 'b', 'c'])
expect(store.checkState).toBe('all')
store.clearChecked()
store.toggleCheckAll()
expect([...store.checkedIds].sort()).toEqual(['a', 'b', 'c'])
store.toggleCheckAll()
expect(store.checkedCount).toBe(0)
})
it('여러 통 처리에 붙잡은 발송이 들어가지 않는다 — 고른 뒤 붙잡은 발송이 되면 고른 것에서도 빠진다', async () => {
const requests = installMockApi((request) =>
request.method === 'PATCH'
? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
: ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }),
)
const store = sentList()
store.toggleCheck('a')
store.toggleCheck('b')
// 다른 창에서 같은 메일이 붙잡힌 발송으로 보이게 됐다(목록을 다시 맞춤)
store.messages = store.messages.map((message) => (message.id === 'b' ? { ...message, pendingSend: UNDO } : message))
expect([...store.checkedIds]).toEqual(['a'])
await store.moveChecked('trash')
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
{ ids: ['a'], mailboxId: 'trash' },
])
})
})
describe('엔진이 붙잡은 발송을 풀었는지 다시 보기 — refreshPendingSends (리뷰 #2)', () => {
it('보이는 목록(붙잡은 메일이 보이면 검색 결과도) · 열린 메일 · 예약됨 수를 알리지 않고 다시 본다 — 풀린 메일의 표시가 내려간다', async () => {
const held = summary('s1', { pendingSend: SCHEDULED })
const requests = installMockApi((request) => {
if (request.url === '/messages') return page([{ ...held, pendingSend: null }])
if (request.url === '/messages/s1') return ok({ ...detail('s1'), pendingSend: null })
if (request.url === '/scheduled-messages') return page([], 0)
return ok({})
})
const store = useMailStore()
store.listSource = { kind: 'search', q: '배포', scope: 'all' }
store.messages = [held]
store.listStatus = 'ready'
store.selectedId = 's1'
store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
store.selectedStatus = 'ready'
await store.refreshPendingSends()
expect(requests.map((request) => [request.method, request.url])).toEqual(
expect.arrayContaining([
['GET', '/messages'],
['GET', '/messages/s1'],
['GET', '/scheduled-messages'],
]),
)
expect(store.messages[0]?.pendingSend).toBeNull()
expect(store.selected?.pendingSend).toBeNull()
expect(store.scheduledCount).toBe(0)
expect(useToastStore().toasts).toEqual([])
})
it('목록을 다시 맞추면 열린 메일의 붙잡은 발송도 맞춘다 (refreshSelectedFrom)', async () => {
installMockApi(() => page([summary('s1', { pendingSend: null })]))
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = [summary('s1', { pendingSend: SCHEDULED })]
store.listStatus = 'ready'
store.selectedId = 's1'
store.selected = { ...detail('s1', { mailboxIds: ['sent'] }), pendingSend: SCHEDULED }
await store.syncVisibleList()
expect(store.selected?.pendingSend).toBeNull()
})
it('열린 메일을 다시 보는 사이 다른 메일을 열었으면 늦은 응답을 버린다', async () => {
const late = deferred()
installMockApi((request) => {
if (request.url === '/messages/s1') return late.promise
if (request.url === '/messages/m2') return ok(detail('m2'))
return page([], 0)
})
const store = useMailStore()
store.selectedId = 's1'
store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
store.selectedStatus = 'ready'
const refreshing = store.refreshPendingSends()
await store.openMessage('m2')
late.resolve(ok({ ...detail('s1'), pendingSend: null }))
await refreshing
expect(store.selected?.id).toBe('m2')
})
it('예약됨 수를 물으면 가장 이른 예약(첫 줄)을 지켜볼 수 있게 둔다', async () => {
installMockApi(() => page([summary('s1', { pendingSend: SCHEDULED })], 3))
const store = useMailStore()
await store.loadScheduledCount()
expect(store.nextScheduled?.id).toBe('s1')
useAuthStore().clearAuth()
await flushPromises()
expect(store.nextScheduled).toBeNull()
})
})
describe('붙잡은 발송 취소 — 같은 메일은 한 번에 하나 · 늦은 응답은 그 세션 · 그 목록에서만 (리뷰 #9 · #12)', () => {
it('같은 메일을 거듭 취소하면 요청은 하나 — 둘 다 같은 결과를 받는다', async () => {
const cancel = deferred()
const requests = installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([], 0)))
const store = useMailStore()
const first = store.cancelPendingSend('s1')
const second = store.cancelPendingSend('s1')
expect(store.isCancellingPendingSend('s1')).toBe(true)
cancel.resolve(ok({ draftId: 's1' }))
await expect(first).resolves.toEqual({ draftId: 's1' })
await expect(second).resolves.toEqual({ draftId: 's1' })
expect(requests.filter((request) => request.method === 'DELETE')).toHaveLength(1)
expect(store.isCancellingPendingSend('s1')).toBe(false)
})
it('기다리는 사이 다른 계정으로 바뀌었으면 늦은 응답으로 새 계정의 목록을 고치지 않는다', async () => {
const cancel = deferred()
installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([], 0)))
const store = useMailStore()
const cancelling = store.cancelPendingSend('s1')
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
await flushPromises()
// 새 계정의 목록 — 메일 id 가 앞 계정과 겹칠 수 있다
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = [summary('s1', { pendingSend: SCHEDULED })]
store.listStatus = 'ready'
cancel.resolve(ok({ draftId: 's1' }))
await cancelling
expect(store.messages.map((message) => message.id)).toEqual(['s1'])
expect(store.messages[0]?.pendingSend).toEqual(SCHEDULED)
})
it('기다리는 사이 다른 목록으로 옮겨 갔으면 그 목록에서 빼지 않는다', async () => {
const cancel = deferred()
installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([summary('s1')])))
const store = useMailStore()
store.listSource = { kind: 'scheduled' }
store.messages = [summary('s1', { pendingSend: SCHEDULED })]
store.listStatus = 'ready'
const cancelling = store.cancelPendingSend('s1')
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('s1', { mailboxIds: ['inbox'] })]
cancel.resolve(ok({ draftId: 's1' }))
await cancelling
expect(store.messages.map((message) => message.id)).toEqual(['s1'])
})
})
it('로그아웃하면 예약한 메일 수도 비운다', async () => {
installMockApi(() => page([], 2))
const store = useMailStore()
await store.loadScheduledCount()
expect(store.scheduledCount).toBe(2)
useAuthStore().clearAuth()
await flushPromises()
expect(store.scheduledCount).toBe(0)
})
})
+22 -2
View File
@@ -8,17 +8,19 @@ import { createListState } from './mail/list'
import { createMailboxAdmin } from './mail/mailbox-admin'
import { createMailboxesState } from './mail/mailboxes'
import { createMessageActions, createOpenedMessage } from './mail/message'
import { createScheduledState } from './mail/scheduled'
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'
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 엔진이 풀었는지 다시 보기
//
// 오류 처리 원칙:
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
@@ -39,6 +41,13 @@ export const useMailStore = defineStore('mail', () => {
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
const scheduled = createScheduledState({
mailApi,
mailboxes: mailboxState,
list,
opened,
sessionGeneration: () => authStore.generation,
})
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
const lastBrowseLocation = ref<string | null>(null)
@@ -73,6 +82,7 @@ export const useMailStore = defineStore('mail', () => {
function reset() {
admin.reset()
scheduled.reset()
mailboxState.reset()
list.reset()
selection.clearChecked()
@@ -122,6 +132,7 @@ export const useMailStore = defineStore('mail', () => {
hasMore: list.hasMore,
neighborAfterRemoval: list.neighborAfterRemoval,
openMailbox: list.openMailbox,
openScheduled: list.openScheduled,
openSearch: list.openSearch,
rememberSearchOrigin: list.rememberSearchOrigin,
refresh: list.refresh,
@@ -141,11 +152,20 @@ export const useMailStore = defineStore('mail', () => {
selected: opened.selected,
selectedStatus: opened.selectedStatus,
selectedError: opened.selectedError,
imagesLoadingId: opened.imagesLoadingId,
openMessage: message.openMessage,
closeMessage: opened.closeMessage,
markRead: message.markRead,
showImages: message.showImages,
toggleFlag: message.toggleFlag,
moveMessage: message.moveMessage,
// 예약됨 · 붙잡은 발송 (9단계)
scheduledCount: scheduled.scheduledCount,
nextScheduled: scheduled.nextScheduled,
loadScheduledCount: scheduled.loadScheduledCount,
cancelPendingSend: scheduled.cancelPendingSend,
isCancellingPendingSend: scheduled.isCancellingPendingSend,
refreshPendingSends: scheduled.refreshPendingSends,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
moveMessages: bulk.moveMessages,
+60 -10
View File
@@ -9,17 +9,20 @@
import { computed, ref, watch } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import { DEFAULT_PAGE_SIZE, type PageInfo, type PaginatedResult } from '@/shared/types/pagination'
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
import {
listQueryOf,
sameListSource,
scopeMailboxIdOf,
SCHEDULED_LIST_SOURCE,
type MailListSource,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
import { messagePendingSend, samePendingSend } from '@/shared/utils/pending-send'
import type { MailboxesState } from './mailboxes'
import type { OpenedMessage } from './message'
import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
@@ -162,18 +165,29 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
})
}
/** 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함)를 맞춘다 */
/**
* 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함 · 붙잡은 발송)를 맞춘다 — 엔진이 붙잡은 발송을 풀었으면(9단계)
* 읽기 화면의 예약 안내 줄 · 보내지 않은 메일의 삭제 확인도 함께 내려간다
*/
function refreshSelectedFrom(list: readonly MessageSummary[]) {
const current = opened.selected.value
if (!current) return
const fresh = list.find((message) => message.id === current.id)
if (!fresh) return
const pendingSend = messagePendingSend(fresh)
const changed =
fresh.isRead !== current.isRead ||
fresh.isFlagged !== current.isFlagged ||
fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n')
fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n') ||
!samePendingSend(pendingSend, messagePendingSend(current))
if (changed) {
opened.selected.value = { ...current, isRead: fresh.isRead, isFlagged: fresh.isFlagged, mailboxIds: fresh.mailboxIds }
opened.selected.value = {
...current,
isRead: fresh.isRead,
isFlagged: fresh.isFlagged,
mailboxIds: fresh.mailboxIds,
pendingSend,
}
}
}
@@ -186,6 +200,30 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
// ── 목록 열기 · 맞추기 ───────────────────────────────
/**
* 출처의 한 쪽 — 메일함 · 검색은 GET /messages, 예약됨은 GET /scheduled-messages (보낼 시각 차례 — 9단계).
* cursor 는 해석하지 않고 그대로 넘긴다
*/
function fetchPage(
source: MailListSource,
page: { cursor?: string; limit: number },
options?: MailRequestOptions,
): Promise<PaginatedResult<MessageSummary>> {
if (source.kind === 'scheduled') return mailApi.listScheduledMessages(page, options)
return mailApi.listMessages({ ...listQueryOf(source), ...page }, options)
}
/** 예약됨 목록의 전체 수를 받았다 — 메일함 패널의 「예약됨」 개수가 이 값을 쓴다 (scheduled.ts) */
let onScheduledTotal: ((total: number | null) => void) | null = null
function watchScheduledTotal(callback: (total: number | null) => void) {
onScheduledTotal = callback
}
function reportTotal(source: MailListSource, info: PageInfo) {
if (source.kind === 'scheduled') onScheduledTotal?.(info.total)
}
/**
* 목록 열기 — 첫 페이지를 불러온다. 다른 목록이면 고른 것이 풀린다 (메일함을 바꾸거나 검색하면 선택을 푼다).
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
@@ -206,8 +244,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
try {
const page = await mailApi.listMessages(
{ ...listQueryOf(next), limit: DEFAULT_PAGE_SIZE },
const page = await fetchPage(
next,
{ limit: DEFAULT_PAGE_SIZE },
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
{ silent: keepVisible ? undefined : true },
)
@@ -216,6 +255,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
listError.value = null
reportTotal(next, page.pageInfo)
} catch (error) {
if (seq !== listSeq || keepVisible) return
listStatus.value = 'error'
@@ -230,6 +270,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
await openList({ kind: 'mailbox', mailboxId })
}
/** 예약됨 열기 (9단계) — 같은 목록으로 돌아오면 보이던 목록을 둔 채 새로 고친다 (보낸 메일은 저절로 빠진다) */
async function openScheduled(): Promise<void> {
await openList(SCHEDULED_LIST_SOURCE)
}
/**
* 검색 결과 열기 (계약 2절 "검색") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL).
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
@@ -263,12 +308,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const seq = listSeq
isLoadingMore.value = true
try {
const page = await mailApi.listMessages({ ...listQueryOf(source), cursor, limit: DEFAULT_PAGE_SIZE })
const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
if (seq !== listSeq) return
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
const known = new Set(messages.value.map((message) => message.id))
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
pageInfo.value = page.pageInfo
reportTotal(source, page.pageInfo)
} catch {
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
} finally {
@@ -280,7 +326,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 (계약 2절 "새 메일 확인") —
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다.
* 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다
*/
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise<void> {
const source = listSource.value
@@ -291,7 +338,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const generation = localGeneration
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
try {
const page = await mailApi.listMessages({ ...listQueryOf(source), limit }, { silent: true })
const page = await fetchPage(source, { limit }, { silent: true })
if (seq !== listSeq || listStatus.value !== 'ready') return
if (generation !== localGeneration) {
if (attempt === 0) await syncVisibleList(options, attempt + 1)
@@ -306,8 +353,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
messages.value = merged.messages
pageInfo.value = merged.pageInfo
refreshSelectedFrom(merged.messages)
reportTotal(source, page.pageInfo)
listSyncSeq += 1
listSync.value = { seq: listSyncSeq, addedAbove: merged.addedAbove }
listSync.value = { seq: listSyncSeq, addedAbove: source.kind === 'scheduled' ? 0 : merged.addedAbove }
} catch {
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
}
@@ -351,11 +399,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
neighborAfterRemoval,
openList,
openMailbox,
openScheduled,
openSearch,
rememberSearchOrigin,
refresh,
loadMore,
syncVisibleList,
watchScheduledTotal,
reset,
}
}
+54 -2
View File
@@ -14,6 +14,8 @@ export function createOpenedMessage() {
const selected = ref<MessageDetail | null>(null)
const selectedStatus = ref<LoadStatus>('idle')
const selectedError = ref<ApiError | null>(null)
/** 외부 이미지를 보이는 본문을 받는 중인 메일 — 읽기 화면의 [이미지 보기]가 기다린다 (9단계) */
const imagesLoadingId = ref<string | null>(null)
/** 메일을 빠르게 바꿀 때 늦게 온 본문 응답이 덮어쓰지 않게 */
let detailSeq = 0
@@ -22,6 +24,11 @@ export function createOpenedMessage() {
return detailSeq
}
/** 지금 순번 — 올리지 않고 읽는다 (이미지 보기처럼 같은 메일을 다시 받을 때 그사이 다른 메일을 열었는지 본다) */
function currentDetailSeq(): number {
return detailSeq
}
function isCurrentDetail(seq: number): boolean {
return seq === detailSeq
}
@@ -32,9 +39,20 @@ export function createOpenedMessage() {
selected.value = null
selectedStatus.value = 'idle'
selectedError.value = null
imagesLoadingId.value = null
}
return { selectedId, selected, selectedStatus, selectedError, nextDetailSeq, isCurrentDetail, closeMessage }
return {
selectedId,
selected,
selectedStatus,
selectedError,
imagesLoadingId,
nextDetailSeq,
currentDetailSeq,
isCurrentDetail,
closeMessage,
}
}
export type OpenedMessage = ReturnType<typeof createOpenedMessage>
@@ -78,6 +96,7 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
opened.selected.value = null
opened.selectedStatus.value = 'loading'
opened.selectedError.value = null
opened.imagesLoadingId.value = null
try {
const detail = await mailApi.getMessage(id, { silent: true })
if (!opened.isCurrentDetail(seq)) return
@@ -91,6 +110,39 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
}
}
/**
* [이미지 보기] — 열린 메일을 `images=show` 로 다시 받아 본문만 바꾼다 (9단계 "외부 이미지 보기" — 그 메일만, 다시 열면 설정대로).
* 그사이 다른 메일을 열었거나 닫았으면(순번이 바뀜) 늦은 응답을 버린다. 읽음 · 중요 · 메일함 표시는 지금 값 그대로 둔다.
* 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다. 바꿨으면 true
*/
async function showImages(): Promise<boolean> {
const current = opened.selected.value
if (!current || current.imagesShown || opened.imagesLoadingId.value === current.id) return false
const id = current.id
const seq = opened.currentDetailSeq()
opened.imagesLoadingId.value = id
try {
const detail = await mailApi.getMessage(id, { showImages: true })
const latest = opened.selected.value
if (!opened.isCurrentDetail(seq) || latest?.id !== id) return false
opened.selected.value = {
...latest,
htmlBody: detail.htmlBody,
textBody: detail.textBody,
hasBlockedImages: detail.hasBlockedImages,
imagesShown: detail.imagesShown === true,
isHtmlBodyOmitted: detail.isHtmlBodyOmitted,
isBodyTruncated: detail.isBodyTruncated,
attachments: detail.attachments,
}
return true
} catch {
return false
} finally {
if (opened.imagesLoadingId.value === id) opened.imagesLoadingId.value = null
}
}
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function toggleFlag(id: string): Promise<void> {
const current =
@@ -131,5 +183,5 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
return true
}
return { markRead, openMessage, toggleFlag, moveMessage }
return { markRead, openMessage, showImages, toggleFlag, moveMessage }
}
+156
View File
@@ -0,0 +1,156 @@
// 메일 스토어 모듈 — 예약됨 개수 · 붙잡은 발송 취소 · 엔진이 풀었는지 다시 보기 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, mail.store.ts 가 조립한다)
// - 메일함 패널의 「예약됨」(있을 때만)은 GET /scheduled-messages?limit=1 의 total 이다. 다시 묻는 때(계약): 메일 화면에 들어올 때 ·
// 보내거나 취소한 뒤 · 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 (MailLayout · 쓰기 화면이 부른다). 예약됨 목록을 받으면 그 total 로도 맞춘다.
// 못 받으면 알리지 않고 지난 값을 둔다 (보조 정보 — 사용량과 같은 결). 같은 응답의 첫 줄(가장 이른 예약)은 nextScheduled — 풀릴 때를 지켜본다
// - 붙잡은 발송 취소(보내기 취소 · 예약 취소 · [고치기] · 보내지 않은 메일 [삭제])는 한 길이다 — 취소한 메일은 임시보관함으로 옮겨진다(같은 id).
// 같은 메일의 취소는 한 번에 하나다 — 이어 누른 두 번째가 첫 번째의 결과를 기다린다(두 번째 요청이 "이미 보냈다"(MAIL_014)로 거짓 알림을 띄우지 않게)
// - 엔진이 붙잡은 발송을 풀어도(보냄) 새 메일 확인 · 실시간 알림의 state 는 바뀌지 않는다 — 그래서 보낼 시각이 지나면 보이는 목록 · 열린 메일 ·
// 예약됨 수를 다시 본다(refreshPendingSends — 때는 usePendingSendRefresh 가 정한다). 취소가 MAIL_014(이미 보냄)이면 바로 다시 본다
import { ref } from 'vue'
import { toApiError } from '@/composables/useApi'
import type { MailRequestOptions } from '@/composables/useMail'
import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
import { sameListSource } from '@/shared/utils/mail-list-source'
import { messagePendingSend, pendingSendOf, samePendingSend } 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'
interface ScheduledDeps {
mailApi: MailApi
mailboxes: MailboxesState
list: ListState
opened: OpenedMessage
/** 지금 세션의 세대 (authStore.generation) — 기다리는 사이 세션이 바뀌었으면 늦은 응답으로 목록을 고치지 않는다 */
sessionGeneration: () => number
}
export function createScheduledState({ mailApi, mailboxes, list, opened, sessionGeneration }: ScheduledDeps) {
/** 예약한 메일 수 — 0 이면 패널에 「예약됨」을 그리지 않는다 (예약됨을 보는 동안은 그린다 — MailboxTree) */
const scheduledCount = ref<number>(0)
/** 가장 이른 예약 — 개수를 물은 응답의 첫 줄. 그 시각이 지나면 예약됨 수를 다시 묻는다 (usePendingSendRefresh) */
const nextScheduled = ref<MessageSummary | null>(null)
let countSeq = 0
/** 메일마다 진행 중인 취소 — 같은 메일의 두 번째 취소는 이것을 기다린다 */
const cancels = new Map<string, Promise<ScheduledMessageCancel>>()
/** 예약한 메일 수를 다시 묻는다 — 실패는 알리지 않고 지난 값을 둔다 */
async function loadScheduledCount(): Promise<void> {
countSeq += 1
const seq = countSeq
try {
const page = await mailApi.listScheduledMessages({ limit: SCHEDULED_COUNT_PAGE_SIZE }, { silent: true })
if (seq !== countSeq) return
scheduledCount.value = page.pageInfo.total ?? page.items.length
nextScheduled.value = page.items[0] ?? null
} catch {
// 보조 정보 — 다음에 물을 때 맞춘다
}
}
// 예약됨 목록을 받았다 — 그 total 이 더 새롭다 (앞서 보낸 개수 질문의 응답은 버린다. 가장 이른 예약은 그 목록의 첫 줄로 지켜본다)
list.watchScheduledTotal((total) => {
if (total === null) return
countSeq += 1
scheduledCount.value = total
})
/**
* 열린 메일의 붙잡은 발송을 다시 본다 — 알리지 않는다(silent). 붙잡은 발송이 없는 메일이면 묻지 않는다.
* 그사이 다른 메일을 열었으면(상세 순번) 늦은 응답을 버린다. 본문 · 읽음 표시는 그대로 두고 pendingSend 만 맞춘다
*/
async function refreshOpenedPendingSend(): Promise<void> {
const current = opened.selected.value
if (!current || messagePendingSend(current) === null) return
const id = current.id
const seq = opened.currentDetailSeq()
try {
const fresh = await mailApi.getMessage(id, { silent: true })
const latest = opened.selected.value
if (!opened.isCurrentDetail(seq) || latest?.id !== id) return
const pending = pendingSendOf(fresh.pendingSend)
if (!samePendingSend(pending, messagePendingSend(latest))) opened.selected.value = { ...latest, pendingSend: pending }
} catch {
// 다음에 다시 볼 때 맞춘다
}
}
/**
* 엔진이 붙잡은 발송을 풀었는지 다시 본다 — 보이는 목록(붙잡은 메일이 보이면 검색 결과도) · 열린 메일 · 예약됨 수.
* 모두 알리지 않는다. 다 끝나면 돌아온다
*/
async function refreshPendingSends(): Promise<void> {
const includeSearch = list.messages.value.some((message) => messagePendingSend(message) !== null)
await Promise.all([list.syncVisibleList({ includeSearch }), refreshOpenedPendingSend(), loadScheduledCount()])
}
/** 같은 메일의 취소가 진행 중인가 — 화면은 두 번째 누름에 아무것도 하지 않는다 */
function isCancellingPendingSend(id: string): boolean {
return cancels.has(id)
}
async function sendCancel(id: string, options?: MailRequestOptions): Promise<ScheduledMessageCancel> {
const session = sessionGeneration()
const source = list.listSource.value
let result: ScheduledMessageCancel
try {
result = await mailApi.cancelScheduledMessage(id, options)
} catch (error) {
// 이미 보냈다(엔진이 풀었다) — 화면에 남은 "보내는 중" · 예약 표시를 바로 다시 맞춘다
if (sessionGeneration() === session && toApiError(error).code === 'MAIL_014') void refreshPendingSends()
throw error
}
// 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다 (스토어도 이미 비웠다)
if (sessionGeneration() !== session) return result
// 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다
if (sameListSource(list.listSource.value, source) && (source?.kind === 'scheduled' || source?.kind === 'mailbox')) {
list.removeFromList(new Set([id]))
}
const current = opened.selected.value
if (current?.id === id && sameListSource(list.listSource.value, source)) {
opened.selected.value = { ...current, pendingSend: null }
}
void list.syncVisibleList({ includeSearch: true })
mailboxes.syncCounts()
void loadScheduledCount()
return result
}
/**
* 붙잡은 발송을 취소한다 (DELETE /scheduled-messages/{id}) — 그 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id → draftId).
* 예약됨 · 메일함 목록에서는 뺀다(보낸편지함을 떠났다 — 임시보관함이면 다시 맞추며 들어온다), 열린 메일이면 붙잡은 발송 표시를 내린다.
* 개수 · 예약됨 수를 다시 맞춘다. 같은 메일의 취소가 진행 중이면 새로 보내지 않고 그 결과를 함께 기다린다.
* 실패(이미 보냄 409 MAIL_014 · 없음 404 등)는 예외가 그대로 올라간다 — 알림은 인터셉터가 띄운다(options.silent 를 주지 않으면)
*/
function cancelPendingSend(id: string, options?: MailRequestOptions): Promise<ScheduledMessageCancel> {
const running = cancels.get(id)
if (running) return running
const cancel = sendCancel(id, options).finally(() => {
if (cancels.get(id) === cancel) cancels.delete(id)
})
cancels.set(id, cancel)
return cancel
}
function reset() {
countSeq += 1
scheduledCount.value = 0
nextScheduled.value = null
cancels.clear()
}
return {
scheduledCount,
nextScheduled,
loadScheduledCount,
cancelPendingSend,
isCancellingPendingSend,
refreshPendingSends,
reset,
}
}
export type ScheduledState = ReturnType<typeof createScheduledState>
+26 -23
View File
@@ -1,6 +1,8 @@
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
// 아직 보내지 않은 메일(붙잡은 발송 — 보내는 중 · 예약, 9단계)은 고를 수 없다: 옮기거나 지워도 엔진은 그 시각에 보낸다 — 취소해야만 보낸편지함을 떠난다.
// 하나 고르기 · 범위 · 모두 고르기가 모두 건너뛰고, 고른 메일(checkedMessages — 여러 통 처리가 쓰는 것)에도 들지 않는다 (pending-send.ts 의 isHeldMessage)
import { computed, shallowRef, watch } from 'vue'
import type { MessageSummary } from '@/shared/types/mail'
@@ -11,6 +13,7 @@ import {
toggleChecked,
type CheckAllState,
} from '@/shared/utils/mail-selection'
import { isHeldMessage } from '@/shared/utils/pending-send'
import type { ListState } from './list'
export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>) {
@@ -19,21 +22,22 @@ export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>)
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
let checkAnchorId: string | null = null
/** 고를 수 있는 메일 id — 목록 순서 그대로, 붙잡은 발송은 뺀다 */
const selectableIds = computed<string[]>(() =>
list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
)
const checkedCount = computed<number>(() => checkedIds.value.size)
/** 고른 메일 — 목록 순서 그대로 */
/** 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다) */
const checkedMessages = computed<MessageSummary[]>(() =>
checkedIds.value.size === 0 ? [] : list.messages.value.filter((message) => checkedIds.value.has(message.id)),
)
/** 표 머리 고르기 상자의 상태 */
const checkState = computed<CheckAllState>(() =>
checkAllState(
checkedIds.value,
list.messages.value.map((message) => message.id),
),
checkedIds.value.size === 0
? []
: list.messages.value.filter((message) => checkedIds.value.has(message.id) && !isHeldMessage(message)),
)
/** 표 머리 고르기 상자의 상태 — 고를 수 있는 메일 기준 */
const checkState = computed<CheckAllState>(() => checkAllState(checkedIds.value, selectableIds.value))
function visibleIds(): string[] {
return list.messages.value.map((message) => message.id)
function isSelectable(id: string): boolean {
return selectableIds.value.includes(id)
}
function clearChecked() {
@@ -41,32 +45,31 @@ export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>)
checkAnchorId = null
}
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭 */
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 메일이면 아무것도 하지 않는다 */
function toggleCheck(id: string) {
if (!isSelectable(id)) return
checkedIds.value = toggleChecked(checkedIds.value, id)
checkAnchorId = id
}
/** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 */
/** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 (고를 수 없는 메일은 건너뛴다) */
function checkRangeTo(id: string) {
checkedIds.value = checkRange(checkedIds.value, visibleIds(), checkAnchorId, id)
if (!isSelectable(id)) return
checkedIds.value = checkRange(checkedIds.value, selectableIds.value, checkAnchorId, id)
checkAnchorId = id
}
/** 표 머리 상자 — 불러온 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
/** 표 머리 상자 — 고를 수 있는 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
function toggleCheckAll() {
if (checkState.value === 'all') clearChecked()
else checkedIds.value = new Set(visibleIds())
else checkedIds.value = new Set(selectableIds.value)
}
// 목록에서 빠진 메일은 고른 것에서도 뺀다
// 목록에서 빠진 메일 · 고를 수 없게 된 메일은 고른 것에서도 뺀다
watch(
list.messages,
(messages) => {
const next = pruneChecked(
checkedIds.value,
messages.map((message) => message.id),
)
selectableIds,
(ids) => {
const next = pruneChecked(checkedIds.value, ids)
if (next !== checkedIds.value) checkedIds.value = next
},
{ flush: 'sync' },
+64
View File
@@ -232,4 +232,68 @@ describe('toast.store', () => {
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.' })
})
})
describe('마감 시각이 있는 알림 — 9단계 [보내기 취소] (디자인 원본 MailSendLater)', () => {
it('그 시각에 닫힌다 — 마우스를 올리거나 초점이 들어와도 멈추지 않는다', () => {
const store = useToastStore()
const expiresAt = Date.now() + 10_000
const id = store.show({ kind: 'success', message: '메일을 보냈습니다.', action: undo, expiresAt })
store.hold(id, 'pointer')
store.hold(id, 'focus')
vi.advanceTimersByTime(9_999)
expect(messages()).toEqual(['메일을 보냈습니다.'])
vi.advanceTimersByTime(1)
expect(messages()).toEqual([])
})
it('놓아도 처음부터 다시 세지 않는다 — 단추가 있어도 10초가 아니라 그 시각', () => {
const store = useToastStore()
const id = store.show({ kind: 'error', message: '보냈지만 1명은 받지 못합니다', action: undo, expiresAt: Date.now() + 5_000 })
vi.advanceTimersByTime(3_000)
store.hold(id, 'pointer')
store.release(id, 'pointer')
vi.advanceTimersByTime(2_000)
expect(messages()).toEqual([])
})
it('알림이 넘쳐도 다른 알림부터 닫는다 — 그 시각이 지나면 되돌릴 수 없다', () => {
const store = useToastStore()
store.show({ kind: 'success', message: '메일을 보냈습니다.', action: undo, expiresAt: Date.now() + 10_000 })
store.show({ kind: 'success', message: '하나' })
store.show({ kind: 'success', message: '둘' })
store.show({ kind: 'success', message: '셋' })
expect(messages()).toEqual(['메일을 보냈습니다.', '둘', '셋'])
})
it('진행 알림에는 마감이 없다', () => {
const store = useToastStore()
store.show({ kind: 'progress', message: '비우는 중', expiresAt: Date.now() + 1_000 })
vi.advanceTimersByTime(60_000)
expect(store.toasts.map((toast) => toast.expiresAt)).toEqual([null])
})
it('ToastHost — 둘째 줄에 남은 시간 막대(<progress>, 장식)가 줄어든다', async () => {
const router = createTestRouter()
const wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, router] } })
useToast().success('메일을 보냈습니다.', { label: '보내기 취소', run: () => undefined }, { expiresAt: Date.now() + 10_000 })
useToast().success('옮겼습니다')
await nextTick()
const [timed, plain] = wrapper.findAll('.toast')
expect(plain?.find('progress').exists()).toBe(false)
const bar = timed?.get('progress')
expect(bar?.attributes('aria-hidden')).toBe('true')
expect(bar?.attributes('max')).toBe('10000')
expect(Number(bar?.attributes('value'))).toBe(10_000)
vi.advanceTimersByTime(4_000)
await nextTick()
expect(Number(timed?.get('progress').attributes('value'))).toBe(6_000)
wrapper.unmount()
})
})
})
+64 -14
View File
@@ -13,6 +13,8 @@ import { useAuthStore } from '@/stores/auth.store'
// 알림이 넘쳐도 다른 알림부터 닫는다. 끝나면 같은 알림을 결과(성공 · 오류)로 바꾼다(update) — 그때부터 시간을 센다.
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림과 진행 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
// 마감 시각이 있는 알림(expiresAt — 9단계 [보내기 취소]: 서버가 그 시각에 보낸다)은 그때 닫는다 — 마우스 · 초점이 있어도 멈추지 않는다
// (그 시각이 지나면 단추가 할 수 있는 일이 없다 — WCAG 2.2.1 의 실시간 예외). ToastHost 가 남은 시간 막대를 그린다.
export type ToastKind = 'error' | 'success' | 'progress'
/** 정해진 시간 뒤에 닫히는 알림 */
@@ -29,6 +31,10 @@ export interface Toast {
/** 서버 추적 ID — 화면에는 앞 8자리를 "문의 코드"로 보인다 */
traceId: string | null
action: ToastAction | null
/** 띄운 시각(ms — Date.now) — 남은 시간 막대의 처음 길이 */
shownAt: number
/** 닫히는 시각(ms — Date.now) — 정해 두면 그때 닫고 붙잡아도 멈추지 않는다 (보내기 취소). 없으면 null */
expiresAt: number | null
}
export interface ToastInput {
@@ -36,6 +42,8 @@ export interface ToastInput {
message: string
traceId?: string | null
action?: ToastAction | null
/** 닫히는 시각(ms — Date.now) — 진행 알림에는 쓰지 않는다 */
expiresAt?: number | null
}
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 (진행 알림은 뒤로 미룬다) */
@@ -53,21 +61,31 @@ export const TOAST_ACTION_DURATION_MS = 10_000
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
export type ToastHoldReason = 'pointer' | 'focus'
/** 닫히기까지의 시간 — 진행 알림은 없다(null) */
/** 닫히기까지의 시간 — 진행 알림은 없다(null). 마감 시각이 있으면 그것이 앞선다(deadlineOf) */
function durationOf(toast: Pick<Toast, 'kind' | 'action'>): number | null {
if (toast.kind === 'progress') return null
return toast.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[toast.kind]
}
/** 넘친 만큼 닫을 알림 — 오래된 것부터, 진행 중인 알림은 다른 알림이 모자랄 때만. 방금 띄운 것은 닫지 않는다 */
/** 마감 시각 — 진행 알림은 마감이 없다 */
function deadlineOf(toast: Pick<Toast, 'kind' | 'expiresAt'>): number | null {
return toast.kind === 'progress' ? null : toast.expiresAt
}
/** 넘쳐도 뒤로 미루는 알림 — 진행 중인 알림 · 마감 시각이 있는 알림(보내기 취소 — 그 시각이 지나면 되돌릴 수 없다) */
function isLingering(toast: Toast): boolean {
return toast.kind === 'progress' || toast.expiresAt !== null
}
/** 넘친 만큼 닫을 알림 — 오래된 것부터, 진행 중 · 마감이 있는 알림은 다른 알림이 모자랄 때만. 방금 띄운 것은 닫지 않는다 */
function overflowOf(all: readonly Toast[], keepId: number): Set<number> {
let excess = all.length - MAX_VISIBLE_TOASTS
const evicted = new Set<number>()
for (const preferProgress of [false, true]) {
for (const includeLingering of [false, true]) {
for (const toast of all) {
if (excess <= 0) return evicted
if (toast.id === keepId || evicted.has(toast.id)) continue
if (!preferProgress && toast.kind === 'progress') continue
if (!includeLingering && isLingering(toast)) continue
evicted.add(toast.id)
excess -= 1
}
@@ -80,6 +98,8 @@ export const useToastStore = defineStore('toast', () => {
const timers = new Map<number, ReturnType<typeof setTimeout>>()
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 (진행 알림은 없다) */
const durations = new Map<number, number>()
/** 마감 시각이 있는 알림 — 그 시각에 닫는다. 붙잡아도 멈추지 않는다 (위 머리말) */
const deadlines = new Map<number, number>()
const holds = new Map<number, Set<ToastHoldReason>>()
let nextId = 1
@@ -91,7 +111,8 @@ export const useToastStore = defineStore('toast', () => {
function startTimer(id: number) {
stopTimer(id)
const duration = durations.get(id)
const deadline = deadlines.get(id)
const duration = deadline === undefined ? durations.get(id) : Math.max(0, deadline - Date.now())
if (duration === undefined) return
timers.set(
id,
@@ -103,6 +124,7 @@ export const useToastStore = defineStore('toast', () => {
function forget(id: number) {
stopTimer(id)
durations.delete(id)
deadlines.delete(id)
holds.delete(id)
}
@@ -111,10 +133,31 @@ export const useToastStore = defineStore('toast', () => {
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
/** 닫히기까지의 시간을 정한다 — 붙잡혀 있지 않으면 바로 센다 (진행 알림은 시간이 없다) */
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action'>) {
/**
* 알림의 단추를 누른다 — 알림을 닫고 그 단추를 돌려준다(부르는 쪽이 할 일을 한다). 이미 닫힌 알림이면 null —
* 닫히는 동안(퇴장 전환 0.18초) 한 번 더 눌러도 할 일은 한 번만 한다 (2026-10-29 리뷰: [보내기 취소]를 두 번 보내 거짓 "이미 보냄" 알림)
*/
function takeAction(id: number): ToastAction | null {
const toast = toasts.value.find((item) => item.id === id)
if (!toast) return null
dismiss(id)
return toast.action
}
/**
* 닫히기까지의 시간을 정한다 — 붙잡혀 있지 않으면 바로 센다 (진행 알림은 시간이 없다).
* 마감 시각이 있으면 붙잡혀 있어도 그 시각까지 센다
*/
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action' | 'expiresAt'>) {
const duration = durationOf(toast)
const deadline = deadlineOf(toast)
stopTimer(id)
if (deadline !== null) {
deadlines.set(id, deadline)
startTimer(id)
return
}
deadlines.delete(id)
if (duration === null) {
durations.delete(id)
return
@@ -124,7 +167,7 @@ export const useToastStore = defineStore('toast', () => {
}
/**
* 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다.
* 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 (마감 시각이 있는 알림은 그대로 센다).
* 진행 알림도 기억해 둔다 — 붙잡은 채 결과로 바뀌면 놓을 때부터 시간을 센다
*/
function hold(id: number, reason: ToastHoldReason) {
@@ -132,15 +175,15 @@ export const useToastStore = defineStore('toast', () => {
const reasons = holds.get(id) ?? new Set<ToastHoldReason>()
reasons.add(reason)
holds.set(id, reasons)
stopTimer(id)
if (!deadlines.has(id)) stopTimer(id)
}
/** 놓는다 — 붙잡은 까닭이 모두 사라지면 그 알림의 시간을 처음부터 다시 센다 */
/** 놓는다 — 붙잡은 까닭이 모두 사라지면 그 알림의 시간을 처음부터 다시 센다 (마감 시각이 있는 알림은 이미 세고 있다) */
function release(id: number, reason: ToastHoldReason) {
const reasons = holds.get(id)
if (!reasons?.delete(reason) || reasons.size > 0) return
holds.delete(id)
startTimer(id)
if (!deadlines.has(id)) startTimer(id)
}
function show(input: ToastInput): number {
@@ -153,6 +196,8 @@ export const useToastStore = defineStore('toast', () => {
message: input.message,
traceId: input.traceId ?? null,
action: input.action ?? null,
shownAt: Date.now(),
expiresAt: input.kind === 'progress' ? null : (input.expiresAt ?? null),
}
const all = [...toasts.value, next]
const evicted = overflowOf(all, id)
@@ -171,15 +216,19 @@ export const useToastStore = defineStore('toast', () => {
function update(id: number, patch: Partial<Omit<ToastInput, 'kind'>> & { kind?: ToastKind }): boolean {
const current = toasts.value.find((toast) => toast.id === id)
if (!current) return false
const kind = patch.kind ?? current.kind
const next: Toast = {
...current,
kind: patch.kind ?? current.kind,
kind,
message: patch.message ?? current.message,
traceId: patch.traceId === undefined ? current.traceId : patch.traceId,
action: patch.action === undefined ? current.action : patch.action,
expiresAt: kind === 'progress' ? null : patch.expiresAt === undefined ? current.expiresAt : patch.expiresAt,
}
toasts.value = toasts.value.map((toast) => (toast.id === id ? next : toast))
if (next.kind !== current.kind || next.action !== current.action) setDuration(id, next)
if (next.kind !== current.kind || next.action !== current.action || next.expiresAt !== current.expiresAt) {
setDuration(id, next)
}
return true
}
@@ -187,6 +236,7 @@ export const useToastStore = defineStore('toast', () => {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
durations.clear()
deadlines.clear()
holds.clear()
toasts.value = []
}
@@ -201,5 +251,5 @@ export const useToastStore = defineStore('toast', () => {
const authStore = useAuthStore()
watch(() => authStore.generation, dismissSessionBound)
return { toasts, show, update, dismiss, hold, release, clear }
return { toasts, show, update, dismiss, takeAction, hold, release, clear }
})
+5
View File
@@ -48,6 +48,11 @@ a:hover {
outline-offset: var(--focus-ring-offset);
}
/* 초점을 잃었을 때 대신 받은 화면 영역(main — 닫힌 알림 안에 있던 초점 등, ToastHost) — 영역 전체에 고리를 그리지 않는다 */
[data-focus-fallback]:focus {
outline: none;
}
/*
* 글자 입력칸 포커스 — 한 가지 규칙 (디자인 README "메일 쓰기 · 글자 입력칸 포커스")
* 받는 사람·참조·숨은참조 행, 제목 행, 본문 편집 영역에 .compose-field 를 붙인다.
+4
View File
@@ -50,6 +50,9 @@
--color-on-brand-muted: #a3c2c6;
--color-on-brand-link: #9ed6db;
--color-on-brand-success: #9fe0b5;
--color-on-brand-track: rgba(255, 255, 255, 0.18); /* 알림 안 남은 시간 막대의 바탕 (디자인 원본 MailSendLater) */
--color-on-brand-progress: #7fd3da; /* 알림 안 남은 시간 막대 — [보내기 취소] 가 남은 동안 (디자인 원본 MailSendLater) */
--color-on-brand-divider: rgba(255, 255, 255, 0.35); /* [보내기 ▾] 두 단추 사이 선 (디자인 원본 MailSendLater) */
/* ── 색: 글자 ───────────────────────────── */
--color-text: #171c26;
@@ -81,6 +84,7 @@
--color-warning-surface: #fff7e6;
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
--color-warning-hover: #fdebc8; /* 노란 줄 안의 단추에 마우스를 올렸을 때 — 메일 필터 [다시 불러오기] · [다시 켜기] (디자인 원본 MailFilterStates) */
--color-warning-border: #f0d9a8; /* 노란 줄 안의 흰 단추 테두리 — 읽기 화면 [이미지 보기] (디자인 원본 MailReadTools) */
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
--color-star: #b7791f;
+21
View File
@@ -28,6 +28,15 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 9단계 — 인쇄 화면 · 예약됨 (router/routes.ts 와 같은 이름 · 모양)
{ path: '/mail/print/:messageId', name: 'mail-print', component: Empty, meta: { requiresAuth: true } },
{ path: '/mail/scheduled', name: 'mail-scheduled', component: Empty, meta: { requiresAuth: true } },
{
path: '/mail/scheduled/:messageId',
name: 'mail-scheduled-message',
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { requiresAuth: true } },
{
path: '/mail/:mailboxId/:messageId',
@@ -60,6 +69,18 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/send-read',
name: 'settings-mail-send-read',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/blocks',
name: 'settings-mail-blocks',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/notify',
name: 'settings-mail-notify',