feat(frontend): 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 화면

쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]),
인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절.

리뷰로 고친 것:
- 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다).
  읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다.
- 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도).
- 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다.
- 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도).
- 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다.
- 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게),
  차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 22:08:06 +09:00
parent b0412ac453
commit b99ccfbf6c
96 changed files with 9749 additions and 329 deletions
@@ -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"]')