feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면

- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰
  새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다
- 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다
- 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게
- 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다
- 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다
- 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:23:22 +09:00
parent a79f0791d0
commit 5710151c0b
88 changed files with 8717 additions and 418 deletions
File diff suppressed because it is too large Load Diff
+73 -20
View File
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h, type FunctionalComponent } from 'vue'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import { CLOCK } from '@/composables/useClock'
import {
AUTH_CHANNEL_NAME,
closeLogoutChannel,
@@ -21,6 +22,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { createManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
@@ -100,6 +102,14 @@ describe('ComposePage', () => {
vi.useRealTimers()
})
/**
* 화면에 주는 것 — 지금 경로(이동 가드) · 손으로 돌리는 시계.
* 시계를 넘기지 않으면 자동 저장(입력이 3초 멈추면)이 돌지 않는다 — 이 파일은 자동 저장을 보지 않는다 (ComposePage.drafts.spec.ts 가 본다)
*/
function screenProvide(router: ReturnType<typeof createTestRouter>): Record<symbol, unknown> {
return { ...routeViewProvide(router), [CLOCK as symbol]: createManualClock() }
}
/**
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
@@ -113,7 +123,7 @@ describe('ComposePage', () => {
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
@@ -906,8 +916,24 @@ describe('ComposePage', () => {
})
})
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
describe('나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인")', () => {
const DIALOG = '#compose-leave-dialog'
/**
* 임시 저장이 늘 실패하는 서버 — 떠나기 전에 저장하지 못해 묻는다.
* (저장이 되면 묻지 않고 떠난다 — 그 흐름은 ComposePage.drafts.spec.ts 가 본다)
*/
const unsavable =
(base: MockHandler): MockHandler =>
(request) =>
request.url.startsWith('/drafts') ? fail(503, 'MAIL_001') : base(request)
/** 임시 저장을 받는 서버 — 처음은 POST 로 d1 */
const savable =
(base: MockHandler): MockHandler =>
(request) =>
request.method === 'POST' && request.url === '/drafts'
? ok({ id: 'd1', attachments: [], updatedAt: '2026-10-27T06:12:00Z' }, 201)
: base(request)
const draftSaves = (requests: MockRequest[]) => requests.filter((request) => request.url.startsWith('/drafts'))
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const forwardApi: MockHandler = (request) =>
request.method === 'GET' && request.url === '/messages/m1'
@@ -969,20 +995,28 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
it('손대지 않은 전달 초안(원본 첨부 포함)은 저장하지도 묻지도 않는다 — 원본 첨부를 빼면 바꾼 것이라 떠나기 전에 저장한다', async () => {
const first = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
expect(blocksUnload()).toBe(false)
await first.router.push('/mail/inbox')
expect(draftSaves(first.requests)).toEqual([])
first.wrapper.unmount()
const { wrapper, router, requests } = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(true)
void router.push('/mail/inbox')
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(draftSaves(requests).map((request) => [request.method, request.url])).toEqual([['POST', '/drafts']])
expect(draftSaves(requests)[0]?.data).toMatchObject({ subject: 'Fwd: 메일 서버 이전 일정 공유', attachments: [] })
})
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
it('저장하지 못하면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), true)
await writeSubject(wrapper, '일정 공유')
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
subjectInput.focus()
@@ -995,8 +1029,8 @@ describe('ComposePage', () => {
expect(dialog.attributes('role')).toBe('alertdialog')
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
expect(wrapper.get('#compose-leave-title').text()).toBe('저장하지 못한 내용이 있습니다')
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.')
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('compose-leave-stay')
@@ -1013,13 +1047,32 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
it('「취소」는 없다 — 닫기 ✕ 는 저장한 채 닫는다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
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([
'보내기',
'임시 저장',
])
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__cancel').trigger('click')
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(draftSaves(requests)[0]?.data).toMatchObject({ body: '쓰던 본문' })
})
it('저장하지 못하면 닫기 ✕ 도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
@@ -1037,7 +1090,7 @@ describe('ComposePage', () => {
})
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
await writeSubject(wrapper, '일정 공유')
const navigation = router.push('/mail/inbox')
await flushPromises()
@@ -1053,7 +1106,7 @@ describe('ComposePage', () => {
})
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), false, ['/mail/inbox'])
await writeSubject(wrapper, '초안')
router.back()
@@ -1076,7 +1129,7 @@ describe('ComposePage', () => {
})
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
await writeSubject(wrapper, '일정 공유')
const first = router.push('/mail/inbox')
@@ -1093,8 +1146,8 @@ describe('ComposePage', () => {
expect(dialogOpen(wrapper)).toBe(false)
})
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 저장부터 하고, 못 하면 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', unsavable(replyApi))
await writeSubject(wrapper, 'Re: 고친 제목')
const navigation = router.push('/mail/compose')
@@ -1348,7 +1401,7 @@ describe('ComposePage', () => {
})
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
mountedWrappers.push(sessionDialog)
await writeSubject(wrapper, '쓰던 메일')
@@ -1388,7 +1441,7 @@ describe('ComposePage', () => {
const router = createTestRouter()
await router.push('/mail/compose?forward=m1')
await router.isReady()
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: screenProvide(router) } })
mountedWrappers.push(wrapper)
await flushPromises()
File diff suppressed because it is too large Load Diff
+3
View File
@@ -3,6 +3,7 @@
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
import AppShell from '@/components/shell/AppShell.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
@@ -11,6 +12,8 @@ const mailStore = useMailStore()
void mailStore.ensureMailboxes()
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
</script>
<template>
@@ -0,0 +1,460 @@
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 { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailSignature, SaveSignaturePayload, SignatureImage } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import MailSignaturePage from './MailSignaturePage.vue'
// 설정 › 메일 › 서명 (디자인 원본 Settings · 계약 2절 "서명")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
const NEW_IMAGE_ID = '00000000-0000-4000-8000-000000000001'
const SAVED_AT = '2026-10-27T06:00:00Z'
const SIGNATURE: MailSignature = {
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p></div>',
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
function imageOf(id: string, width: number): SignatureImage {
return { id, type: 'image/png', size: 1000, width, height: 60 }
}
interface Server {
signature?: MailSignature | MockResult
/** 그림 올리기 응답 — 비면 NEW_IMAGE_ID · 폭 480 */
uploads?: MockResult[]
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
function server(options: Server = {}): MockHandler {
const uploads = [...(options.uploads ?? [])]
return (request) => {
const { method, url } = request
if (method === 'GET' && url === '/mail-signature') {
const signature = options.signature ?? SIGNATURE
return typeof signature === 'string' || 'status' in signature ? signature : ok(signature)
}
if (method === 'POST' && url === '/mail-signature/images') {
return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
}
if (method === 'PUT' && url === '/mail-signature') {
if (options.save) return options.save(request)
const body = request.data as SaveSignaturePayload
return ok({ ...body, images: [], updatedAt: SAVED_AT })
}
return ok(null)
}
}
describe('MailSignaturePage', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
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()
})
async function mountPage(handler: MockHandler, waitForEditor = true) {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push('/settings/mail/signature')
await router.isReady()
// 서명 칸의 나가기 확인(onBeforeRouteLeave)이 지금 경로에 걸리게 — RouterView 가 주는 값과 같다
const wrapper = mount(MailSignaturePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
if (waitForEditor) {
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
}
return { wrapper, requests, router }
}
function choose(wrapper: VueWrapper, files: File[]) {
const picker = wrapper.get('#signature-image-input')
Object.defineProperty(picker.element, 'files', { value: files, configurable: true })
return picker.trigger('change')
}
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
function saveRequest(requests: MockRequest[]) {
return requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature')
}
it('저장한 서명으로 칸을 채우고, 넣을 메일 · 미리 보기를 보인다', async () => {
const { wrapper } = await mountPage(server())
expect(wrapper.get('h1').text()).toBe('서명')
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀')
expect(wrapper.get('#signature-body').attributes('aria-labelledby')).toBe('signature-label')
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('서명 서식')
expect(wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))).toEqual([
'굵게',
'기울임',
'밑줄',
'글자 색: 기본',
'링크',
'그림 넣기',
])
expect((wrapper.get('#signature-for-new').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('#signature-for-reply').element as HTMLInputElement).checked).toBe(true)
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀')
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('새 메일은 본문 끝에 서명을 넣습니다.')
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
let fails = 1
const { wrapper, requests } = await mountPage(
(request) => {
if (request.url === '/mail-signature' && fails > 0) {
fails -= 1
return fail(503, 'MAIL_001')
}
return server()(request)
},
false,
)
expect(wrapper.get('[role="alert"]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
await wrapper.get('[role="alert"] button').trigger('click')
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
expect(requests.filter((request) => request.url === '/mail-signature')).toHaveLength(2)
})
it('[저장] — 쓰기 본문과 같은 서식 본문 · 텍스트 서명 · 넣을 메일을 보내고 "저장했습니다"를 보인다 (바꾸면 내린다)', async () => {
const { wrapper, requests } = await mountPage(server())
const editor = editorOf(wrapper.element)
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>ARA SYSTEM</p>')
await wrapper.get('#signature-for-reply').setValue(false)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequest(requests)?.data).toEqual({
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
body: '김아라 · 인프라팀\nARA SYSTEM',
isForNew: true,
isForReply: false,
})
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.')
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
await wrapper.get('#signature-for-new').setValue(false)
expect(wrapper.get('#signature-save-status').text()).toBe('')
})
it('비운 서명은 bodyHtml · body 모두 null 로 저장한다', async () => {
const { wrapper, requests } = await mountPage(server())
editorOf(wrapper.element).commands.clearContent(true)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequest(requests)?.data).toEqual({ bodyHtml: null, body: null, isForNew: true, isForReply: true })
})
it('서버가 서명을 거절하면(VAL_001) 알림 없이 서명 칸 아래에 사전 문구', async () => {
const { wrapper } = await mountPage(server({ save: () => fail(400, 'VAL_001') }))
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#signature-body').attributes('aria-describedby')).toBe('signature-hint signature-error')
expect(useToastStore().toasts).toEqual([])
})
describe('서명 그림', () => {
it('그림을 올려 보통(144) 폭으로 넣고 고른다 — 크기 줄: 작게 96 · 보통 144 · 원본(600 이하) · 그림 빼기', async () => {
const { wrapper, requests } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 800), 201)] }))
await choose(wrapper, [png()])
await flushPromises()
const upload = requests.find((request) => request.url === '/mail-signature/images')
expect(upload?.data).toBeInstanceOf(FormData)
const image = () => wrapper.get('#signature-body img[src]')
expect(image().attributes()).toMatchObject({ src: `/api/mail-signature/images/${NEW_IMAGE_ID}`, width: '144' })
expect(wrapper.get('[aria-labelledby="signature-image-size-label"]').attributes('role')).toBe('group')
const pressed = () => wrapper.get('[data-size][aria-pressed="true"]').text()
expect(pressed()).toBe('보통')
await wrapper.get('[data-size="small"]').trigger('click')
expect(image().attributes('width')).toBe('96')
expect(pressed()).toBe('작게')
await wrapper.get('[data-size="original"]').trigger('click')
expect(image().attributes('width')).toBe('600')
expect(pressed()).toBe('원본')
await wrapper.get('.signature-editor__remove').trigger('click')
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
expect(wrapper.find('[data-size]').exists()).toBe(false)
})
it('작은 그림은 늘리지 않는다 — 원래 폭으로 넣는다', async () => {
const { wrapper } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 80), 201)] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.get('#signature-body img[src]').attributes('width')).toBe('80')
})
it.each([
['PNG · JPG · GIF 가 아니면 FILE_001', new File(['x'], '로고.webp', { type: 'image/webp' }), 'FILE_001'],
['확장자와 형식이 다르면 FILE_001', new File(['x'], '로고.gif', { type: 'image/png' }), 'FILE_001'],
['300KB 를 넘으면 FILE_002', png('큰 로고.png', 300 * 1024 + 1), 'FILE_002'],
] as const)('올리기 전에 막는다 — %s (서명 칸 아래에 사전 문구)', async (_label, file, code) => {
const { wrapper, requests } = await mountPage(server())
await choose(wrapper, [file])
await flushPromises()
expect(requests.some((request) => request.url === '/mail-signature/images')).toBe(false)
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES[code])
})
it('3장까지 — 서명 칸에 있는 서로 다른 그림을 센다 (같은 그림 두 번은 한 장, 뺀 그림은 세지 않는다)', async () => {
const SECOND_ID = '00000000-0000-4000-8000-000000000002'
const imageTag = (id: string) => `<img src="/api/mail-signature/images/${id}" width="96" alt="">`
const withTwo: MailSignature = {
...SIGNATURE,
bodyHtml: `<p>${imageTag(IMAGE_ID)}${imageTag(IMAGE_ID)}${imageTag(SECOND_ID)}</p>`,
images: [imageOf(IMAGE_ID, 200), imageOf(SECOND_ID, 200)],
}
const { wrapper, requests } = await mountPage(
server({
signature: withTwo,
uploads: [
ok(imageOf('00000000-0000-4000-8000-000000000003', 200), 201),
ok(imageOf('00000000-0000-4000-8000-000000000004', 200), 201),
],
}),
)
const uploads = () => requests.filter((request) => request.url === '/mail-signature/images')
// 두 장이 있다 — 한 장만 더 올리고 넷째 장은 올리지 않고 막는다
await choose(wrapper, [png('a.png'), png('b.png')])
await flushPromises()
expect(uploads()).toHaveLength(1)
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
// 서명 칸에서 그림을 빼면 다시 넣을 수 있다 — 올려 두기만 한 그림은 세지 않는다 (그것은 서버의 10장 한도)
const editor = editorOf(wrapper.element)
let secondAt = -1
editor.state.doc.descendants((node, pos) => {
if (node.attrs.src === `/api/mail-signature/images/${SECOND_ID}`) secondAt = pos
})
editor.commands.setNodeSelection(secondAt)
await flushPromises()
await wrapper.get('.signature-editor__remove').trigger('click')
await choose(wrapper, [png('c.png')])
await flushPromises()
expect(uploads()).toHaveLength(2)
expect(wrapper.find('#signature-error').exists()).toBe(false)
expect(wrapper.findAll('#signature-body img[src]')).toHaveLength(4)
})
it('서버가 그림을 거절하면(FILE_001 등) 알림 없이 서명 칸 아래에 사전 문구', async () => {
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'FILE_001')] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
expect(useToastStore().toasts).toEqual([])
})
it('올려 둔 그림이 10장을 넘었다는 거절(VAL_001)은 인터셉터의 알림으로 — 서명 칸 아래 문구는 형식 · 크기 거절만', async () => {
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.find('#signature-error').exists()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
})
})
describe('나가기 확인 (디자인 README "2단계 › 서명")', () => {
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
function dialogOpen(wrapper: VueWrapper): boolean {
return wrapper.get('#signature-leave-dialog').attributes('open') !== undefined
}
/** 서명 끝에 한 줄을 더 쓴다 */
function typeLine(wrapper: VueWrapper, text: string) {
const editor = editorOf(wrapper.element)
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
return flushPromises()
}
it('바꾸고 떠나면 묻는다 — alertdialog · 처음 초점은 계속 고치기, [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
const stay = router.push('/mail/inbox')
await flushPromises()
const dialog = wrapper.get('#signature-leave-dialog')
expect(dialogOpen(wrapper)).toBe(true)
expect(dialog.attributes('role')).toBe('alertdialog')
expect(dialog.attributes('aria-labelledby')).toBe('signature-leave-title')
expect(dialog.attributes('aria-describedby')).toBe('signature-leave-message')
expect(wrapper.get('#signature-leave-title').text()).toBe('저장하지 않은 서명이 있습니다')
expect(wrapper.get('#signature-leave-message').text()).toBe('이 화면을 나가면 바꾼 서명이 사라집니다.')
expect(wrapper.get('#signature-leave-stay').text()).toBe('계속 고치기')
expect(wrapper.get('#signature-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('signature-leave-stay')
await wrapper.get('#signature-leave-stay').trigger('click')
await stay
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/signature')
expect(wrapper.get('#signature-body').text()).toContain('ARA SYSTEM')
const leave = router.push('/mail/inbox')
await flushPromises()
await wrapper.get('#signature-leave-confirm').trigger('click')
await leave
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('불러온(저장한 뒤에는 저장한) 서명과 같으면 묻지 않는다 — 바꿨다 되돌린 것 · 저장한 뒤, 창 닫기 확인도 그동안만', async () => {
const { wrapper, router } = await mountPage(server())
expect(blocksUnload()).toBe(false)
// 넣을 메일을 바꿨다 되돌리면 바꾼 것이 아니다
await wrapper.get('#signature-for-reply').setValue(false)
expect(blocksUnload()).toBe(true)
await wrapper.get('#signature-for-reply').setValue(true)
expect(blocksUnload()).toBe(false)
// 쓰고 저장하면 저장한 서명이 새 기준이다
await typeLine(wrapper, 'ARA SYSTEM')
await wrapper.get('#signature-for-new').setValue(false)
expect(blocksUnload()).toBe(true)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('저장하는 중에는 앱 안 이동을 묻지 않는다 — 창 닫기는 저장이 끝날 때까지 확인한다', async () => {
let finishSave: (() => void) | null = null
const { wrapper, router } = await mountPage(
server({
save: (request) =>
new Promise<MockResult>((resolve) => {
finishSave = () => resolve(ok({ ...(request.data as SaveSignaturePayload), images: [], updatedAt: SAVED_AT }))
}),
}),
)
await typeLine(wrapper, 'ARA SYSTEM')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(blocksUnload()).toBe(true)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
;(finishSave as (() => void) | null)?.()
await flushPromises()
expect(blocksUnload()).toBe(false)
expect(useMailSignatureStore().signature?.body).toBe('김아라 · 인프라팀\nARA SYSTEM')
})
it('세션이 끝나면 묻지 않는다 — 서명 칸을 내리고 창 닫기 확인도 뗀다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
useAuthStore().expireSession()
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/login')
await flushPromises()
expect(wrapper.find('#signature-leave-dialog').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/login')
})
})
it('미리 보기 — [답장]은 인용 위에 서명, 넣지 않기로 했으면 빼고 안내가 바뀐다', async () => {
const { wrapper } = await mountPage(server())
const replyMode = wrapper.get('[data-mode="reply"]')
await replyMode.trigger('click')
expect(replyMode.attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.')
await wrapper.get('#signature-for-reply').setValue(false)
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.')
})
})
@@ -0,0 +1,122 @@
<script setup lang="ts">
// 설정 › 메일 › 서명 (디자인 원본 Settings · README "2단계 › 서명") — /settings/mail/signature
// 들어올 때마다 서명을 새로 받는다(다른 창에서 고쳤을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
// 서명 칸(TipTap)은 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도" (ComposeEditorLoadError · MAIL_EDITOR_RETRY).
import { defineAsyncComponent, provide, ref } from 'vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
/** 서명 칸 — TipTap 을 이 화면에서만 내려받는다 */
const SignatureForm = defineAsyncComponent({
loader: () => import('@/components/mail/editor/signature/SignatureForm.vue'),
errorComponent: ComposeEditorLoadError,
})
const signatureStore = useMailSignatureStore()
/** 서명 칸을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
const formKey = ref<number>(0)
provide(MAIL_EDITOR_RETRY, () => {
formKey.value += 1
})
function reload() {
void signatureStore.load()
}
// 처음 그리기 전에 — 지난번에 받아 둔 서명으로 칸을 잠깐 그렸다 지우지 않게
reload()
</script>
<template>
<main
class="signature-page"
aria-labelledby="signature-page-title"
>
<div class="signature-page__column">
<div class="signature-page__intro">
<h1
id="signature-page-title"
class="signature-page__title"
>
{{ SIGNATURE_TEXT.title }}
</h1>
<p class="signature-page__description">
{{ SIGNATURE_TEXT.description }}
</p>
</div>
<MailLoadError
v-if="signatureStore.status === 'error' && signatureStore.error"
:error="signatureStore.error"
leave-label=""
@retry="reload"
/>
<SignatureForm
v-else-if="signatureStore.status === 'ready' && signatureStore.signature"
:key="formKey"
:signature="signatureStore.signature"
/>
<div
v-else
class="signature-page__placeholder"
aria-busy="true"
/>
</div>
</main>
</template>
<style scoped>
.signature-page {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
}
/* 콘텐츠 열 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (읽기 · 쓰기와 같은 값) */
.signature-page__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
.signature-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.signature-page__title {
margin: 0;
font-size: var(--font-size-title-lg);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.signature-page__description {
margin: 0;
color: var(--color-text-secondary);
}
/* 서명을 받는 동안의 자리 — 서명 칸(툴바 52 + 편집 132)만큼 */
.signature-page__placeholder {
min-height: calc(var(--size-editor-toolbar) + 8.25rem);
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
@media (min-width: 70rem) {
.signature-page {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,93 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
</script>
<template>
<AppShell module-id="settings">
<nav
class="settings-menu"
aria-label="설정 메뉴"
>
<p
id="settings-menu-mail"
class="settings-menu__group"
>
메일
</p>
<ul
class="settings-menu__list"
aria-labelledby="settings-menu-mail"
>
<li>
<RouterLink
:to="{ name: 'settings-mail-signature' }"
class="settings-menu__link"
active-class="is-active"
>
<AppIcon
name="edit"
:size="16"
/>
<span>서명</span>
</RouterLink>
</li>
</ul>
</nav>
<RouterView />
</AppShell>
</template>
<style scoped>
/* 왼쪽 패널 — 메일함 패널과 같은 바탕 · 간격 */
.settings-menu {
flex: 1 1 13rem;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-14) var(--space-10);
border-right: var(--border-width) solid var(--color-border);
background: var(--color-surface-panel);
}
.settings-menu__group {
margin: var(--space-4) var(--space-10);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.settings-menu__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.settings-menu__link {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.settings-menu__link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.settings-menu__link.is-active {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
</style>