|
@@ -186,20 +208,20 @@ onMounted(async () => {
/>
|
-
+ |
안 읽음, {{ whoOf(message) }}
|
- {{ message.subject || '(제목 없음)' }}
+ {{ message.subject || MAIL_NO_SUBJECT }}
{
font-variant-numeric: tabular-nums;
}
+/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
+.mail-table__who.is-placeholder,
+.mail-table__link.is-placeholder {
+ color: var(--color-text-muted);
+}
+
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.mail-table__row.is-unread .mail-table__who,
.mail-table__row.is-unread .mail-table__link {
diff --git a/frontend/src/components/mail/RecipientField.spec.ts b/frontend/src/components/mail/RecipientField.spec.ts
index 4f1abd3..b4f832a 100644
--- a/frontend/src/components/mail/RecipientField.spec.ts
+++ b/frontend/src/components/mail/RecipientField.spec.ts
@@ -1,14 +1,51 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
+import { shallowRef } from 'vue'
-import type { RecipientToken } from '@/shared/utils/mail-address'
+import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
+import type { RecipientSuggestion } from '@/shared/types/mail'
+import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
+import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
import RecipientField from './RecipientField.vue'
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
const models = new WeakMap()
-function mountField(initial: RecipientToken[] = []): VueWrapper {
+/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
+function suggestionsOf(pool: RecipientSuggestion[]) {
+ const items = shallowRef(pool)
+ const searches: string[] = []
+ const preloads: number[] = []
+ let current: () => RecipientToken[] = () => []
+ const handle: RecipientSuggestionsHandle = {
+ preload: () => {
+ preloads.push(preloads.length + 1)
+ },
+ matchesFor: (query) =>
+ filterRecipientSuggestions(
+ query,
+ items.value,
+ new Set(current().map((token) => normalizeEmail(token.address.email))),
+ ),
+ search: (query) => {
+ searches.push(query)
+ },
+ }
+ return {
+ handle,
+ items,
+ searches,
+ preloads,
+ bind: (tokens: () => RecipientToken[]) => {
+ current = tokens
+ },
+ }
+}
+
+function mountField(initial: RecipientToken[] = [], suggestions?: ReturnType): VueWrapper {
const wrapper: VueWrapper = mount(RecipientField, {
+ attachTo: suggestions ? document.body : undefined,
+ global: suggestions ? { provide: { [RECIPIENT_SUGGESTIONS as symbol]: suggestions.handle } } : {},
props: {
modelValue: initial,
label: '받는 사람',
@@ -21,6 +58,7 @@ function mountField(initial: RecipientToken[] = []): VueWrapper {
},
})
models.set(wrapper, initial)
+ suggestions?.bind(() => models.get(wrapper) ?? [])
return wrapper
}
@@ -109,4 +147,246 @@ describe('RecipientField', () => {
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
})
+
+ describe('자동 완성 (디자인 원본 ComposeAssist)', () => {
+ const POOL: RecipientSuggestion[] = [
+ { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' },
+ { name: '최도윤', email: 'choi@partner.example', source: 'recent' },
+ { name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' },
+ { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' },
+ ]
+
+ async function typed(wrapper: VueWrapper, text: string) {
+ const input = wrapper.get('input')
+ ;(input.element as HTMLInputElement).focus()
+ await input.trigger('focus')
+ await input.setValue(text)
+ return input
+ }
+
+ /** 추천 한 줄 — "이름 주소" */
+ function optionText(option: ReturnType): string {
+ return option
+ .findAll('.recipient-suggestions__name, .recipient-suggestions__email')
+ .map((part) => part.text())
+ .join(' ')
+ }
+
+ function options(wrapper: VueWrapper): string[] {
+ return wrapper.findAll('[role="option"]').map(optionText)
+ }
+
+ function activeOption(wrapper: VueWrapper): string | null {
+ const active = wrapper.find('[role="option"][aria-selected="true"]')
+ return active.exists() ? optionText(active) : null
+ }
+
+ it('한 글자 이상 치면 칸 아래에 사내 · 최근 주고받은 주소 묶음으로 보이고, 맞는 글자는 굵게 · 첫 추천을 골라 둔다', async () => {
+ const suggestions = suggestionsOf(POOL)
+ const wrapper = mountField([], suggestions)
+ const input = await typed(wrapper, 'ㄷㅇ')
+
+ expect(input.attributes('role')).toBe('combobox')
+ expect(input.attributes('aria-expanded')).toBe('true')
+ expect(input.attributes('aria-controls')).toBe('compose-to-suggestions')
+ expect(wrapper.get('#compose-to-suggestions').attributes('role')).toBe('listbox')
+ expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
+ '사내',
+ '최근 주고받은 주소',
+ ])
+ expect(options(wrapper)).toEqual([
+ '이도윤 doyun.lee@arasystem.it.kr',
+ '김도윤 doyun.kim@arasystem.it.kr',
+ '최도윤 choi@partner.example',
+ ])
+ expect(wrapper.findAll('#compose-to-option-0 strong').map((part) => part.text())).toEqual(['도윤'])
+ expect(input.attributes('aria-activedescendant')).toBe('compose-to-option-0')
+ expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
+ expect(input.attributes('aria-describedby')).toBe('compose-to-suggestions-hint')
+ expect(suggestions.searches).toEqual(['ㄷㅇ'])
+ wrapper.unmount()
+ })
+
+ it('↑↓ 로 고르고 Enter 로 넣는다 — 폼을 제출하지 않고, 이름 <주소> 토큰이 되고 목록은 닫힌다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '도윤')
+
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ expect(activeOption(wrapper)).toBe('김도윤 doyun.kim@arasystem.it.kr')
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ expect(activeOption(wrapper)).toBe('최도윤 choi@partner.example')
+
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
+ input.element.dispatchEvent(enter)
+ await wrapper.vm.$nextTick()
+
+ expect(enter.defaultPrevented).toBe(true)
+ expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최도윤 '])
+ expect((input.element as HTMLInputElement).value).toBe('')
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('Tab 으로도 넣고 칸에 남아 이어 친다 — 이미 넣은 주소는 다음 추천에서 빠진다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '도윤')
+
+ const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })
+ input.element.dispatchEvent(tab)
+ await wrapper.vm.$nextTick()
+ expect(tab.defaultPrevented).toBe(true)
+ expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
+
+ await input.setValue('도윤')
+ expect(options(wrapper)).not.toContain('이도윤 doyun.lee@arasystem.it.kr')
+ wrapper.unmount()
+ })
+
+ it('Esc 는 목록만 닫고, 다시 치면 연다 — 누르면(마우스) 그 추천을 넣는다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '준호')
+
+ await input.trigger('keydown', { key: 'Escape' })
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
+ expect(input.attributes('aria-expanded')).toBe('false')
+
+ await input.setValue('준')
+ const option = wrapper.get('[role="option"]')
+ const down = new MouseEvent('mousedown', { cancelable: true, bubbles: true })
+ option.element.dispatchEvent(down)
+ await option.trigger('click')
+
+ expect(down.defaultPrevented).toBe(true)
+ expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['박준호 '])
+ wrapper.unmount()
+ })
+
+ it('완전한 주소를 쳤으면 미리 고르지 않는다 — Enter 는 친 주소를 그대로, 화살표로 고른 뒤에만 추천이 들어간다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, 'doyun.lee@partner.example')
+
+ expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
+ expect(activeOption(wrapper)).toBeNull()
+ expect(input.attributes('aria-activedescendant')).toBeUndefined()
+ expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
+
+ await input.trigger('keydown', { key: 'Enter' })
+ expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
+
+ await input.setValue('doyun.lee@partner.example')
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
+ await input.trigger('keydown', { key: 'Enter' })
+ expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
+ 'doyun.lee@partner.example',
+ 'doyun.lee@arasystem.it.kr',
+ ])
+ wrapper.unmount()
+ })
+
+ it('한글 조합 중의 Enter 는 추천도 넣지 않는다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '도윤')
+
+ await input.trigger('keydown', { key: 'Enter', isComposing: true })
+
+ expect(tokensOf(wrapper)).toEqual([])
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
+ wrapper.unmount()
+ })
+
+ it('Safari · macOS 처럼 조합을 끝내는 Enter 가 isComposing 없이 keyCode 229 로 와도 넣지 않는다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '도윤')
+
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
+ input.element.dispatchEvent(enter)
+ await wrapper.vm.$nextTick()
+
+ expect(enter.defaultPrevented).toBe(false)
+ expect(tokensOf(wrapper)).toEqual([])
+ wrapper.unmount()
+ })
+
+ it('칸에 초점이 오면 미리 받기를 부른다 — 실패했던 미리 받기를 다시 해 본다 (받았으면 추천 쪽이 아무것도 하지 않는다)', async () => {
+ const suggestions = suggestionsOf(POOL)
+ const wrapper = mountField([], suggestions)
+ expect(suggestions.preloads).toHaveLength(0)
+ await wrapper.get('input').trigger('focus')
+ expect(suggestions.preloads).toHaveLength(1)
+ wrapper.unmount()
+ })
+
+ it('Shift+Tab 은 추천을 넣지 않는다 — 원래대로 앞 칸으로 (막지 않는다)', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, '도윤')
+
+ const back = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })
+ input.element.dispatchEvent(back)
+ await wrapper.vm.$nextTick()
+
+ expect(back.defaultPrevented).toBe(false)
+ expect(tokensOf(wrapper)).toEqual([])
+ wrapper.unmount()
+ })
+
+ it('한글 조합 중인 글자로도 찾는다 — "김"(조합 중) · "ㄷㅇ"(마지막 자모가 조합 중). 토큰 확정은 조합이 끝난 글자로', async () => {
+ const suggestions = suggestionsOf([
+ ...POOL,
+ { name: '김아라', email: 'ara.kim@arasystem.it.kr', source: 'directory' },
+ ])
+ const wrapper = mountField([], suggestions)
+ const input = wrapper.get('input')
+ const element = input.element as HTMLInputElement
+ element.focus()
+ await input.trigger('focus')
+
+ // "김" 을 조합하는 중 — v-model 은 아직 비어 있지만 추천은 보인다
+ await input.trigger('compositionstart')
+ element.value = '김'
+ await input.trigger('compositionupdate')
+ await input.trigger('input')
+ expect(options(wrapper)).toEqual(['김도윤 doyun.kim@arasystem.it.kr', '김아라 ara.kim@arasystem.it.kr'])
+ expect(suggestions.searches.at(-1)).toBe('김')
+ await input.trigger('compositionend')
+
+ // "ㄷ" 을 확정하고 "ㅇ" 을 조합하는 중 — 초성 "ㄷㅇ" 으로 찾는다
+ element.value = ''
+ await input.trigger('input')
+ await input.trigger('compositionstart')
+ element.value = 'ㄷ'
+ await input.trigger('input')
+ await input.trigger('compositionend')
+ await input.trigger('compositionstart')
+ element.value = 'ㄷㅇ'
+ await input.trigger('input')
+ expect(options(wrapper)).toEqual([
+ '이도윤 doyun.lee@arasystem.it.kr',
+ '김도윤 doyun.kim@arasystem.it.kr',
+ '최도윤 choi@partner.example',
+ ])
+
+ // 조합 중의 Enter 는 넣지 않는다 — 조합이 끝난 뒤의 Enter 가 고른 추천을 넣는다
+ await input.trigger('keydown', { key: 'Enter', isComposing: true })
+ expect(tokensOf(wrapper)).toEqual([])
+ await input.trigger('compositionend')
+ await input.trigger('keydown', { key: 'Enter' })
+ expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
+ expect(element.value).toBe('')
+ wrapper.unmount()
+ })
+
+ it('칸을 벗어나면 목록을 닫고 친 글자를 확정한다 — 맞는 추천이 없으면 목록을 열지 않는다', async () => {
+ const wrapper = mountField([], suggestionsOf(POOL))
+ const input = await typed(wrapper, 'zzz')
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
+
+ await input.setValue('준호')
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
+ await input.trigger('blur')
+ expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
+ wrapper.unmount()
+ })
+ })
})
diff --git a/frontend/src/components/mail/RecipientField.vue b/frontend/src/components/mail/RecipientField.vue
index 6b4ef7a..119f4d6 100644
--- a/frontend/src/components/mail/RecipientField.vue
+++ b/frontend/src/components/mail/RecipientField.vue
@@ -1,18 +1,39 @@
@@ -141,19 +276,39 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
+ :role="suggestions ? 'combobox' : undefined"
+ :aria-autocomplete="suggestions ? 'list' : undefined"
+ :aria-expanded="suggestions ? listOpen : undefined"
+ :aria-controls="listOpen ? listId : undefined"
+ :aria-activedescendant="activeOptionId"
:aria-invalid="hasInvalid"
- :aria-describedby="hasInvalid && errorId ? errorId : undefined"
+ :aria-describedby="describedBy"
+ @input="onTextInput"
+ @compositionupdate="onTextInput"
@keydown="onKeydown"
- @blur="commit()"
+ @focus="onFocus"
+ @blur="onBlur"
@paste="onPaste"
>
+
+
diff --git a/frontend/src/components/mail/editor/EditorLinkForm.vue b/frontend/src/components/mail/editor/EditorLinkForm.vue
index eb9b316..9873588 100644
--- a/frontend/src/components/mail/editor/EditorLinkForm.vue
+++ b/frontend/src/components/mail/editor/EditorLinkForm.vue
@@ -9,6 +9,7 @@ import AppIcon from '@/components/AppIcon.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { getErrorMessage } from '@/composables/useApi'
+import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { normalizeLinkHref } from '@/shared/utils/link-href'
interface Props {
@@ -58,8 +59,8 @@ function submit() {
}
function onEnter(event: KeyboardEvent) {
- // 한글 조합 중의 Enter 는 글자를 확정할 뿐이다
- if (event.isComposing) return
+ // 한글 조합 중의 Enter 는 글자를 확정할 뿐이다 (Safari · macOS 의 keyCode 229 포함)
+ if (isImeComposing(event)) return
event.preventDefault()
submit()
}
diff --git a/frontend/src/components/mail/editor/MailEditor.spec.ts b/frontend/src/components/mail/editor/MailEditor.spec.ts
index 84b0215..8aee544 100644
--- a/frontend/src/components/mail/editor/MailEditor.spec.ts
+++ b/frontend/src/components/mail/editor/MailEditor.spec.ts
@@ -160,6 +160,22 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.vm.getContentForSend()).toBeNull()
expect(editor.state.doc).toBe(before)
})
+
+ it('임시 저장도 같은 한도로 잰다 — 넘으면 인용을 줄인 모양을 싣되 편집기는 그대로, 내 글만으로 넘으면 null', () => {
+ const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
+ const editor = editorOf(view.element)
+ const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
+ editor.commands.insertContentAt(1, mine)
+ const before = editor.state.doc
+
+ const snapshot = view.vm.captureForSave()
+ expect(snapshot?.content.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
+ expect(snapshot?.content.text.startsWith(mine.trimEnd())).toBe(true)
+ expect(editor.state.doc).toBe(before)
+
+ editor.commands.setContent(`${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)} `)
+ expect(view.vm.captureForSave()).toBeNull()
+ }, LONG_DRAFT_TIMEOUT_MS)
})
it('본문 오류 안내(describedBy)를 주면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다', async () => {
@@ -313,4 +329,54 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
})
+
+ describe('임시 저장 · 서명 그림 (2단계)', () => {
+ const SIGNATURE_IMAGE = ' '
+
+ it('저장한 뒤에는 저장한 본문과 견준다 — 저장하는 동안 더 쓴 글은 바꿈으로 남는다', () => {
+ const view = mountEditor()
+ const editor = editorOf(view.element)
+ editor.commands.insertContent('첫 줄')
+ const snapshot = view.vm.captureForSave()
+ if (!snapshot) throw new Error('저장할 본문이 없다')
+ expect(snapshot.content.text).toBe('첫 줄')
+
+ // 저장하는 동안 더 썼다
+ editor.commands.insertContent(' 더')
+ view.vm.markSaved(snapshot.savePoint)
+ expect(view.vm.isChanged()).toBe(true)
+
+ // 더 쓴 글을 지워 저장한 모양으로 돌아오면 바꾸지 않은 것이다
+ const end = editor.state.doc.content.size - 1
+ editor.commands.deleteRange({ from: end - ' 더'.length, to: end })
+ expect(editor.getText()).toBe('첫 줄')
+ expect(view.vm.isChanged()).toBe(false)
+ })
+
+ it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
+ const view = mountEditor({ initialHtml: `${SIGNATURE_IMAGE} ` })
+
+ const { text, html } = view.vm.getContent()
+
+ expect(text).toBe('')
+ expect(html).toContain(SIGNATURE_IMAGE)
+ })
+
+ it('붙여 넣은 HTML 의 다른 그림은 빠진다 — 서명 그림 주소만 받는다', () => {
+ const view = mountEditor()
+ const dom = view.get('.ProseMirror').element
+
+ dom.dispatchEvent(
+ pasteEvent({
+ text: '글',
+ html: `글 ${SIGNATURE_IMAGE} `,
+ }),
+ )
+
+ const { html } = view.vm.getContent()
+ expect(html).toContain(SIGNATURE_IMAGE)
+ expect(html).not.toContain('tracker.example')
+ expect(html).not.toContain('data:image')
+ })
+ })
})
diff --git a/frontend/src/components/mail/editor/MailEditor.vue b/frontend/src/components/mail/editor/MailEditor.vue
index c809c3b..bd00c4a 100644
--- a/frontend/src/components/mail/editor/MailEditor.vue
+++ b/frontend/src/components/mail/editor/MailEditor.vue
@@ -1,8 +1,11 @@
@@ -390,6 +422,18 @@ defineExpose({ getContent, getContentForSend, focus, isChanged })
color: var(--color-brand);
}
+/* 서명 그림 — 받는 쪽과 같은 폭(width 속성)으로, 칸보다 넓으면 줄여 보인다. 고르면 브랜드색 고리 (디자인 원본 Settings) */
+.mail-editor__body :deep(.ProseMirror img) {
+ max-width: 100%;
+ height: auto;
+ vertical-align: bottom;
+}
+
+.mail-editor__body :deep(.ProseMirror img.ProseMirror-selectednode) {
+ outline: var(--focus-ring-width) solid var(--color-brand);
+ outline-offset: var(--focus-ring-offset);
+}
+
/* 빈 본문 안내 — 커서는 문구 앞에 선다 */
.mail-editor__body :deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
diff --git a/frontend/src/components/mail/editor/mail-editor-handle.ts b/frontend/src/components/mail/editor/mail-editor-handle.ts
index afd8d52..1740a8a 100644
--- a/frontend/src/components/mail/editor/mail-editor-handle.ts
+++ b/frontend/src/components/mail/editor/mail-editor-handle.ts
@@ -9,6 +9,20 @@ export interface MailEditorContent {
html: string | null
}
+/**
+ * 저장한 순간의 본문 — 편집기 밖에서는 열어 보지 않는 값이다 (TipTap 문서를 쓰기 화면이 모르게 감싼다).
+ * 임시 저장이 끝나면 markSaved 로 돌려줘 "바꾸지 않은" 기준으로 삼는다.
+ */
+export interface EditorSavePoint {
+ readonly __editorSavePoint: true
+}
+
+/** 임시 저장할 본문 — 보낼 모양(한도에 맞추지 않은 지금 본문)과 그 순간의 기준점 */
+export interface MailEditorSnapshot {
+ content: MailEditorContent
+ savePoint: EditorSavePoint
+}
+
export interface MailEditorHandle {
/** 지금 본문 — 보낼 모양 그대로 */
getContent: () => MailEditorContent
@@ -21,9 +35,18 @@ export interface MailEditorHandle {
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
- * 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
+ * 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false.
+ * 임시 저장한 뒤에는 저장한 본문(markSaved)과 견준다
*/
isChanged: () => boolean
+ /**
+ * 임시 저장할 본문 — 저장이 끝나면 그 기준점을 markSaved 로 돌려준다 (저장하는 동안 바뀐 글은 "바꿈"으로 남는다).
+ * 보내기와 같은 한도로 잰다: 넘으면 원문 인용을 줄인 모양을 싣는다(편집기는 그대로 — 보낼 때 맞춘다).
+ * 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 저장하지 않는다 (서버도 MAIL_007 로 거절한다)
+ */
+ captureForSave: () => MailEditorSnapshot | null
+ /** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
+ markSaved: (savePoint: EditorSavePoint) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
diff --git a/frontend/src/components/mail/editor/mail-extensions.ts b/frontend/src/components/mail/editor/mail-extensions.ts
index 0083bb8..a4c4a5b 100644
--- a/frontend/src/components/mail/editor/mail-extensions.ts
+++ b/frontend/src/components/mail/editor/mail-extensions.ts
@@ -1,7 +1,8 @@
// 서식 편집기(TipTap) 확장 구성 — 디자인 README "본문 서식 편집기 (TipTap)" · 계약 2절 "서식 본문"
// 편집기가 만드는 것 = 서버 정화 허용 목록(sanitize-outgoing-html.ts)에 있는 것만 둔다:
-// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크.
-// 제목 스타일·코드·이미지·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
+// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
+// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
+// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -22,6 +23,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
+import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
EDITOR_MAX_INDENT,
@@ -228,6 +230,11 @@ const LINK_OPTIONS = {
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
}
+/** 링크 확장 — 쓰기 본문과 서명 칸이 같은 규칙을 쓴다 (주소만 읽고, 늘 새 창으로) */
+export function createMailLinkExtension(): AnyExtension {
+ return mailLink(starterKitPart(StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS }), 'link'))
+}
+
/** 편집기 확장 목록 — 화면의 편집기와 초안 맞춤(fit-draft)·테스트가 같은 스키마를 쓴다 */
export function createMailEditorExtensions(hooks: Partial = {}): AnyExtension[] {
const kitWithLink = StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS })
@@ -245,6 +252,7 @@ export function createMailEditorExtensions(hooks: Partial = {})
TableKit.configure({ table: { resizable: false }, tableCell: false, tableHeader: false }),
MailTableCell,
MailTableHeader,
+ SignatureImage,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
diff --git a/frontend/src/components/mail/editor/mail-serialize.ts b/frontend/src/components/mail/editor/mail-serialize.ts
index 2924f8a..80424ed 100644
--- a/frontend/src/components/mail/editor/mail-serialize.ts
+++ b/frontend/src/components/mail/editor/mail-serialize.ts
@@ -158,8 +158,14 @@ function styleAll(root: ParentNode, selector: string, style: string) {
root.querySelectorAll(selector).forEach((element) => prependStyle(element, style))
}
+/** 글자도 줄바꿈도 그림(서명 그림)도 없는 문단 — 받는 쪽에서 높이가 0 이 되기 쉽다 */
function isEmptyParagraph(element: Element): boolean {
- return element.tagName === 'P' && element.textContent === '' && element.querySelector('br') === null
+ return (
+ element.tagName === 'P' &&
+ element.textContent === '' &&
+ element.querySelector('br') === null &&
+ element.querySelector('img') === null
+ )
}
/**
diff --git a/frontend/src/components/mail/editor/signature-image.spec.ts b/frontend/src/components/mail/editor/signature-image.spec.ts
new file mode 100644
index 0000000..f53590e
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature-image.spec.ts
@@ -0,0 +1,84 @@
+import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
+import { describe, expect, it } from 'vitest'
+
+import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
+import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
+import {
+ hasSignatureImage,
+ signatureImageIdsOf,
+ signatureImagesOf,
+} from '@/components/mail/editor/signature-image'
+import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
+import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID } from '@/test-utils/outgoing-style-samples'
+
+const schema = getSchema(createMailEditorExtensions())
+const signatureSchema = getSchema(createSignatureEditorExtensions())
+const SRC = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
+
+function parse(html: string, target = schema): ProseMirrorNode {
+ return createDocument(normalizeIncomingHtml(html), target)
+}
+
+function editorHtml(doc: ProseMirrorNode): string {
+ return getHTMLFromFragment(doc.content, doc.type.schema)
+}
+
+describe('서명 그림 노드 — 서명 그림만 받는다 (계약 2절 "서식 본문"의 그림 예외)', () => {
+ it('서명 그림 주소의 img 만 남기고, 주소 · 폭 · 설명만 그린다', () => {
+ const doc = parse(`서명 `)
+
+ expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 144, alt: '로고' }])
+ expect(editorHtml(doc)).toBe(`서명 `)
+ })
+
+ it.each([
+ 'https://evil.example/t.png',
+ 'data:image/png;base64,iVBORw0KGgo=',
+ `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`,
+ `cid:${SAMPLE_IMAGE_ID}`,
+ '/api/mail-signature/images/not-a-uuid',
+ ])('그 밖의 그림은 빠진다 — %s', (src) => {
+ const doc = parse(`앞 뒤 `)
+ expect(hasSignatureImage(doc)).toBe(false)
+ expect(editorHtml(doc)).toBe('앞뒤 ')
+ })
+
+ it('표본 표의 폭 · 설명 — 서버가 남기지 않는 값은 그 속성만 뺀다', () => {
+ for (const [attribute, value, kept] of IMAGE_SAMPLES) {
+ if (attribute === 'src') continue
+ const doc = parse(` `)
+ const [image] = signatureImagesOf(doc)
+ const read = attribute === 'width' ? image?.width : image?.alt
+ expect(read !== null && read !== undefined, `${attribute}=${value}`).toBe(kept)
+ }
+ })
+
+ it('주소는 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로 읽는다', () => {
+ const doc = parse(`} ) `)
+ expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 96, alt: null }])
+ expect(signatureImageIdsOf(doc)).toEqual(new Set([SAMPLE_IMAGE_ID]))
+ })
+
+ it('서명 칸 스키마도 같은 규칙이다 — 목록 · 표 · 글꼴은 글자만 남는다', () => {
+ const doc = parse(
+ `빨강 `,
+ signatureSchema,
+ )
+ const html = editorHtml(doc)
+ expect(html).toMatch(/^항목<\/p> 빨강<\/span> <\/p>$/)
+ expect(html).not.toContain('font-family')
+ })
+})
+
+describe('보낼 모양 — 그림은 서식 본문에만', () => {
+ it('텍스트 대체 본문에는 그림이 없다', () => {
+ expect(docToText(parse(`김아라 
끝 `))).toBe('김아라\n\n끝')
+ })
+
+ it('그림만 있는 문단에 줄바꿈을 더하지 않는다 — 빈 문단만 높이를 지킨다', () => {
+ const html = toMailHtml(editorHtml(parse(`
끝 `)))
+ expect(html).toContain(` `)
+ expect(html).toContain('
')
+ })
+})
diff --git a/frontend/src/components/mail/editor/signature-image.ts b/frontend/src/components/mail/editor/signature-image.ts
new file mode 100644
index 0000000..033d076
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature-image.ts
@@ -0,0 +1,104 @@
+// 서명 그림 — 쓰기 본문 · 서명 칸이 받는 유일한 그림 (계약 2절 "서식 본문"의 그림 예외 · 디자인 README "2단계 › 서명")
+// 만 읽고 그린다 — 주소 · 폭 · 설명은 서버 정화와 같은 형식만 받는다
+// (shared/utils/outgoing-html-rules.ts 의 그림 규칙). 그 밖의 그림(외부 주소 · data: · 다른 경로)은 스키마가 받지 않아 빠진다.
+// 그림 파일을 본문에 붙여 넣거나 끌어다 놓으면 여전히 첨부로 올린다 (MailEditor 의 attachPasted · attachDropped).
+// 글 안의 한 글자처럼 다룬다(inline atom) — 지우기 · 옮기기(끌기) · 고르기가 글자와 같다. 고르면 서명 칸이 크기 줄을 보인다.
+import { Node } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
+
+import {
+ imageAltOf,
+ imageWidthOf,
+ normalizeSignatureImageSrc,
+ signatureImageIdOf,
+} from '@/shared/utils/outgoing-html-rules'
+
+export const SIGNATURE_IMAGE_NODE = 'signatureImage'
+
+/** 그림 노드의 속성 — 형식이 틀린 폭 · 설명은 null(그 속성을 그리지 않는다) */
+export interface SignatureImageAttributes {
+ src: string
+ width: number | null
+ alt: string | null
+}
+
+export const SignatureImage = Node.create({
+ name: SIGNATURE_IMAGE_NODE,
+ group: 'inline',
+ inline: true,
+ atom: true,
+ selectable: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ // 서버가 다시 쓰는 모양으로 — 앞뒤 공백 없이, id 는 소문자
+ parseHTML: (element: HTMLElement) => normalizeSignatureImageSrc(element.getAttribute('src')),
+ renderHTML: (attributes: Record) => {
+ const src = normalizeSignatureImageSrc(attributes.src)
+ return src === null ? {} : { src }
+ },
+ },
+ width: {
+ default: null,
+ parseHTML: (element: HTMLElement) => imageWidthOf(element.getAttribute('width')),
+ renderHTML: (attributes: Record) => {
+ const width = imageWidthOf(attributes.width)
+ return width === null ? {} : { width: String(width) }
+ },
+ },
+ alt: {
+ default: null,
+ parseHTML: (element: HTMLElement) => imageAltOf(element.getAttribute('alt')),
+ renderHTML: (attributes: Record) => {
+ const alt = imageAltOf(attributes.alt)
+ return alt === null ? {} : { alt }
+ },
+ },
+ }
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'img[src]',
+ // 서명 그림 주소가 아닌 그림은 읽지 않는다 — 노드 없이 빠진다
+ getAttrs: (element: HTMLElement) =>
+ normalizeSignatureImageSrc(element.getAttribute('src')) === null ? false : null,
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['img', HTMLAttributes]
+ },
+})
+
+/** 문서 안의 서명 그림 — 나온 순서대로 */
+export function signatureImagesOf(doc: ProseMirrorNode): SignatureImageAttributes[] {
+ const images: SignatureImageAttributes[] = []
+ doc.descendants((node) => {
+ if (node.type.name !== SIGNATURE_IMAGE_NODE) return true
+ const src = normalizeSignatureImageSrc(node.attrs.src)
+ if (src !== null) images.push({ src, width: imageWidthOf(node.attrs.width), alt: imageAltOf(node.attrs.alt) })
+ return false
+ })
+ return images
+}
+
+/** 문서에 서명 그림이 있는가 — 글자 없이 그림만 있어도 보낼 내용이 있다 */
+export function hasSignatureImage(doc: ProseMirrorNode): boolean {
+ return signatureImagesOf(doc).length > 0
+}
+
+/** 문서가 쓰는 서명 그림 id — 겹치지 않게 */
+export function signatureImageIdsOf(doc: ProseMirrorNode): Set {
+ const ids = new Set()
+ for (const image of signatureImagesOf(doc)) {
+ const id = signatureImageIdOf(image.src)
+ if (id !== null) ids.add(id)
+ }
+ return ids
+}
diff --git a/frontend/src/components/mail/editor/signature/SignatureEditor.vue b/frontend/src/components/mail/editor/signature/SignatureEditor.vue
new file mode 100644
index 0000000..f127045
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureEditor.vue
@@ -0,0 +1,398 @@
+
+
+
+
+
+
+
+ {{ SIGNATURE_TEXT.imageSizeLabel }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureForm.vue b/frontend/src/components/mail/editor/signature/SignatureForm.vue
new file mode 100644
index 0000000..09363b6
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureForm.vue
@@ -0,0 +1,465 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignaturePreview.vue b/frontend/src/components/mail/editor/signature/SignaturePreview.vue
new file mode 100644
index 0000000..7f75120
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignaturePreview.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+ {{ SIGNATURE_TEXT.previewTitle }}
+
+
+
+
+
+
+
+
+ {{ SIGNATURE_TEXT.previewBody }}
+
+
+
+
+
+
+ {{ MAIL_QUOTE_HEADING }}
+
+
+
+
+ |
+ {{ row.label }}
+ |
+ {{ row.value }} |
+
+
+
+
+ {{ SIGNATURE_PREVIEW_QUOTE.body }}
+
+
+
+ {{ note }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/SignatureToolbar.vue b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue
new file mode 100644
index 0000000..50c82a7
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/SignatureToolbar.vue
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/editor/signature/signature-extensions.ts b/frontend/src/components/mail/editor/signature/signature-extensions.ts
new file mode 100644
index 0000000..35177c1
--- /dev/null
+++ b/frontend/src/components/mail/editor/signature/signature-extensions.ts
@@ -0,0 +1,68 @@
+// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings)
+// 쓸 수 있는 것: 문단 · 줄바꿈, 굵게 · 기울임 · 밑줄, 글자 색, 링크, 서명 그림. 그 밖(목록 · 표 · 글꼴 · 글자 크기 등)은
+// 스키마에 없어 붙여 넣어도 글자만 남는다. 값 형식은 쓰기 본문과 같다 (서버 정화 허용 목록 — mail-format-extensions.ts).
+// 서명은 쓰기 화면을 열 때 본문에 그대로 들어가므로, 이 스키마는 쓰기 본문 스키마(mail-extensions.ts)의 일부여야 한다.
+import { Extension, type AnyExtension } from '@tiptap/core'
+import { TextStyle } from '@tiptap/extension-text-style'
+import { StarterKit } from '@tiptap/starter-kit'
+
+import { createMailLinkExtension } from '@/components/mail/editor/mail-extensions'
+import { MailColor } from '@/components/mail/editor/mail-format-extensions'
+import { SignatureImage } from '@/components/mail/editor/signature-image'
+
+export interface SignatureEditorHooks {
+ /** Ctrl+K — 링크 넣기 · 고치기 */
+ openLink: (() => void) | null
+ /** Alt+F10 — 툴바로 간다 */
+ focusToolbar: (() => void) | null
+}
+
+const SignatureBehavior = Extension.create({
+ name: 'signatureEditor',
+
+ addOptions() {
+ return { openLink: null, focusToolbar: null }
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-k': () => {
+ this.options.openLink?.()
+ return true
+ },
+ 'Alt-F10': () => {
+ this.options.focusToolbar?.()
+ return true
+ },
+ }
+ },
+})
+
+/** StarterKit 에서 쓰지 않는 것 — 제목 · 코드 · 목록 · 인용 · 가로줄 · 취소선 (링크는 따로 넣는다) */
+const STARTER_KIT_OPTIONS = {
+ heading: false,
+ code: false,
+ codeBlock: false,
+ blockquote: false,
+ bulletList: false,
+ orderedList: false,
+ listItem: false,
+ listKeymap: false,
+ horizontalRule: false,
+ strike: false,
+ link: false,
+} as const
+
+export function createSignatureEditorExtensions(hooks: Partial = {}): AnyExtension[] {
+ return [
+ StarterKit.configure(STARTER_KIT_OPTIONS),
+ createMailLinkExtension(),
+ TextStyle,
+ MailColor,
+ SignatureImage,
+ SignatureBehavior.configure({
+ openLink: hooks.openLink ?? null,
+ focusToolbar: hooks.focusToolbar ?? null,
+ }),
+ ]
+}
diff --git a/frontend/src/components/mail/recipient-suggestion-ids.ts b/frontend/src/components/mail/recipient-suggestion-ids.ts
new file mode 100644
index 0000000..446472b
--- /dev/null
+++ b/frontend/src/components/mail/recipient-suggestion-ids.ts
@@ -0,0 +1,14 @@
+// 받는 사람 추천 목록의 고정 id — 입력칸(RecipientField)과 목록(RecipientSuggestionList)이 함께 쓴다 (화면 검증 · 테스트도 쓴다)
+// 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint
+
+export function suggestionListId(inputId: string): string {
+ return `${inputId}-suggestions`
+}
+
+export function suggestionHintId(inputId: string): string {
+ return `${inputId}-suggestions-hint`
+}
+
+export function suggestionOptionId(inputId: string, index: number): string {
+ return `${inputId}-option-${index}`
+}
diff --git a/frontend/src/components/shell/AppRail.spec.ts b/frontend/src/components/shell/AppRail.spec.ts
index 216d9d6..a475127 100644
--- a/frontend/src/components/shell/AppRail.spec.ts
+++ b/frontend/src/components/shell/AppRail.spec.ts
@@ -20,19 +20,25 @@ describe('AppRail', () => {
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
})
- it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
+ it('메일 · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
const wrapper = await mountRail()
const current = wrapper.get('a[aria-current="page"]')
+ const links = wrapper.findAll('a.rail-item').map((link) => [link.text(), link.attributes('href')])
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
+ // 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
+ expect(links).toEqual([
+ ['메일', '/mail'],
+ ['설정', '/settings'],
+ ])
})
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
const wrapper = await mountRail()
const disabled = wrapper.findAll('button[aria-disabled="true"]')
- expect(disabled).toHaveLength(APP_MODULES.length - 1)
+ expect(disabled).toHaveLength(APP_MODULES.length - 2)
for (const item of disabled) {
expect(item.attributes('title')).toContain('준비 중')
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
diff --git a/frontend/src/composables/useApi.spec.ts b/frontend/src/composables/useApi.spec.ts
index 361c6f6..b22fe80 100644
--- a/frontend/src/composables/useApi.spec.ts
+++ b/frontend/src/composables/useApi.spec.ts
@@ -60,6 +60,10 @@ describe('에러 코드 사전', () => {
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
+ // 웹메일 2단계
+ 'MAIL_008', // 다른 곳에서 바뀐 임시 저장본 — 쓰기 화면이 새 저장본으로 남기고 정보 상태 글을 보인다
+ 'MAIL_001', // 메일 서버 장애 — 임시 저장이 조금 뒤 저절로 다시 해 본다
+ 'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
diff --git a/frontend/src/composables/useAttachmentUploads.spec.ts b/frontend/src/composables/useAttachmentUploads.spec.ts
index d1df0c1..3f18d0a 100644
--- a/frontend/src/composables/useAttachmentUploads.spec.ts
+++ b/frontend/src/composables/useAttachmentUploads.spec.ts
@@ -411,4 +411,58 @@ describe('useAttachmentUploads', () => {
stop()
})
})
+
+ describe('임시 저장 뒤 첨부 id 바꾸기 (계약 2절 "임시 저장" — 저장할 때마다 첨부 id 가 바뀐다)', () => {
+ const SAVED = (id: string, name: string) => ({ id, name, type: 'application/pdf', size: 1 })
+
+ it('저장 본문에 실은 순서대로 응답의 id 로 바꾼다 — 화면의 이름 · 키는 그대로', async () => {
+ installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
+ const { uploads, stop } = withUploads()
+ uploads.addFiles([file('회의록.pdf')])
+ await flushPromises()
+ const [forward] = uploads.addExisting([SAVED('orig-1', '체크리스트.xlsx')])
+ const keys = uploads.items.value.map((item) => item.key)
+
+ uploads.applySavedAttachments(keys, [SAVED('up1-v1', '회의록.pdf'), SAVED('orig-1-v1', '체크리스트.xlsx')])
+
+ expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['up1-v1', 'orig-1-v1'])
+ expect(uploads.items.value.map((item) => item.key)).toEqual(keys)
+ expect(uploads.items.value[1]?.key).toBe(forward?.key)
+ stop()
+ })
+
+ it('이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이고 응답 순서가 달라도', async () => {
+ installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
+ const { uploads, stop } = withUploads()
+ const added = uploads.addExisting([
+ { id: 'small', name: '보고서.pdf', type: 'application/pdf', size: 10 },
+ { id: 'large', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
+ { id: 'sheet', name: '표.xlsx', type: 'application/vnd.ms-excel', size: 5 },
+ ])
+ const keys = added.map((item) => item.key)
+
+ uploads.applySavedAttachments(keys, [
+ { id: 'large-v1', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
+ // 서버가 형식을 고쳐 돌려줬다 — 남은 것끼리 순서대로
+ { id: 'sheet-v1', name: '표.xlsx', type: 'application/octet-stream', size: 5 },
+ { id: 'small-v1', name: '보고서.pdf', type: 'application/pdf', size: 10 },
+ ])
+
+ expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['small-v1', 'large-v1', 'sheet-v1'])
+ stop()
+ })
+
+ it('개수가 다르면 같은 자리 · 같은 이름끼리만 — 저장하는 동안 뺀 첨부는 건너뛴다', async () => {
+ installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
+ const { uploads, stop } = withUploads()
+ const added = uploads.addExisting([SAVED('a', '가.pdf'), SAVED('b', '나.pdf')])
+ const keys = added.map((item) => item.key)
+
+ uploads.remove(keys[1] ?? -1)
+ uploads.applySavedAttachments(keys, [SAVED('a-v1', '가.pdf')])
+
+ expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['a-v1'])
+ stop()
+ })
+ })
})
diff --git a/frontend/src/composables/useAttachmentUploads.ts b/frontend/src/composables/useAttachmentUploads.ts
index 26cc519..c8c3105 100644
--- a/frontend/src/composables/useAttachmentUploads.ts
+++ b/frontend/src/composables/useAttachmentUploads.ts
@@ -51,6 +51,11 @@ export interface AttachmentUploadOptions {
const COMPLETE = 100
+/** 같은 파일로 보이는가 — 이름 · 크기 · 형식이 모두 같다 */
+function isSameFile(a: Attachment, b: Attachment): boolean {
+ return a.name === b.name && a.size === b.size && a.type === b.type
+}
+
/**
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
@@ -205,6 +210,43 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
return marked
}
+ /**
+ * 임시 저장이 끝났다 — 저장본의 첨부 id 로 바꿔 쓴다 (계약 2절 "임시 저장": 저장할 때마다 첨부 id 가 바뀐다).
+ * keys 는 저장 본문에 실은 첨부 항목의 키(실은 순서대로), saved 는 응답의 첨부다.
+ * 이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이어도 응답의 순서에만 기대지 않게.
+ * 남은 것은 순서대로 — 남은 수가 같으면 같은 자리끼리, 다르면 같은 자리 · 같은 이름끼리만 바꾼다.
+ * 저장하는 동안 뺀 항목은 이미 없고(그 자리는 건너뛴다), 새로 붙인 항목은 다음 저장에 실린다.
+ */
+ function applySavedAttachments(keys: readonly number[], saved: readonly Attachment[]) {
+ const sent = keys.map((key) => items.value.find((candidate) => candidate.key === key))
+ const next = new Map()
+ const usedSaved = new Set()
+ const matchedSent = new Set()
+ sent.forEach((item, position) => {
+ const current = item?.attachment
+ if (!item || !current) return
+ const index = saved.findIndex((candidate, at) => !usedSaved.has(at) && isSameFile(current, candidate))
+ const attachment = saved[index]
+ if (!attachment) return
+ usedSaved.add(index)
+ matchedSent.add(position)
+ next.set(item.key, attachment)
+ })
+ const restSent = sent.filter((_, position) => !matchedSent.has(position))
+ const restSaved = saved.filter((_, at) => !usedSaved.has(at))
+ const sameCount = restSent.length === restSaved.length
+ restSent.forEach((item, index) => {
+ const attachment = restSaved[index]
+ if (!attachment || !item?.attachment) return
+ if (sameCount || item.attachment.name === attachment.name) next.set(item.key, attachment)
+ })
+ if (next.size === 0) return
+ items.value = items.value.map((item) => {
+ const attachment = next.get(item.key)
+ return attachment && item.status === 'done' ? { ...item, attachment } : item
+ })
+ }
+
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
function remove(key: number) {
controllers.get(key)?.abort()
@@ -233,6 +275,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachedCount,
addFiles,
addExisting,
+ applySavedAttachments,
markMissing,
remove,
reset,
diff --git a/frontend/src/composables/useClock.ts b/frontend/src/composables/useClock.ts
new file mode 100644
index 0000000..b7bfd00
--- /dev/null
+++ b/frontend/src/composables/useClock.ts
@@ -0,0 +1,29 @@
+import { inject, type InjectionKey } from 'vue'
+
+// 시계 — 기다렸다 하는 일(임시 저장의 입력 멈춤 · 받는 사람 추천의 기다림)이 쓰는 시각과 타이머.
+// 화면은 기본으로 브라우저 시계를 쓰고, 테스트는 손으로 돌리는 시계를 provide 해 시간을 정확히 넘긴다
+// (test-utils/manual-clock.ts — 진짜 타이머를 기다리면 느리고, 부하에 따라 결과가 달라진다).
+
+/** 타이머 손잡이 — 시계마다 모양이 다르므로 열어 보지 않는다 */
+export type ClockTimer = ReturnType | number | symbol
+
+export interface Clock {
+ /** 지금 시각 (밀리초) */
+ now: () => number
+ setTimeout: (callback: () => void, ms: number) => ClockTimer
+ clearTimeout: (timer: ClockTimer) => void
+}
+
+export const systemClock: Clock = {
+ now: () => Date.now(),
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType),
+}
+
+/** 화면 트리에 시계를 준다 — 주지 않으면 브라우저 시계 */
+export const CLOCK: InjectionKey = Symbol('clock')
+
+/** 컴포넌트 setup 에서 부른다 */
+export function useClock(): Clock {
+ return inject(CLOCK, systemClock)
+}
diff --git a/frontend/src/composables/useComposeDraftLink.ts b/frontend/src/composables/useComposeDraftLink.ts
new file mode 100644
index 0000000..f3861d8
--- /dev/null
+++ b/frontend/src/composables/useComposeDraftLink.ts
@@ -0,0 +1,119 @@
+import { onBeforeUnmount } from 'vue'
+import { useRouter } from 'vue-router'
+
+import { saveDraftDetached } from './useDraftSaving'
+import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
+import { useMailStore } from '@/stores/mail.store'
+
+// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
+// - 주소 맞추기(sync): 저장할 때마다 /mail/compose?draft=<새 id> 로 바꿔 끼운다 — 새로고침 · 다시 열기가 마지막 저장본을 이어 쓴다.
+// 새 메일 · 답장 · 전달 · 옛 draft 매개변수를 모두 이것으로 바꾼다. 스스로 바꾼 주소는 ownsKey 로 알아본다 —
+// 쓰기 화면은 그 주소로 양식을 다시 채우지도, 떠나는 이동으로 보지도 않는다.
+// 다른 이동이 진행 중이면 바꾸지 않는다 — 그 이동을 취소해 버린다: 쓰기 화면이 막는 때(canReplace — 나가기 확인 · 떠나기 전 저장 ·
+// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
+// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
+// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
+// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
+// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
+
+export interface ComposeDraftLinkOptions {
+ /** 지금 주소를 바꿔도 되는가 — 쓰기 화면의 다른 흐름(나가기 확인 · 보내기 · 버리기 · 보낸 뒤 · 버린 뒤)이 아니다 */
+ canReplace: () => boolean
+}
+
+/** 이 화면이 주소에 쓰는 저장본 표시 — 쓰기 화면의 draftKeyOf 와 같은 모양 */
+function draftKeyFor(id: string): string {
+ return `draft:${id}`
+}
+
+export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
+ const router = useRouter()
+ const mailStore = useMailStore()
+
+ /**
+ * 이 양식의 주소 — 연 때의 쓰기 종류 · 원본, 저장한 뒤에는 바꿔 끼운 `draft:`.
+ * 주소가 이것으로 바뀐 것은 이 화면이 스스로 바꾼 것이다
+ */
+ let ownKey = ''
+ /** 이 화면이 바꿔 끼우는 중인 주소 — 다른 이동을 세는 데서 뺀다 */
+ let ownSyncPath: string | null = null
+ /**
+ * 라우터의 가드를 지난 다른 이동 수 — 화면이 바뀌기 전(지연 로딩한 화면을 받는 중 등)에 주소를 바꿔 끼우면 그 이동이 취소된다.
+ * 이 화면의 이동 가드(떠나기 전 저장 · 대화상자)를 기다리는 동안은 쓰기 화면의 canReplace 가 맡는다 (그 가드가 라우터 전체 가드보다 먼저 돈다)
+ */
+ let navigationsPending = 0
+
+ const stopCountingStart = router.beforeEach((to) => {
+ if (to.fullPath !== ownSyncPath) navigationsPending += 1
+ })
+ const stopCountingEnd = router.afterEach((to, _from, failure) => {
+ const own = ownSyncPath !== null && to.fullPath === ownSyncPath
+ if (own) ownSyncPath = null
+ // 끝까지 간 이동이 있으면 그 앞의 이동은 남아 있지 않다(라우터가 앞 이동을 취소한다). 실패로 끝난 이동은 하나만 뺀다
+ if (!failure) navigationsPending = 0
+ else if (!own) navigationsPending = Math.max(0, navigationsPending - 1)
+ })
+ // 이동이 오류로 끝나면 afterEach 가 오지 않는다 — 세던 것을 비운다
+ const stopCountingError = router.onError(() => {
+ navigationsPending = 0
+ })
+
+ /** 이 화면이 스스로 바꾼(또는 연) 주소인가 */
+ function ownsKey(key: string): boolean {
+ return key === ownKey
+ }
+
+ /** 이 주소의 양식을 연다 — 이제 이 주소가 이 화면의 것이다 */
+ function adoptKey(key: string) {
+ ownKey = key
+ }
+
+ /** 주소를 /mail/compose?draft= 로 바꿔 끼운다 — 이 화면이 스스로 바꾼 것으로 표시한다. 이미 그 주소면 그대로 */
+ function replace(id: string) {
+ ownKey = draftKeyFor(id)
+ const current = router.currentRoute.value
+ if (current.query.draft === id && Object.keys(current.query).length === 1) return
+ const target = router.resolve({ name: 'mail-compose', query: { draft: id } })
+ ownSyncPath = target.fullPath
+ void router.replace(target)
+ }
+
+ /** 저장한 저장본을 주소에 옮긴다 — 다른 이동이 진행 중이거나 쓰기 화면을 떠났으면 바꾸지 않는다 */
+ function sync(id: string | null) {
+ if (!id || !options.canReplace() || navigationsPending > 0) return
+ if (router.currentRoute.value.name !== 'mail-compose') return
+ replace(id)
+ }
+
+ /** 이어 쓸 저장본 — 이 탭에서 다시 저장해 바뀐 옛 id 면 마지막 저장본을 고르고 주소도 그것으로 바꾼다 */
+ function openDraft(requested: string): string {
+ const latest = mailStore.latestDraftId(requested)
+ if (latest !== requested) replace(latest)
+ return latest
+ }
+
+ /** 저장이 끝났다 — 옛 id → 새 id 를 적는다 (양식이 바뀐 뒤 끝난 저장도 — 탭 메모리 정보라 해가 없다) */
+ function recordSaved(previousId: string | null, saved: DraftSaved) {
+ if (previousId !== null) mailStore.recordDraftSuccessor(previousId, saved.id)
+ }
+
+ /**
+ * 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
+ * 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
+ */
+ async function keepUnsent(payload: SendMessagePayload) {
+ const { draftId = null, ...draft } = payload
+ const saved = await saveDraftDetached(draft, draftId)
+ if (!saved) return
+ recordSaved(draftId, saved)
+ mailStore.syncCounts()
+ }
+
+ onBeforeUnmount(() => {
+ stopCountingStart()
+ stopCountingEnd()
+ stopCountingError()
+ })
+
+ return { ownsKey, adoptKey, sync, openDraft, recordSaved, keepUnsent }
+}
diff --git a/frontend/src/composables/useDraftSaving.spec.ts b/frontend/src/composables/useDraftSaving.spec.ts
new file mode 100644
index 0000000..21ba6c6
--- /dev/null
+++ b/frontend/src/composables/useDraftSaving.spec.ts
@@ -0,0 +1,447 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { effectScope, type EffectScope } from 'vue'
+
+import { useDraftSaving, type DraftSaveRequest } from '@/composables/useDraftSaving'
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } 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'
+
+const PAYLOAD: DraftPayload = { to: [], cc: [], bcc: [], subject: '초안', body: '', attachments: [] }
+
+function savedAs(id: string, at = '2026-10-27T06:12:00Z'): DraftSaved {
+ return { id, attachments: [], updatedAt: at }
+}
+
+/** 붙잡아 둔 응답 */
+interface Held {
+ release: (result: MockResult) => void
+}
+
+describe('useDraftSaving (임시 저장 — 계약 2절 "임시 저장")', () => {
+ let scope: EffectScope
+ let clock: ManualClock
+ /** 저장할 바뀐 내용이 있다 — 저장이 끝나면(commit) 지운다 */
+ let changed: boolean
+ let commits: DraftSaved[]
+ let requests: MockRequest[]
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
+ scope = effectScope()
+ clock = createManualClock(1_000_000)
+ changed = false
+ commits = []
+ })
+
+ afterEach(() => {
+ scope.stop()
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ function setup(handler: MockHandler, canSave = () => true) {
+ requests = installMockApi(handler)
+ const drafts = scope.run(() =>
+ useDraftSaving({
+ canSave,
+ hasChanges: () => changed,
+ prepare: (): DraftSaveRequest => ({
+ payload: PAYLOAD,
+ commit: (saved) => {
+ changed = false
+ commits.push(saved)
+ },
+ }),
+ clock,
+ }),
+ )
+ if (!drafts) throw new Error('scope')
+ return drafts
+ }
+
+ /** 바꿨다 — 화면이 하는 것처럼 touch */
+ function edit(drafts: ReturnType) {
+ changed = true
+ drafts.touch()
+ }
+
+ const saves = () => requests.filter((request) => request.url.startsWith('/drafts'))
+ const calls = () => saves().map((request) => `${request.method} ${request.url}`)
+
+ /** 처음은 d1, 그다음 d2 … 를 돌려주는 서버 */
+ function counting(): MockHandler {
+ let next = 1
+ return (request) => {
+ if (!request.url.startsWith('/drafts')) return ok(null)
+ const id = `d${next}`
+ next += 1
+ return ok(savedAs(id), request.method === 'POST' ? 201 : 200)
+ }
+ }
+
+ it('입력이 3초 멈추면 저장한다 — 계속 쓰는 동안은 미룬다', async () => {
+ const drafts = setup(counting())
+ edit(drafts)
+ clock.advance(2_000)
+ edit(drafts)
+ clock.advance(2_999)
+ await flushPromises()
+ expect(calls()).toEqual([])
+
+ clock.advance(1)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+ expect(drafts.status.value).toBe('saved')
+ expect(drafts.draftId.value).toBe('d1')
+ expect(drafts.savedAt.value).toBe('2026-10-27T06:12:00Z')
+ expect(commits).toHaveLength(1)
+ })
+
+ it('쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다 — 되돌려 바꾼 것이 없어지면 거둔다', async () => {
+ const drafts = setup(counting())
+ for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
+ edit(drafts)
+ clock.advance(1_000)
+ await flushPromises()
+ if (elapsed + 1_000 < 30_000) expect(calls()).toEqual([])
+ }
+ expect(calls()).toEqual(['POST /drafts'])
+
+ // 바꿨다가 되돌리면(바꾼 것이 없어짐) 30초 타이머도 거둔다
+ edit(drafts)
+ changed = false
+ drafts.touch()
+ clock.advance(60_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+ })
+
+ it('저장하는 동안 바꾼 것은 그때(저장하는 동안 처음 바꾼 때)부터 30초를 센다', async () => {
+ let held: Held | null = null
+ let posts = 0
+ const drafts = setup((request) => {
+ if (request.method === 'POST' && posts === 0) {
+ posts += 1
+ return new Promise((resolve) => {
+ held = { release: resolve }
+ })
+ }
+ return ok(savedAs('d2'))
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+
+ // 저장이 끝나지 않은 채 25초 동안 쉬지 않고 쓴다 — 30초 타이머는 저장하는 동안 처음 바꾼 때(3초)부터
+ for (let elapsed = 0; elapsed < 25_000; elapsed += 1_000) {
+ edit(drafts)
+ clock.advance(1_000)
+ }
+ ;(held as Held | null)?.release(ok(savedAs('d1'), 201))
+ await flushPromises()
+ changed = true
+ for (let elapsed = 0; elapsed < 5_000; elapsed += 1_000) {
+ edit(drafts)
+ clock.advance(1_000)
+ await flushPromises()
+ }
+ expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
+ })
+
+ it('바꾼 내용이 없으면 저장하지 않는다', async () => {
+ const drafts = setup(counting())
+ drafts.touch()
+ clock.advance(60_000)
+ await flushPromises()
+ expect(calls()).toEqual([])
+ expect(drafts.status.value).toBe('idle')
+ })
+
+ it('10초에 한 번까지 — 그다음 저장은 직전 저장을 시작한 지 10초 뒤, 직전 id 로 PUT', async () => {
+ const drafts = setup(counting())
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+
+ // 첫 저장을 시작한 때(3초)부터 10초 — 13초에
+ clock.advance(6_999)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+ clock.advance(1)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
+ expect(drafts.draftId.value).toBe('d2')
+ })
+
+ it('한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시 저장한다', async () => {
+ let held: Held | null = null
+ const drafts = setup((request) => {
+ if (request.method === 'POST') {
+ return new Promise((resolve) => {
+ held = { release: resolve }
+ })
+ }
+ return ok(savedAs('d2'))
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(drafts.status.value).toBe('saving')
+
+ // 저장하는 동안 또 바꾸고 3초 · 10초가 지나도 두 번째 요청은 나가지 않는다
+ edit(drafts)
+ clock.advance(15_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts'])
+
+ ;(held as Held | null)?.release(ok(savedAs('d1'), 201))
+ await flushPromises()
+ // 첫 저장의 commit 이 "바꿈"을 지웠지만, 그 뒤 또 바꿨다 — 화면은 다시 touch 한다
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
+ })
+
+ it.each(['MAIL_008', 'RES_001'] as const)(
+ 'PUT 이 %s 면 막지 않고 지금 내용을 POST 로 새로 남긴다 — 상태 replaced, 다음은 새 id 로',
+ async (code) => {
+ let posts = 0
+ const drafts = setup((request) => {
+ if (request.method === 'POST') {
+ posts += 1
+ return ok(savedAs(posts === 1 ? 'd1' : 'd9'), 201)
+ }
+ if (request.url === '/drafts/d1') return fail(code === 'MAIL_008' ? 409 : 404, code)
+ return ok(savedAs('d10'))
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ edit(drafts)
+ clock.advance(10_000)
+ await flushPromises()
+
+ expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1', 'POST /drafts'])
+ expect(drafts.status.value).toBe('replaced')
+ expect(drafts.draftId.value).toBe('d9')
+ expect(useToastStore().toasts).toEqual([])
+
+ edit(drafts)
+ clock.advance(10_000)
+ await flushPromises()
+ expect(calls().at(-1)).toBe('PUT /drafts/d9')
+ expect(drafts.status.value).toBe('saved')
+ },
+ )
+
+ it('자동 저장이 실패하면 알림 없이 failed — 다시 해 보면 나아질 실패는 15초 뒤 저절로 다시 한다', async () => {
+ let fails = 1
+ const drafts = setup(() => {
+ if (fails > 0) {
+ fails -= 1
+ return fail(503, 'MAIL_001')
+ }
+ return ok(savedAs('d1'), 201)
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(drafts.status.value).toBe('failed')
+ expect(drafts.lastError.value?.code).toBe('MAIL_001')
+ expect(useToastStore().toasts).toEqual([])
+
+ clock.advance(14_999)
+ await flushPromises()
+ expect(calls()).toHaveLength(1)
+ clock.advance(1)
+ await flushPromises()
+ expect(calls()).toHaveLength(2)
+ expect(drafts.status.value).toBe('saved')
+ })
+
+ it('내용을 고쳐야 하는 실패(MAIL_007 등)는 저절로 다시 하지 않는다 — 다시 바꾸면 저장한다', async () => {
+ const drafts = setup(() => fail(400, 'MAIL_007'))
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ clock.advance(120_000)
+ await flushPromises()
+ expect(calls()).toHaveLength(1)
+
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls()).toHaveLength(2)
+ })
+
+ it('「임시 저장」은 바로 저장하고, 실패하면 사전 문구 알림을 띄운다', async () => {
+ const drafts = setup(() => fail(507, 'MAIL_003'))
+ edit(drafts)
+ await drafts.saveNow()
+
+ expect(calls()).toEqual(['POST /drafts'])
+ expect(drafts.status.value).toBe('failed')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
+ })
+
+ it('「임시 저장」 — 아직 저장한 적 없으면 바꾼 것이 없어도 저장하고, 저장한 그대로면 요청하지 않는다', async () => {
+ const drafts = setup(counting())
+ await drafts.saveNow()
+ expect(calls()).toEqual(['POST /drafts'])
+ await drafts.saveNow()
+ expect(calls()).toEqual(['POST /drafts'])
+ })
+
+ it('보내기 · 버리기 전 — suspend 로 멈추고 settle 로 진행 중인 저장이 끝나기를 기다린다', async () => {
+ let held: Held | null = null
+ const drafts = setup(() => {
+ return new Promise((resolve) => {
+ held = { release: resolve }
+ })
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+
+ drafts.suspend()
+ let settled = false
+ void drafts.settle().then(() => {
+ settled = true
+ })
+ edit(drafts)
+ clock.advance(30_000)
+ await flushPromises()
+ expect(settled).toBe(false)
+ expect(calls()).toHaveLength(1)
+
+ ;(held as Held | null)?.release(ok(savedAs('d1'), 201))
+ await flushPromises()
+ expect(settled).toBe(true)
+ expect(drafts.draftId.value).toBe('d1')
+ // 멈춘 동안은 자동 저장하지 않는다
+ clock.advance(30_000)
+ await flushPromises()
+ expect(calls()).toHaveLength(1)
+ })
+
+ it('떠나기 전 — 남은 변경을 저장하고 성공 여부를 알려 준다 (저장한 그대로면 요청 없이 true)', async () => {
+ const drafts = setup(counting())
+ expect(await drafts.saveBeforeLeave()).toBe(true)
+ expect(calls()).toEqual([])
+
+ edit(drafts)
+ expect(await drafts.saveBeforeLeave()).toBe(true)
+ expect(calls()).toEqual(['POST /drafts'])
+ })
+
+ it('저장할 수 없는 때(canSave false)는 요청하지 않는다', async () => {
+ const drafts = setup(counting(), () => false)
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(await drafts.saveNow()).toBe(false)
+ expect(calls()).toEqual([])
+ })
+
+ it('reset — 이어 쓰는 저장본이면 그 id · 시각으로 시작하고, 늦게 끝난 앞 양식의 저장은 무시한다', async () => {
+ let held: Held | null = null
+ const drafts = setup((request) => {
+ if (request.method === 'POST') {
+ return new Promise((resolve) => {
+ held = { release: resolve }
+ })
+ }
+ return ok(savedAs('d8'))
+ })
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+
+ drafts.reset({ id: 'd7', savedAt: '2026-10-26T01:00:00Z' })
+ expect(drafts.status.value).toBe('saved')
+ ;(held as Held | null)?.release(ok(savedAs('d1'), 201))
+ await flushPromises()
+ expect(drafts.draftId.value).toBe('d7')
+ expect(commits).toEqual([])
+
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ expect(calls().at(-1)).toBe('PUT /drafts/d7')
+ })
+
+ it('reset(keepOriginal) — 원본을 지우지 않는다: 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT. 그동안 draftId 는 없다', async () => {
+ const drafts = setup(counting())
+ drafts.reset({ id: 'outlook-1', savedAt: '2026-10-26T01:00:00Z', keepOriginal: true })
+ expect(drafts.status.value).toBe('saved')
+ expect(drafts.draftId.value).toBeNull()
+
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ edit(drafts)
+ clock.advance(10_000)
+ await flushPromises()
+ expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
+ })
+
+ it('onSaved — 저장이 끝날 때마다 바꾼 id 와 새 저장본을 알린다 (양식이 바뀐 뒤 끝난 저장도)', async () => {
+ let held: Held | null = null
+ const seen: [string | null, string][] = []
+ requests = installMockApi((request) =>
+ request.method === 'PUT'
+ ? new Promise((resolve) => {
+ held = { release: resolve }
+ })
+ : ok(savedAs('d1'), 201),
+ )
+ const drafts = scope.run(() =>
+ useDraftSaving({
+ canSave: () => true,
+ hasChanges: () => changed,
+ prepare: (): DraftSaveRequest => ({ payload: PAYLOAD, commit: () => (changed = false) }),
+ onSaved: (previousId, saved) => seen.push([previousId, saved.id]),
+ clock,
+ }),
+ )
+ if (!drafts) throw new Error('scope')
+ edit(drafts)
+ clock.advance(3_000)
+ await flushPromises()
+ edit(drafts)
+ clock.advance(10_000)
+ await flushPromises()
+
+ // PUT 이 나가 있는 동안 양식을 새로 채운다 — 늦게 끝난 저장도 알린다
+ drafts.reset()
+ ;(held as Held | null)?.release(ok(savedAs('d2')))
+ await flushPromises()
+ expect(seen).toEqual([
+ [null, 'd1'],
+ ['d1', 'd2'],
+ ])
+ expect(drafts.draftId.value).toBeNull()
+ })
+})
diff --git a/frontend/src/composables/useDraftSaving.ts b/frontend/src/composables/useDraftSaving.ts
new file mode 100644
index 0000000..f47c9f3
--- /dev/null
+++ b/frontend/src/composables/useDraftSaving.ts
@@ -0,0 +1,352 @@
+import { onScopeDispose, readonly, ref } from 'vue'
+
+import { toApiError, type ApiError } from './useApi'
+import { systemClock, type Clock, type ClockTimer } from './useClock'
+import { useMail, type MailRequestOptions } from './useMail'
+import {
+ DRAFT_IDLE_SAVE_MS,
+ DRAFT_MAX_WAIT_MS,
+ DRAFT_MIN_SAVE_INTERVAL_MS,
+ DRAFT_RETRY_CODES,
+ DRAFT_RETRY_DELAYS_MS,
+} from '@/shared/constants/mail'
+import type { ErrorCode } from '@/shared/constants/error-codes.generated'
+import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
+
+// 쓰기 화면의 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장")
+// - 자동 저장: 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 저장은 한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시.
+// 쉬지 않고 쓰는 동안에도 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 30초가 지나면 저장한다.
+// - 처음은 POST /drafts, 그다음은 PUT /drafts/{직전 id} — 응답의 id · 첨부로 바꿔 쓴다(prepare 가 준 commit).
+// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌으면(409 MAIL_008 · 임시보관함 밖으로 옮겨져 404 RES_001) 막지 않고 POST 로 새로 남긴다
+// — 상태 'replaced' (임시보관함에 두 통이 남는다).
+// - 자동 저장 실패는 알림 없이 상태(failed)로만 보이고, 다시 해 보면 나아질 실패(DRAFT_RETRY_CODES)는 조금 뒤 저절로 다시 한다.
+// 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다 (인터셉터 — MAIL_008 · RES_001 은 위처럼 처리하므로 끈다).
+// - 보내기 · 버리기 전에는 suspend 로 자동 저장을 멈추고 settle 로 진행 중인 저장을 기다린다 (draftId 가 최신이어야 한다).
+
+export type DraftSaveStatus = 'idle' | 'saving' | 'saved' | 'failed' | 'replaced'
+
+/** 저장을 시작한 까닭 — 알림을 띄울지(manual) 정한다 */
+export type DraftSaveTrigger = 'auto' | 'manual' | 'leave'
+
+/**
+ * 저장할 수 없는 내용이다 — 보내기 전 검사에 걸렸다 (예: 본문이 한도를 넘음 MAIL_007).
+ * 요청 없이 실패로 두고 저절로 다시 하지 않는다 — 내용이 바뀌면(touch) 다시 잰다
+ */
+export interface DraftSaveRejected {
+ rejected: ApiError
+}
+
+/** 저장 한 번 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수 */
+export interface DraftSaveRequest {
+ payload: DraftPayload
+ /** 저장이 끝났다 — 응답의 첨부 id 로 바꿔 쓰고, 이 요청을 만든 때의 양식을 "바꾸지 않은" 기준으로 삼는다 */
+ commit: (saved: DraftSaved) => void
+}
+
+export interface DraftSavingOptions {
+ /** 지금 저장해도 되는가 — 편집기 준비 · 로그인 · 보내는 중 아님 등 */
+ canSave: () => boolean
+ /** 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다 */
+ hasChanges: () => boolean
+ /** 지금 내용으로 저장 요청을 만든다 — 만들 수 없으면 null, 저장할 수 없는 내용이면(사전 검사) rejected */
+ prepare: () => DraftSaveRequest | DraftSaveRejected | null
+ /** 저장이 실패했다 — 예: 찾지 못한 첨부(MAIL_004)를 표시한다 */
+ onFailed?: (error: ApiError, trigger: DraftSaveTrigger) => void
+ /**
+ * 저장이 끝났다 — previousId 는 이 저장이 바꾼(PUT 한) 저장본 id, 새로 만들었으면 null.
+ * 양식이 바뀐 뒤 끝난 저장도 부른다 (commit 은 부르지 않는다) — 예: 옛 id → 새 id 를 적어 둔다
+ */
+ onSaved?: (previousId: string | null, saved: DraftSaved) => void
+ clock?: Clock
+}
+
+/** 이어 쓰는 저장본 — 쓰기 화면이 임시 저장본을 불러와 채웠을 때 */
+export interface ExistingDraft {
+ id: string
+ /** 마지막으로 저장한 시각 (ISO 8601) */
+ savedAt: string
+ /**
+ * 원본을 지우지 않는다 — 첫 저장을 PUT 대신 새 저장본(POST)으로 하고, 그 뒤는 새 id 로 PUT 한다.
+ * 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(사전 검사 실패)가 있을 때 — PUT 하면 서버가 그 첨부째 원본을 지운다.
+ * 그동안 draftId 는 null 이다 (보내기도 원본을 가리키지 않는다)
+ */
+ keepOriginal?: boolean
+}
+
+/** 「임시 저장」을 눌렀을 때 끄는 알림 — 이 둘은 새로 저장해 처리한다 */
+const REPLACED_CODES: readonly ErrorCode[] = ['MAIL_008', 'RES_001']
+
+function isRetryable(error: ApiError): boolean {
+ return !error.canceled && DRAFT_RETRY_CODES.includes(error.code)
+}
+
+type DraftApi = Pick, 'createDraft' | 'updateDraft'>
+
+/**
+ * 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
+ * silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
+ */
+async function putOrCreate(
+ mail: DraftApi,
+ payload: DraftPayload,
+ id: string | null,
+ silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
+): Promise<{ saved: DraftSaved; replaced: boolean }> {
+ if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
+ try {
+ return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
+ } catch (error) {
+ if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
+ // 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
+ return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
+ }
+}
+
+/**
+ * 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
+ * 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
+ */
+export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise {
+ try {
+ return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
+ } catch {
+ return null
+ }
+}
+
+export function useDraftSaving(options: DraftSavingOptions) {
+ const mail = useMail()
+ const clock = options.clock ?? systemClock
+
+ /** 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로 */
+ const draftId = ref(null)
+ const status = ref('idle')
+ /** 마지막으로 저장한 시각 — 상태 글에 보인다 */
+ const savedAt = ref(null)
+ /** 마지막 저장의 실패 (성공하면 비운다) */
+ const lastError = ref(null)
+
+ /** 양식 세대 — reset 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게 */
+ let generation = 0
+ let inFlight: Promise | null = null
+ let idleTimer: ClockTimer | null = null
+ /** 쉬지 않고 쓰는 동안의 저장 — 마지막 저장을 시작한 뒤 처음 바꾼 때 건다 (그 뒤로는 다시 걸지 않는다) */
+ let maxWaitTimer: ClockTimer | null = null
+ let retryTimer: ClockTimer | null = null
+ /** 마지막 저장을 시작한 시각 — 자동 저장은 10초에 한 번까지 */
+ let lastStartedAt = Number.NEGATIVE_INFINITY
+ let retryCount = 0
+ /** 저장하는 동안 자동 저장할 차례가 왔다 — 끝나면 다시 저장한다 */
+ let queued = false
+ let suspended = false
+ let stopped = false
+
+ function clearIdle() {
+ if (idleTimer !== null) clock.clearTimeout(idleTimer)
+ idleTimer = null
+ }
+
+ function clearMaxWait() {
+ if (maxWaitTimer !== null) clock.clearTimeout(maxWaitTimer)
+ maxWaitTimer = null
+ }
+
+ function clearRetry() {
+ if (retryTimer !== null) clock.clearTimeout(retryTimer)
+ retryTimer = null
+ }
+
+ /** 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 그때까지 미룬다 (입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다) */
+ function autosave() {
+ clearIdle()
+ if (suspended || stopped) return
+ if (inFlight) {
+ queued = true
+ return
+ }
+ if (!options.hasChanges()) return
+ const wait = lastStartedAt + DRAFT_MIN_SAVE_INTERVAL_MS - clock.now()
+ if (wait > 0) {
+ idleTimer = clock.setTimeout(autosave, wait)
+ return
+ }
+ void run('auto')
+ }
+
+ function onMaxWait() {
+ maxWaitTimer = null
+ autosave()
+ }
+
+ /**
+ * 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다 (계속 쓰는 동안은 미룬다).
+ * 다만 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다 — 그 타이머는 처음 바꾼 때 한 번만 건다.
+ * 바꾼 것이 없어졌으면(되돌림) 둘 다 거둔다.
+ */
+ function touch() {
+ if (suspended || stopped) return
+ clearIdle()
+ if (!options.hasChanges()) {
+ clearMaxWait()
+ return
+ }
+ idleTimer = clock.setTimeout(autosave, DRAFT_IDLE_SAVE_MS)
+ if (maxWaitTimer === null) maxWaitTimer = clock.setTimeout(onMaxWait, DRAFT_MAX_WAIT_MS)
+ }
+
+ function scheduleRetry() {
+ clearRetry()
+ const delay = DRAFT_RETRY_DELAYS_MS[Math.min(retryCount, DRAFT_RETRY_DELAYS_MS.length - 1)] ?? 0
+ retryCount += 1
+ retryTimer = clock.setTimeout(() => {
+ retryTimer = null
+ autosave()
+ }, delay)
+ }
+
+ /** 저장 요청 — PUT 이 MAIL_008 · RES_001 이면 새 저장본으로. 「임시 저장」만 사전 문구 알림을 띄운다 */
+ function send(payload: DraftPayload, trigger: DraftSaveTrigger): Promise<{ saved: DraftSaved; replaced: boolean }> {
+ const manual = trigger === 'manual'
+ return putOrCreate(mail, payload, draftId.value, {
+ first: manual ? REPLACED_CODES : true,
+ fallback: manual ? undefined : true,
+ })
+ }
+
+ /** 저장 한 번 — 끝나면 성공 여부 */
+ async function run(trigger: DraftSaveTrigger): Promise {
+ if (stopped || !options.canSave()) return false
+ const request = options.prepare()
+ if (!request) return false
+ clearIdle()
+ clearMaxWait()
+ clearRetry()
+ queued = false
+ if ('rejected' in request) {
+ // 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다. 저절로 다시 하지 않는다 (내용이 바뀌면 touch 가 다시 잡는다)
+ status.value = 'failed'
+ lastError.value = request.rejected
+ options.onFailed?.(request.rejected, trigger)
+ return false
+ }
+ const started = generation
+ lastStartedAt = clock.now()
+ status.value = 'saving'
+
+ // 이 저장이 바꿀 저장본 — send 가 이 id 로 PUT 한다(없으면 POST)
+ const previousId = draftId.value
+ const attempt = (async (): Promise => {
+ try {
+ const { saved, replaced } = await send(request.payload, trigger)
+ options.onSaved?.(previousId, saved)
+ if (started !== generation) return false
+ draftId.value = saved.id
+ savedAt.value = saved.updatedAt
+ status.value = replaced ? 'replaced' : 'saved'
+ lastError.value = null
+ retryCount = 0
+ request.commit(saved)
+ return true
+ } catch (error) {
+ if (started !== generation) return false
+ const apiError = toApiError(error)
+ status.value = 'failed'
+ lastError.value = apiError
+ options.onFailed?.(apiError, trigger)
+ if (isRetryable(apiError) && !suspended && !stopped) scheduleRetry()
+ return false
+ }
+ })()
+ inFlight = attempt
+ const ok = await attempt
+ if (inFlight === attempt) inFlight = null
+ // 저장하는 동안 자동 저장 차례가 왔다 — 남은 변경을 다시 (10초 간격은 autosave 가 지킨다)
+ if (started === generation && queued) {
+ queued = false
+ autosave()
+ }
+ return ok
+ }
+
+ /** 진행 중인 저장이 끝나기를 기다린다 (보내기 · 버리기 전) */
+ async function settle(): Promise {
+ while (inFlight) await inFlight
+ }
+
+ /**
+ * 「임시 저장」 — 진행 중인 저장을 기다린 뒤, 바꾼 것이 있거나 아직 한 번도 저장하지 않았으면 저장한다.
+ * 이미 저장한 그대로면 요청하지 않는다. 실패하면 사전 문구 알림(인터셉터)이 함께 뜬다.
+ */
+ async function saveNow(): Promise {
+ clearIdle()
+ await settle()
+ if (draftId.value !== null && !options.hasChanges()) return true
+ return run('manual')
+ }
+
+ /** 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) true */
+ async function saveBeforeLeave(): Promise {
+ clearIdle()
+ await settle()
+ if (!options.hasChanges()) return true
+ return run('leave')
+ }
+
+ /** 자동 저장을 멈춘다 — 보내기 · 버리기 동안 */
+ function suspend() {
+ suspended = true
+ clearIdle()
+ clearMaxWait()
+ clearRetry()
+ }
+
+ /** 다시 자동 저장한다 — 보내기가 실패했을 때 등 */
+ function resume() {
+ if (stopped) return
+ suspended = false
+ touch()
+ }
+
+ /** 더 저장하지 않는다 — 보냈거나 버렸다 */
+ function stop() {
+ stopped = true
+ suspend()
+ }
+
+ /** 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 와 저장 시각으로 시작한다 */
+ function reset(existing: ExistingDraft | null = null) {
+ generation += 1
+ clearIdle()
+ clearMaxWait()
+ clearRetry()
+ inFlight = null
+ queued = false
+ suspended = false
+ stopped = false
+ lastStartedAt = Number.NEGATIVE_INFINITY
+ retryCount = 0
+ draftId.value = existing && !existing.keepOriginal ? existing.id : null
+ savedAt.value = existing?.savedAt ?? null
+ status.value = existing ? 'saved' : 'idle'
+ lastError.value = null
+ }
+
+ // 화면이 사라지면 더 저장하지 않는다 — 진행 중인 저장은 끝까지 받아 draftId 를 맞춘다
+ // (보내기를 누르고 바로 떠난 경우 — 보내기가 그 저장을 기다렸다가 새 draftId 로 보낸다)
+ onScopeDispose(stop)
+
+ return {
+ draftId: readonly(draftId),
+ status: readonly(status),
+ savedAt: readonly(savedAt),
+ lastError: readonly(lastError),
+ touch,
+ saveNow,
+ saveBeforeLeave,
+ settle,
+ suspend,
+ resume,
+ stop,
+ reset,
+ }
+}
diff --git a/frontend/src/composables/useDraftTrash.ts b/frontend/src/composables/useDraftTrash.ts
new file mode 100644
index 0000000..e43927c
--- /dev/null
+++ b/frontend/src/composables/useDraftTrash.ts
@@ -0,0 +1,65 @@
+import { getErrorMessage, notifyApiError, toApiError } from './useApi'
+import { useMail } from './useMail'
+import { useToast } from './useToast'
+import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
+import { useMailStore } from '@/stores/mail.store'
+
+// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장" · 계약 2절 "임시 저장")
+// - 저장본을 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 } 이다. DELETE 는 이미 휴지통에 있는 메일을 영구 삭제한다 —
+// 다른 창에서 먼저 휴지통으로 옮긴 저장본이 되돌릴 수 없게 지워지지 않게.
+// - 휴지통 id 를 모르면 메일함을 먼저 불러온다. 그래도 모르면 지우지 않고 실패다 — 메일함 목록을 못 받았으면 그 오류,
+// 받았는데 휴지통이 없으면 BIZ_001 을 알린다.
+// - 옮겼으면 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + [되돌리기](임시보관함으로 — "임시보관함으로 되돌렸습니다.").
+// 이미 없는 저장본(RES_001 — 다른 곳에서 보냈거나 지웠다)이면 버린 것과 같다 — 알리지 않는다.
+
+/** moved — 옮겼다 · gone — 이미 없다(RES_001) · failed — 옮기지 못했다(알렸다) */
+export type DraftTrashResult = 'moved' | 'gone' | 'failed'
+
+export function useDraftTrash() {
+ const mailStore = useMailStore()
+ const { updateMessage } = useMail()
+ const toast = useToast()
+
+ async function moveToTrash(draftId: string): Promise {
+ let trash = mailStore.mailboxByRole('trash')
+ if (!trash) {
+ // 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
+ const loadError = await mailStore.loadMailboxes({ background: true })
+ trash = mailStore.mailboxByRole('trash')
+ if (!trash) {
+ notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
+ return 'failed'
+ }
+ }
+ try {
+ await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
+ return 'moved'
+ } catch (error) {
+ // 그 밖의 실패는 인터셉터가 알렸다
+ return toApiError(error).code === 'RES_001' ? 'gone' : 'failed'
+ }
+ }
+
+ /** 되돌리기 — 버린 저장본을 임시보관함으로 (PATCH { mailboxId }) */
+ async function restore(draftId: string, draftsMailboxId: string) {
+ if (await mailStore.moveMessage(draftId, draftsMailboxId)) toast.success(DRAFT_RESTORED_MESSAGE)
+ }
+
+ /** 저장본을 휴지통으로 옮기고 알린다 — 옮겼으면 [되돌리기] 알림. 옮겼거나 이미 없으면 개수를 서버 기준으로 다시 맞춘다 */
+ async function discard(draftId: string): Promise {
+ const result = await moveToTrash(draftId)
+ if (result === 'failed') return result
+ // 임시보관함 · 휴지통 개수를 서버 기준으로
+ mailStore.syncCounts()
+ if (result === 'moved') {
+ const draftsMailbox = mailStore.mailboxByRole('drafts')
+ toast.success(
+ DRAFT_DISCARDED_MESSAGE,
+ draftsMailbox ? { label: DRAFT_UNDO_LABEL, run: () => void restore(draftId, draftsMailbox.id) } : undefined,
+ )
+ }
+ return result
+ }
+
+ return { discard }
+}
diff --git a/frontend/src/composables/useLeaveConfirm.ts b/frontend/src/composables/useLeaveConfirm.ts
index 153740e..be5eb2b 100644
--- a/frontend/src/composables/useLeaveConfirm.ts
+++ b/frontend/src/composables/useLeaveConfirm.ts
@@ -1,11 +1,13 @@
-import { onBeforeUnmount, readonly, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
-// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
+// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
+// 묻기 전에 prepareLeave(임시 저장 등)를 기다린다 — 그 뒤에도 잃을 것이 남았을 때만 묻는다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
+// guarding: 이동 가드가 준비 · 답을 기다리는 동안 true — 그동안 화면이 스스로 주소를 바꾸면(router.replace) 그 이동을 취소해 버린다.
export interface LeaveConfirmOptions {
/**
@@ -24,6 +26,16 @@ export interface LeaveConfirmOptions {
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
+ /**
+ * 묻기 전에 할 일 — 예: 남은 변경을 임시 저장한다. 끝난 뒤 active 를 다시 읽어 잃을 것이 남았을 때만 묻는다.
+ * 이동은 이것이 끝날 때까지 기다린다 (그사이 다른 이동이 오면 라우터가 앞 이동을 취소한다)
+ */
+ prepareLeave?: () => Promise
+ /**
+ * 묻는 중에 잃을 것이 없어졌을 때(active 가 false) 그대로 떠날지 — 예: 늦게 끝난 떠나기 전 저장(이제 잃을 것이 없다).
+ * 주지 않거나 false 면 그 이동을 취소한다 — 세션이 끝난 때처럼 다른 흐름(로그인 화면으로)이 이어받을 때
+ */
+ leaveWhenSettled?: () => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
@@ -52,8 +64,34 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
resolve?.(leave)
}
- function confirmLeave(): true | Promise {
- return options.active() ? ask() : true
+ /** 떠나기 전 준비 — 이동이 겹쳐도 한 번만 한다 */
+ let preparing: Promise | null = null
+ function prepare(): Promise {
+ if (!options.prepareLeave) return Promise.resolve()
+ if (!preparing) {
+ // 준비가 실패해도 이동 가드를 깨뜨리지 않는다 — 그다음 active 로 물을지 정한다
+ preparing = options
+ .prepareLeave()
+ .catch(() => undefined)
+ .finally(() => {
+ preparing = null
+ })
+ }
+ return preparing
+ }
+
+ /** 답을 기다리는 이동 가드 수 — 이동이 겹치면 여럿이다 */
+ const guards = ref(0)
+
+ async function confirmLeave(): Promise {
+ if (!options.active()) return true
+ guards.value += 1
+ try {
+ await prepare()
+ return options.active() ? await ask() : true
+ } finally {
+ guards.value -= 1
+ }
}
onBeforeRouteLeave(() => confirmLeave())
@@ -78,7 +116,7 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
- if (!active && pending) answer(false)
+ if (!active && pending) answer(options.leaveWhenSettled?.() ?? false)
})
onBeforeUnmount(() => {
@@ -89,6 +127,8 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
return {
confirming: readonly(confirming),
+ /** 이동 가드가 준비(prepareLeave) · 답을 기다리는 중 */
+ guarding: computed(() => guards.value > 0),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts
index d684226..0fc49e0 100644
--- a/frontend/src/composables/useMail.ts
+++ b/frontend/src/composables/useMail.ts
@@ -8,13 +8,21 @@ import {
} from '@/shared/constants/mail'
import type {
Attachment,
+ Draft,
+ DraftPayload,
+ DraftSaved,
MailQuota,
+ MailSignature,
Mailbox,
MessageDetail,
MessageListQuery,
MessageSummary,
+ RecipientSuggestion,
+ RecipientSuggestionQuery,
+ SaveSignaturePayload,
SendMessagePayload,
SendMessageResult,
+ SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -86,13 +94,71 @@ export function useMail() {
return api.get(`/messages/${segment(id)}`, options)
}
- function updateMessage(id: string, changes: UpdateMessagePayload): Promise {
- return api.patch(`/messages/${segment(id)}`, changes)
+ function updateMessage(
+ id: string,
+ changes: UpdateMessagePayload,
+ options?: MailRequestOptions,
+ ): Promise {
+ return api.patch(`/messages/${segment(id)}`, changes, options)
}
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
- async function deleteMessage(id: string): Promise {
- await api.delete(`/messages/${segment(id)}`)
+ async function deleteMessage(id: string, options?: MailRequestOptions): Promise {
+ await api.delete(`/messages/${segment(id)}`, options)
+ }
+
+ // ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
+
+ /** 이어 쓸 임시 저장본 — 없거나 초안이 아니면 404 RES_001 */
+ function getDraft(id: string, options?: MailRequestOptions): Promise {
+ return api.get(`/drafts/${segment(id)}`, options)
+ }
+
+ /** 처음 저장 — 201 DraftSaved. 본문 · 첨부는 발송과 같은 한도라 시간 한도도 발송과 같다 */
+ function createDraft(payload: DraftPayload, options?: MailRequestOptions): Promise {
+ return api.post('/drafts', payload, { timeout: MAIL_SEND_TIMEOUT_MS, ...options })
+ }
+
+ /**
+ * 다시 저장 — 서버가 새 초안을 만들고 옛것을 지운다(응답의 id · attachments 로 바꿔 쓴다).
+ * 옛 초안이 다른 곳에서 먼저 바뀌었거나 지워졌으면 409 MAIL_008 — 부르는 쪽이 createDraft 로 새로 남긴다.
+ */
+ function updateDraft(id: string, payload: DraftPayload, options?: MailRequestOptions): Promise {
+ return api.put(`/drafts/${segment(id)}`, payload, {
+ timeout: MAIL_SEND_TIMEOUT_MS,
+ ...options,
+ })
+ }
+
+ // ── 서명 (계약 2절 "서명") ─────────────────────────────
+
+ /** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
+ function getSignature(options?: MailRequestOptions): Promise {
+ return api.get('/mail-signature', options)
+ }
+
+ function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise {
+ return api.put('/mail-signature', payload, options)
+ }
+
+ /** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
+ function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise {
+ const form = new FormData()
+ form.append('file', file)
+ return api.post('/mail-signature/images', form, options)
+ }
+
+ // ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
+
+ /** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
+ function listRecipientSuggestions(
+ query: RecipientSuggestionQuery,
+ options?: MailRequestOptions,
+ ): Promise> {
+ return api.get, PaginatedResult>(
+ '/recipient-suggestions',
+ { ...options, params: query },
+ )
}
/**
@@ -160,5 +226,12 @@ export function useMail() {
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
+ getDraft,
+ createDraft,
+ updateDraft,
+ getSignature,
+ saveSignature,
+ uploadSignatureImage,
+ listRecipientSuggestions,
}
}
diff --git a/frontend/src/composables/useRecipientSuggestions.spec.ts b/frontend/src/composables/useRecipientSuggestions.spec.ts
new file mode 100644
index 0000000..6bbe7ad
--- /dev/null
+++ b/frontend/src/composables/useRecipientSuggestions.spec.ts
@@ -0,0 +1,145 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { effectScope, type EffectScope } from 'vue'
+
+import {
+ RECIPIENT_SEARCH_DELAY_MS,
+ useRecipientSuggestions,
+} from '@/composables/useRecipientSuggestions'
+import type { RecipientSuggestion } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
+import {
+ fail,
+ flushPromises,
+ installMockApi,
+ ok,
+ restoreApiAdapter,
+ type MockRequest,
+} from '@/test-utils/mock-api'
+
+const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
+const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
+const VENDOR: RecipientSuggestion = { name: '협력사 담당', email: 'ops@vendor.example', source: 'recent' }
+
+function page(items: RecipientSuggestion[], hasNext: boolean) {
+ return ok({ items, pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: null } })
+}
+
+describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () => {
+ let scope: EffectScope
+ let clock: ManualClock
+ let requests: MockRequest[]
+ let excluded: Set
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
+ scope = effectScope()
+ clock = createManualClock()
+ excluded = new Set()
+ })
+
+ afterEach(() => {
+ scope.stop()
+ restoreApiAdapter()
+ })
+
+ function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
+ requests = installMockApi((request) =>
+ request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
+ )
+ const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
+ if (!handle) throw new Error('scope')
+ return handle
+ }
+
+ const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
+
+ it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
+ const handle = setup(true)
+ handle.preload()
+ handle.preload()
+ await flushPromises()
+
+ expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
+ expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
+
+ excluded = new Set([DOYUN.email])
+ expect(handle.matchesFor('ㄷㅇ')).toEqual([])
+ })
+
+ it('받아 둔 목록이 전부면 서버에 다시 묻지 않는다', async () => {
+ const handle = setup(true)
+ handle.preload()
+ await flushPromises()
+
+ handle.search('vendor')
+ clock.advance(1_000)
+ await flushPromises()
+ expect(searches()).toEqual([])
+ })
+
+ it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
+ const handle = setup(false, [VENDOR])
+ handle.preload()
+ await flushPromises()
+
+ handle.search('ven')
+ clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
+ handle.search('vendor')
+ clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
+ await flushPromises()
+ expect(searches()).toEqual([])
+ clock.advance(1)
+ await flushPromises()
+
+ expect(searches()).toEqual(['vendor'])
+ expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
+
+ handle.search('ㄷㅇ')
+ handle.search('vendor')
+ clock.advance(1_000)
+ await flushPromises()
+ expect(searches()).toEqual(['vendor'])
+ })
+
+ it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
+ let failPreload = true
+ requests = installMockApi((request) => {
+ if (request.params.q !== undefined) return page([VENDOR], false)
+ if (failPreload) return fail(503, 'MAIL_001')
+ return page([DOYUN, JUNHO], false)
+ })
+ const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
+ if (!handle) throw new Error('scope')
+ handle.preload()
+ await flushPromises()
+
+ handle.search('ㄷㅇ')
+ clock.advance(1_000)
+ await flushPromises()
+ expect(searches()).toEqual([])
+ handle.search('vendor')
+ clock.advance(RECIPIENT_SEARCH_DELAY_MS)
+ await flushPromises()
+ expect(searches()).toEqual(['vendor'])
+ expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
+
+ failPreload = false
+ handle.preload()
+ await flushPromises()
+ handle.preload()
+ await flushPromises()
+ expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
+ expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
+ })
+
+ it('추천을 못 받아도 조용히 넘어간다 (주소를 그대로 칠 수 있다)', async () => {
+ installMockApi(() => fail(503, 'MAIL_001'))
+ const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
+ handle?.preload()
+ await flushPromises()
+ expect(handle?.matchesFor('도윤')).toEqual([])
+ })
+})
diff --git a/frontend/src/composables/useRecipientSuggestions.ts b/frontend/src/composables/useRecipientSuggestions.ts
new file mode 100644
index 0000000..f32c0f8
--- /dev/null
+++ b/frontend/src/composables/useRecipientSuggestions.ts
@@ -0,0 +1,127 @@
+import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
+
+import { systemClock, type Clock, type ClockTimer } from './useClock'
+import { useMail } from './useMail'
+import type { RecipientSuggestion } from '@/shared/types/mail'
+import { normalizeEmail } from '@/shared/utils/mail-address'
+import {
+ RECIPIENT_SUGGESTION_LIMIT,
+ filterRecipientSuggestions,
+ hasHangulJamo,
+ type RecipientMatch,
+} from '@/shared/utils/recipient-match'
+
+// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
+// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
+// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
+// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
+// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
+// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
+// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
+
+/** 처음 받아 두는 개수 — 계약의 최대 limit */
+export const RECIPIENT_SUGGESTION_PRELOAD = 100
+/** 서버에 다시 묻기 전에 기다리는 시간 */
+export const RECIPIENT_SEARCH_DELAY_MS = 250
+/** 서버가 받는 검색 글자 길이 (계약: q 1~100자) */
+const SEARCH_QUERY_MAX_LENGTH = 100
+
+export interface RecipientSuggestionsOptions {
+ /** 이미 넣은 주소 — 받는 사람 · 참조 · 숨은참조 모두 (정규화한 주소) */
+ excluded: () => ReadonlySet
+ clock?: Clock
+}
+
+export interface RecipientSuggestionsHandle {
+ /** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
+ preload: () => void
+ /** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
+ matchesFor: (query: string) => RecipientMatch[]
+ /** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
+ search: (query: string) => void
+}
+
+export const RECIPIENT_SUGGESTIONS: InjectionKey = Symbol('recipient-suggestions')
+
+export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
+ const { listRecipientSuggestions } = useMail()
+ const clock = options.clock ?? systemClock
+
+ /** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
+ const pool = shallowRef([])
+ /** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
+ const complete = ref(false)
+ /** 미리 받기 — 실패했으면 다음 preload 가 다시 받는다 */
+ let preloadState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
+ let searchTimer: ClockTimer | null = null
+ let controller: AbortController | null = null
+ /** 이미 물어본 글자 — 같은 글자를 두 번 묻지 않는다 */
+ const asked = new Set()
+
+ function merge(items: readonly RecipientSuggestion[]) {
+ const known = new Set(pool.value.map((item) => normalizeEmail(item.email)))
+ const added = items.filter((item) => {
+ const key = normalizeEmail(item.email)
+ if (known.has(key)) return false
+ known.add(key)
+ return true
+ })
+ if (added.length > 0) pool.value = [...pool.value, ...added]
+ }
+
+ function preload() {
+ if (preloadState === 'loading' || preloadState === 'done') return
+ preloadState = 'loading'
+ void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
+ .then((page) => {
+ preloadState = 'done'
+ merge(page.items)
+ complete.value = !page.pageInfo.hasNext
+ })
+ .catch(() => {
+ // 치는 대로 서버에 묻는다(search) — 다음에 칸에 초점이 오면 다시 받아 본다
+ preloadState = 'failed'
+ })
+ }
+
+ function matchesFor(query: string): RecipientMatch[] {
+ return filterRecipientSuggestions(query, pool.value, options.excluded())
+ }
+
+ function cancelSearch() {
+ if (searchTimer !== null) clock.clearTimeout(searchTimer)
+ searchTimer = null
+ controller?.abort()
+ controller = null
+ }
+
+ async function ask(query: string) {
+ searchTimer = null
+ asked.add(query)
+ controller = new AbortController()
+ const { signal } = controller
+ try {
+ const page = await listRecipientSuggestions(
+ { q: query, limit: RECIPIENT_SUGGESTION_LIMIT * 2 },
+ { silent: true, signal },
+ )
+ if (!signal.aborted) merge(page.items)
+ } catch {
+ // 보조 정보 — 못 받으면 받아 둔 목록만 보인다
+ asked.delete(query)
+ }
+ }
+
+ function search(query: string) {
+ cancelSearch()
+ const text = query.trim()
+ if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
+ if (hasHangulJamo(text) || asked.has(text)) return
+ if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
+ searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
+ }
+
+ onScopeDispose(cancelSearch)
+
+ return { preload, matchesFor, search }
+}
diff --git a/frontend/src/pages/mail/ComposePage.drafts.spec.ts b/frontend/src/pages/mail/ComposePage.drafts.spec.ts
new file mode 100644
index 0000000..9c00294
--- /dev/null
+++ b/frontend/src/pages/mail/ComposePage.drafts.spec.ts
@@ -0,0 +1,1081 @@
+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 { h, type FunctionalComponent } from 'vue'
+
+import ToastHost from '@/components/feedback/ToastHost.vue'
+import { CLOCK } from '@/composables/useClock'
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import { MAIL_BODY_MAX_LENGTH } from '@/shared/constants/mail'
+import type { Draft, MailSignature, Mailbox, MessageDetail } from '@/shared/types/mail'
+import { formatDraftSavedTime } from '@/shared/utils/format'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailStore } from '@/stores/mail.store'
+import { useToastStore } from '@/stores/toast.store'
+import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
+import {
+ 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 { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
+import ComposePage from './ComposePage.vue'
+
+// 쓰기 화면의 2단계 — 임시 저장 · 휴지통 · 이어 쓰기 · 서명 (계약 2절 "임시 저장" · "서명", 디자인 README "2단계")
+// 시계는 손으로 돌린다 — advance 를 부를 때만 자동 저장(입력이 3초 멈추면)이 돈다.
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec.ts 와 같은 값 */
+const EDITOR_LOAD_TIMEOUT_MS = 15_000
+const SAVED_AT = '2026-10-27T06:12:00Z'
+const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
+
+const ORIGINAL: MessageDetail = {
+ id: 'm1',
+ threadId: 't1',
+ mailboxIds: ['inbox'],
+ from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
+ to: [{ name: '김아라', email: USER.email }],
+ subject: '메일 서버 이전 일정 공유',
+ preview: '',
+ receivedAt: '2026-10-26T01:42:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ cc: [],
+ bcc: [],
+ replyTo: [],
+ sentAt: null,
+ messageId: '',
+ references: [],
+ htmlBody: null,
+ textBody: '김아라님, 안녕하세요.',
+ hasBlockedImages: false,
+ attachments: [],
+}
+
+const MAILBOXES: Mailbox[] = [
+ { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 1, totalCount: 1, unreadCount: 0 },
+ { id: 'drafts', name: '임시보관함', role: 'drafts', parentId: null, sortOrder: 3, totalCount: 0, unreadCount: 0 },
+ { id: 'trash', name: '휴지통', role: 'trash', parentId: null, sortOrder: 5, totalCount: 0, unreadCount: 0 },
+]
+
+const NO_SIGNATURE: MailSignature = {
+ bodyHtml: null,
+ body: null,
+ isForNew: true,
+ isForReply: true,
+ images: [],
+ updatedAt: null,
+}
+
+const SIGNATURE: MailSignature = {
+ bodyHtml:
+ '' +
+ ' 김아라 · 인프라팀 ' +
+ ` 
`,
+ body: '김아라 · 인프라팀',
+ isForNew: true,
+ isForReply: true,
+ images: [{ id: IMAGE_ID, type: 'image/png', size: 1000, width: 480, height: 120 }],
+ updatedAt: SAVED_AT,
+}
+
+/** 먼저 보는 응답 — undefined 면 기본 서버가 답한다 */
+type ExtraHandler = (request: MockRequest) => MockResult | Promise | undefined
+
+interface ServerOptions {
+ signature?: MailSignature
+ /** POST · PUT /drafts 의 응답 — 비면 d1, d2 … 로 성공 */
+ draftResults?: MockResult[]
+ /** GET /drafts/{id} */
+ draft?: Draft | MockResult
+ /** 그 밖의 요청 */
+ extra?: ExtraHandler
+}
+
+function isMockResult(value: Draft | MockResult): value is MockResult {
+ return typeof value === 'string' || 'status' in value
+}
+
+/**
+ * 쓰기 화면이 부르는 서버 — 임시 저장은 실은 첨부를 "v{n}" id 로 바꿔 돌려주고, 저장한 판을 GET /drafts/{id} 로 다시 내준다
+ * (PUT 하면 옛 판은 지운다 — 계약 2절 "임시 저장")
+ */
+function server(options: ServerOptions = {}): MockHandler {
+ let saved = 0
+ const results = [...(options.draftResults ?? [])]
+ const drafts = new Map()
+ return async (request) => {
+ const extra = await options.extra?.(request)
+ if (extra) return extra
+ const { method, url } = request
+ if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
+ if (method === 'GET' && url === '/mailboxes') {
+ return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
+ }
+ if (method === 'GET' && url === '/mail-signature') return ok(options.signature ?? NO_SIGNATURE)
+ if (method === 'GET' && url.startsWith('/drafts/')) {
+ const draft = options.draft ?? drafts.get(url.slice('/drafts/'.length))
+ if (!draft) return fail(404, 'RES_001')
+ return isMockResult(draft) ? draft : ok(draft)
+ }
+ if ((method === 'POST' && url === '/drafts') || (method === 'PUT' && url.startsWith('/drafts/'))) {
+ const next = results.shift()
+ if (next) return next
+ saved += 1
+ const body = request.data as Omit & {
+ attachments: { id: string; name: string }[]
+ bodyHtml?: string
+ inReplyTo?: string
+ }
+ const result = {
+ id: `d${saved}`,
+ attachments: body.attachments.map((item) => ({ ...item, id: `${item.id}-v${saved}`, type: 'application/pdf', size: 1 })),
+ updatedAt: SAVED_AT,
+ }
+ if (method === 'PUT') drafts.delete(url.slice('/drafts/'.length))
+ drafts.set(result.id, {
+ ...result,
+ to: body.to,
+ cc: body.cc,
+ bcc: body.bcc,
+ subject: body.subject,
+ body: body.body,
+ bodyHtml: body.bodyHtml ?? null,
+ inReplyTo: body.inReplyTo ?? null,
+ })
+ return ok(result, method === 'POST' ? 201 : 200)
+ }
+ if (method === 'POST' && url === '/mail-attachments') {
+ return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201)
+ }
+ if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
+ if (method === 'DELETE' && url.startsWith('/messages/')) return { status: 204 }
+ if (method === 'PATCH' && url.startsWith('/messages/')) return ok({ id: url.split('/').pop() })
+ return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
+ }
+}
+
+describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
+ 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)
+ clock = createManualClock()
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(new Date(SAVED_AT))
+ })
+
+ afterEach(() => {
+ for (const wrapper of mountedWrappers.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ useToastStore().clear()
+ restoreApiAdapter()
+ vi.useRealTimers()
+ })
+
+ async function mountCompose(path: string, handler: MockHandler, visited: readonly string[] = [], attach = false) {
+ const requests = installMockApi(handler)
+ const router = createTestRouter()
+ for (const earlier of visited) await router.push(earlier)
+ await router.push(path)
+ await router.isReady()
+ // 메일 화면에 들어오면 메일함을 받아 둔다 (MailLayout) — 휴지통 되돌리기가 임시보관함 id 를 쓴다
+ await useMailStore().ensureMailboxes()
+ 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 }
+ }
+
+ const draftCalls = (requests: MockRequest[]) =>
+ requests.filter((request) => request.url.startsWith('/drafts') && request.method !== 'GET')
+
+ function statusText(wrapper: VueWrapper): string {
+ return wrapper.get('#compose-draft-status').text()
+ }
+
+ /** 시간을 넘기고 응답까지 기다린다 */
+ async function wait(ms: number) {
+ clock.advance(ms)
+ await flushPromises()
+ await flushPromises()
+ }
+
+ function blocksUnload(): boolean {
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+ return event.defaultPrevented
+ }
+
+ describe('자동 저장 · 상태 글', () => {
+ it('아무것도 쓰지 않은 쓰기 화면은 저장하지 않는다 — 상태 글도 없다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server())
+ await wait(60_000)
+
+ expect(draftCalls(requests)).toEqual([])
+ expect(statusText(wrapper)).toBe('')
+ })
+
+ it('입력이 3초 멈추면 임시보관함에 저장한다 — "저장하는 중…" → "오후 3:12에 임시 저장됨"', async () => {
+ let held: ((result: MockResult) => void) | null = null
+ const { wrapper, requests } = await mountCompose(
+ '/mail/compose',
+ server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/drafts'
+ ? new Promise((resolve) => {
+ held = resolve
+ })
+ : undefined,
+ }),
+ )
+ await wrapper.get('#compose-subject').setValue('일정 공유')
+ editorOf(wrapper.element).commands.insertContent('안녕하세요')
+ await flushPromises()
+ await wait(2_999)
+ expect(draftCalls(requests)).toEqual([])
+
+ await wait(1)
+ expect(draftCalls(requests)[0]?.data).toEqual({
+ to: [],
+ cc: [],
+ bcc: [],
+ subject: '일정 공유',
+ body: '안녕하세요',
+ bodyHtml: expect.stringContaining('안녕하세요 '),
+ attachments: [],
+ })
+ expect(statusText(wrapper)).toBe('저장하는 중…')
+ expect(wrapper.get('#compose-draft-status').attributes('role')).toBe('status')
+
+ ;(held as ((result: MockResult) => void) | null)?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
+ await flushPromises()
+ expect(statusText(wrapper)).toBe(`${formatDraftSavedTime(SAVED_AT)}에 임시 저장됨`)
+ // 저장한 그대로다 — 창 닫기도 막지 않는다
+ expect(blocksUnload()).toBe(false)
+ })
+
+ it('형식이 틀린 주소는 저장 본문에서 뺀다 (서버가 저장본 전체를 거절하지 않게) — 칸에는 그대로', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server())
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr, kim@ara')
+ await input.trigger('blur')
+ await wait(3_000)
+
+ expect(draftCalls(requests)[0]?.data).toMatchObject({
+ to: [{ name: null, email: 'doyun.lee@arasystem.it.kr' }],
+ })
+ expect(wrapper.findAll('.recipient-chip')).toHaveLength(2)
+ })
+
+ it('다음 저장은 직전 id 로 PUT — 다른 곳에서 먼저 바뀌었으면(MAIL_008) 새로 저장하고 정보 상태 글을 보인다', async () => {
+ const { wrapper, requests } = await mountCompose(
+ '/mail/compose',
+ server({
+ draftResults: [
+ ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201),
+ fail(409, 'MAIL_008'),
+ ok({ id: 'd2', attachments: [], updatedAt: SAVED_AT }, 201),
+ ],
+ }),
+ )
+ await wrapper.get('#compose-subject').setValue('첫 저장')
+ await wait(3_000)
+ await wrapper.get('#compose-subject').setValue('두 번째')
+ await wait(10_000)
+
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
+ 'POST /drafts',
+ 'PUT /drafts/d1',
+ 'POST /drafts',
+ ])
+ expect(statusText(wrapper)).toBe(
+ `다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · ${formatDraftSavedTime(SAVED_AT)}`,
+ )
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('자동 저장이 실패하면 알림 없이 상태 글 + [다시 시도] — 누르면 다시 저장한다', async () => {
+ const { wrapper, requests } = await mountCompose(
+ '/mail/compose',
+ server({ draftResults: [fail(503, 'MAIL_001')] }),
+ )
+ await wrapper.get('#compose-subject').setValue('제목')
+ await wait(3_000)
+
+ expect(statusText(wrapper)).toBe('임시 저장하지 못했습니다')
+ expect(useToastStore().toasts).toEqual([])
+ expect(blocksUnload()).toBe(true)
+
+ await wrapper.get('#compose-draft-retry').trigger('click')
+ await flushPromises()
+ expect(draftCalls(requests)).toHaveLength(2)
+ expect(statusText(wrapper)).toBe(`${formatDraftSavedTime(SAVED_AT)}에 임시 저장됨`)
+ expect(wrapper.find('#compose-draft-retry').exists()).toBe(false)
+ })
+
+ it('「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다', async () => {
+ const { wrapper } = await mountCompose('/mail/compose', server({ draftResults: [fail(507, 'MAIL_003')] }))
+ await wrapper.get('#compose-subject').setValue('제목')
+
+ await wrapper.get('#compose-save-draft').trigger('click')
+ await flushPromises()
+
+ expect(statusText(wrapper)).toBe('임시 저장하지 못했습니다')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
+ })
+
+ it('저장할 때마다 첨부 id 가 바뀐다 — 보내기는 진행 중인 저장을 기다려 새 id 와 draftId 로 보낸다', async () => {
+ let held: ((result: MockResult) => void) | null = null
+ let puts = 0
+ const { wrapper, requests } = await mountCompose(
+ '/mail/compose',
+ server({
+ extra: (request) => {
+ if (request.method !== 'PUT') return undefined
+ puts += 1
+ return new Promise((resolve) => {
+ held = resolve
+ })
+ },
+ }),
+ )
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [new File(['x'], '회의록.pdf')], configurable: true })
+ await picker.trigger('change')
+ await flushPromises()
+ await wait(3_000)
+ expect(draftCalls(requests)[0]?.data).toMatchObject({ attachments: [{ id: 'up1', name: '회의록.pdf' }] })
+
+ // 두 번째 저장(PUT)이 나가 있는 동안 보내기를 누른다
+ await wrapper.get('#compose-subject').setValue('제목')
+ await wait(10_000)
+ expect(puts).toBe(1)
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(requests.some((request) => request.method === 'POST' && request.url === '/messages')).toBe(false)
+
+ ;(held as ((result: MockResult) => void) | null)?.(
+ ok({ id: 'd2', attachments: [{ id: 'up1-v2', name: '회의록.pdf', type: 'application/pdf', size: 1 }], updatedAt: SAVED_AT }),
+ )
+ await flushPromises()
+ await flushPromises()
+
+ const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
+ expect(send?.data).toMatchObject({
+ attachments: [{ id: 'up1-v2', name: '회의록.pdf' }],
+ draftId: 'd2',
+ })
+ })
+ })
+
+ describe('찾지 못한 첨부 (MAIL_004)', () => {
+ async function attachFile(wrapper: VueWrapper, name = '회의록.pdf') {
+ const picker = wrapper.get('input[type="file"]')
+ Object.defineProperty(picker.element, 'files', { value: [new File(['x'], name)], configurable: true })
+ await picker.trigger('change')
+ await flushPromises()
+ }
+
+ /** POST /drafts 를 붙잡아 두는 서버 — release 로 답한다 */
+ function holdingFirstSave() {
+ const control: { release: ((result: MockResult) => void) | null } = { release: null }
+ const handler = server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/drafts' && control.release === null
+ ? new Promise((resolve) => {
+ control.release = resolve
+ })
+ : undefined,
+ })
+ return { handler, control }
+ }
+
+ it('보내기가 기다린 저장이 첨부를 찾지 못하면(MAIL_004) 보내지 않는다 — 첨부 없이 나가지 않게 칸 안내 · 초점', async () => {
+ const { handler, control } = holdingFirstSave()
+ const { wrapper, requests } = await mountCompose('/mail/compose', handler, [], true)
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ await attachFile(wrapper)
+ await wait(3_000)
+ expect(control.release).not.toBeNull()
+
+ // 저장이 나가 있는 동안 보내기 — 보내기는 그 저장을 기다린다. 그 저장이 MAIL_004 로 끝난다
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ control.release?.(fail(400, 'MAIL_004'))
+ await flushPromises()
+ await flushPromises()
+
+ expect(requests.some((request) => request.method === 'POST' && request.url === '/messages')).toBe(false)
+ expect(wrapper.get('.compose__send').text()).toBe('보내기')
+ expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
+ expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004)
+ expect(document.activeElement?.textContent?.trim()).toBe('파일 첨부')
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('찾지 못한 첨부가 있는 동안은 저장하지 않는다 — 떠나면 묻고, 그 첨부를 빼면 다시 저장한다', async () => {
+ const { wrapper, router, requests } = await mountCompose(
+ '/mail/compose',
+ server({ draftResults: [fail(400, 'MAIL_004')] }),
+ )
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ await attachFile(wrapper)
+ await wait(3_000)
+ expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004)
+
+ // 더 써도 저장하지 않는다 — 첨부 없는 판이 옛 저장본을 지우지 않게 (「임시 저장」도 막는다)
+ await wrapper.get('#compose-subject').setValue('제목')
+ await wait(30_000)
+ expect(draftCalls(requests)).toHaveLength(1)
+ expect(wrapper.get('#compose-save-draft').attributes('disabled')).toBeDefined()
+
+ // 떠나면 저장하지 않고 묻는다
+ const leaving = router.push('/mail/inbox')
+ await flushPromises()
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeDefined()
+ expect(draftCalls(requests)).toHaveLength(1)
+ await wrapper.get('#compose-leave-stay').trigger('click')
+ await leaving
+ await flushPromises()
+
+ // 찾지 못한 첨부를 빼면 다시 저장한다
+ await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
+ await wait(3_000)
+ expect(draftCalls(requests)).toHaveLength(2)
+ expect(draftCalls(requests)[1]?.data).toMatchObject({ subject: '제목', attachments: [] })
+ })
+ })
+
+ describe('나가기 · 휴지통', () => {
+ it('바꾼 채 떠나면 먼저 저장하고 묻지 않고 간다 — 저장하는 동안 이동이 기다린다', async () => {
+ let held: ((result: MockResult) => void) | null = null
+ const { wrapper, router, requests } = await mountCompose(
+ '/mail/compose',
+ server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/drafts'
+ ? new Promise((resolve) => {
+ held = resolve
+ })
+ : undefined,
+ }),
+ )
+ await wrapper.get('#compose-subject').setValue('쓰던 메일')
+ expect(blocksUnload()).toBe(true)
+
+ const navigation = router.push('/mail/inbox')
+ await flushPromises()
+ expect(draftCalls(requests)).toHaveLength(1)
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
+
+ ;(held as ((result: MockResult) => void) | null)?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
+ await navigation
+ await flushPromises()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeUndefined()
+ })
+
+ describe('떠나기 전 저장의 시간 상한 (5초)', () => {
+ /** 처음 저장(POST)을 붙잡아 두는 서버 */
+ function holding() {
+ const control: { release: ((result: MockResult) => void) | null } = { release: null }
+ const handler = server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/drafts'
+ ? new Promise((resolve) => {
+ control.release = resolve
+ })
+ : undefined,
+ })
+ return { handler, control }
+ }
+
+ it('5초가 지나도 끝나지 않으면 묻는다 — 나가기를 고르면 떠나고, 저장은 뒤에서 이어져 주소를 건드리지 않는다', async () => {
+ const { handler, control } = holding()
+ const { wrapper, router, requests } = await mountCompose('/mail/compose', handler)
+ await wrapper.get('#compose-subject').setValue('쓰던 메일')
+
+ const navigation = router.push('/mail/inbox')
+ await flushPromises()
+ expect(draftCalls(requests)).toHaveLength(1)
+ await wait(4_999)
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeUndefined()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
+
+ await wait(1)
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeDefined()
+ expect(wrapper.get('#compose-leave-title').text()).toBe('저장하지 못한 내용이 있습니다')
+ await wrapper.get('#compose-leave-confirm').trigger('click')
+ await navigation
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+
+ control.release?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
+ await flushPromises()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(useMailStore().latestDraftId('d1')).toBe('d1')
+ })
+
+ it('묻는 동안 그 저장이 끝나면(잃을 것이 없어졌다) 그대로 떠난다', async () => {
+ const { handler, control } = holding()
+ const { wrapper, router } = await mountCompose('/mail/compose', handler)
+ await wrapper.get('#compose-subject').setValue('쓰던 메일')
+
+ const navigation = router.push('/mail/inbox')
+ await flushPromises()
+ await wait(5_000)
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeDefined()
+
+ control.release?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
+ await navigation
+ await flushPromises()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeUndefined()
+ })
+ })
+
+ it('휴지통 — 저장본이 없으면 그냥 닫는다 (쓰던 내용은 버리고 묻지 않는다)', async () => {
+ useMailStore().rememberBrowseLocation('/mail/inbox')
+ const { wrapper, router, requests } = await mountCompose('/mail/compose', server())
+ await wrapper.get('#compose-subject').setValue('버릴 메일')
+ const trash = wrapper.get('#compose-discard')
+ expect(trash.attributes('aria-label')).toBe('이 메일 삭제')
+
+ await trash.trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(requests.some((request) => request.method === 'DELETE' || request.method === 'PATCH')).toBe(false)
+ expect(draftCalls(requests)).toEqual([])
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('휴지통 — 저장본을 휴지통으로 옮기고(PATCH, 영구 삭제하는 DELETE 가 아니다) 닫는다. 알림의 [되돌리기]는 임시보관함으로', async () => {
+ useMailStore().rememberBrowseLocation('/mail/drafts')
+ const { wrapper, router, requests } = await mountCompose('/mail/compose', server())
+ const toasts = mount(ToastHost, { global: { plugins: [pinia, router] } })
+ mountedWrappers.push(toasts)
+ await wrapper.get('#compose-subject').setValue('버릴 메일')
+ await wait(3_000)
+
+ await wrapper.get('#compose-discard').trigger('click')
+ await flushPromises()
+
+ const patches = () => requests.filter((request) => request.method === 'PATCH')
+ expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
+ expect(patches().map((request) => [request.url, request.data])).toEqual([['/messages/d1', { mailboxId: 'trash' }]])
+ expect(router.currentRoute.value.fullPath).toBe('/mail/drafts')
+ expect(toasts.get('.toast__message').text()).toBe('임시 저장한 메일을 휴지통으로 옮겼습니다.')
+ const undo = toasts.get('button.toast__action')
+ expect(undo.text()).toBe('되돌리기')
+
+ await undo.trigger('click')
+ await flushPromises()
+ expect(patches().at(-1)?.url).toBe('/messages/d1')
+ expect(patches().at(-1)?.data).toEqual({ mailboxId: 'drafts' })
+ expect(toasts.get('.toast__message').text()).toBe('임시보관함으로 되돌렸습니다.')
+ })
+
+ it.each([
+ [
+ '목록은 받았는데 휴지통이 없으면 BIZ_001',
+ ok({ items: MAILBOXES.filter((mailbox) => mailbox.role !== 'trash'), pageInfo: { hasNext: false, nextCursor: null, total: 2 } }),
+ 'BIZ_001',
+ ],
+ ['메일함 목록을 못 받으면 그 오류', fail(503, 'MAIL_001'), 'MAIL_001'],
+ ] as const)('휴지통 — 휴지통을 끝내 모르면 지우지 않는다: %s 를 알리고 양식은 그대로 (성공 알림 없음)', async (_label, mailboxes, code) => {
+ const { wrapper, router, requests } = await mountCompose(
+ '/mail/compose',
+ server({ extra: (request) => (request.url === '/mailboxes' ? mailboxes : undefined) }),
+ )
+ await wrapper.get('#compose-subject').setValue('버릴 메일')
+ await wait(3_000)
+
+ await wrapper.get('#compose-discard').trigger('click')
+ await flushPromises()
+
+ expect(requests.some((request) => request.method === 'DELETE' || request.method === 'PATCH')).toBe(false)
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES[code]])
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d1')
+ expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('버릴 메일')
+ expect(wrapper.get('#compose-discard').attributes('disabled')).toBeUndefined()
+ })
+
+ it('휴지통 — 이미 없는 저장본(RES_001)이면 버린 것으로 보고 알림 · [되돌리기] 없이 닫는다', async () => {
+ useMailStore().rememberBrowseLocation('/mail/inbox')
+ const { wrapper, router } = await mountCompose(
+ '/mail/compose',
+ server({ extra: (request) => (request.method === 'PATCH' ? fail(404, 'RES_001') : undefined) }),
+ )
+ await wrapper.get('#compose-subject').setValue('버릴 메일')
+ await wait(3_000)
+
+ await wrapper.get('#compose-discard').trigger('click')
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('휴지통 — 저장본을 불러오는 동안은 누를 수 없다 (아직 저장본 id 를 몰라 그냥 닫히지 않게)', async () => {
+ let release: ((result: MockResult) => void) | null = null
+ const requests = installMockApi(
+ server({
+ extra: (request) =>
+ request.method === 'GET' && request.url === '/drafts/d7'
+ ? new Promise((resolve) => {
+ release = resolve
+ })
+ : undefined,
+ }),
+ )
+ const router = createTestRouter()
+ await router.push('/mail/compose?draft=d7')
+ await router.isReady()
+ const wrapper = mount(ComposePage, {
+ global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
+ })
+ mountedWrappers.push(wrapper)
+ await flushPromises()
+
+ const discard = wrapper.get('#compose-discard')
+ expect(discard.attributes('disabled')).toBeDefined()
+ await discard.trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d7')
+ expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
+ ;(release as ((result: MockResult) => void) | null)?.(fail(404, 'RES_001'))
+ await flushPromises()
+ })
+ })
+
+ describe('주소 맞추기 — 저장할 때마다 /mail/compose?draft=<새 id>', () => {
+ /** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
+ const RouterHost: FunctionalComponent = () => h('div')
+
+ const getRequests = (requests: MockRequest[], url: string) =>
+ requests.filter((request) => request.method === 'GET' && request.url === url)
+
+ it('답장으로 시작해 저장하면 주소가 ?draft=<새 id> 로 바뀌고 양식은 그대로 — 새로고침하면 그 저장본을 답장으로 이어 쓴다', async () => {
+ const handler = server()
+ const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', handler)
+ const editor = editorOf(wrapper.element)
+ editor.commands.insertContentAt(1, '확인했습니다.')
+ await flushPromises()
+ await wait(3_000)
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d1')
+ // 스스로 바꾼 주소로는 양식을 다시 채우지 않는다 — 원문도 저장본도 다시 받지 않고, 쓰던 글과 제목이 그대로다
+ expect(wrapper.get('h1').text()).toBe('답장')
+ expect(wrapper.get('#compose-body').text()).toContain('확인했습니다.')
+ expect(getRequests(requests, '/messages/m1')).toHaveLength(1)
+ expect(requests.some((request) => request.method === 'GET' && request.url.startsWith('/drafts/'))).toBe(false)
+ expect(wrapper.get('#compose-leave-dialog').attributes('open')).toBeUndefined()
+
+ // 다음 저장은 그 id 로 PUT — 주소도 새 id 로
+ editor.commands.insertContentAt(1, '네, ')
+ await flushPromises()
+ await wait(10_000)
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
+ 'POST /drafts',
+ 'PUT /drafts/d1',
+ ])
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d2')
+ wrapper.unmount()
+
+ // 새로고침 — 같은 주소로 다시 연다
+ const reloaded = await mountCompose('/mail/compose?draft=d2', handler)
+ expect(reloaded.wrapper.get('h1').text()).toBe('답장')
+ expect(reloaded.wrapper.get('#compose-body').text()).toContain('네, 확인했습니다.')
+ expect((reloaded.wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
+ 'Re: 메일 서버 이전 일정 공유',
+ )
+ // 이어 쓴 저장본은 답장할 메일을 기억한다 — 다시 저장해도 inReplyTo 그대로
+ editorOf(reloaded.wrapper.element).commands.insertContentAt(1, '추가 ')
+ await flushPromises()
+ await wait(3_000)
+ expect(draftCalls(reloaded.requests).at(-1)?.data).toMatchObject({ inReplyTo: 'm1' })
+ expect(draftCalls(reloaded.requests).at(-1)?.url).toBe('/drafts/d2')
+ })
+
+ it('떠나기 전 저장은 주소를 바꾸지 않고 그대로 떠난다 (가던 이동을 취소하지 않는다)', async () => {
+ const { wrapper, router, requests } = await mountCompose('/mail/compose', server())
+ await wrapper.get('#compose-subject').setValue('쓰던 메일')
+
+ await router.push('/mail/inbox')
+ await flushPromises()
+
+ expect(draftCalls(requests)).toHaveLength(1)
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(router.options.history.location).toBe('/mail/inbox')
+ })
+
+ it('떠나기 전 저장으로 바뀐 저장본 — 방문 기록의 옛 주소(?draft=<옛 id>)로 돌아오면 이 탭이 마지막으로 저장한 것을 연다', async () => {
+ const handler = server()
+ const { wrapper, router, requests } = await mountCompose('/mail/compose', handler)
+ await wrapper.get('#compose-subject').setValue('일정 공유')
+ await wait(3_000)
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d1')
+
+ // 더 쓰고 바로 떠난다 — 떠나기 전 저장(PUT d1 → d2)은 주소를 맞추지 못한 채 떠난다 (옛 d1 은 서버가 지웠다)
+ await wrapper.get('#compose-subject').setValue('일정 공유 (수정)')
+ await router.push('/mail/inbox')
+ await flushPromises()
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
+ 'POST /drafts',
+ 'PUT /drafts/d1',
+ ])
+ wrapper.unmount()
+
+ // 뒤로 가기로 쓰기 화면의 옛 주소에 돌아온다 (같은 탭 — 스토어가 그대로다)
+ const back = await mountCompose('/mail/compose?draft=d1', handler)
+ expect(
+ back.requests
+ .filter((request) => request.method === 'GET' && request.url.startsWith('/drafts/'))
+ .map((request) => request.url),
+ ).toEqual(['/drafts/d2'])
+ expect((back.wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('일정 공유 (수정)')
+ expect(back.router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d2')
+ expect(useToastStore().toasts).toEqual([])
+ // 주소를 바꿔 끼운 것으로 양식을 다시 채우지 않는다 — 다음 저장은 d2 로 PUT
+ await back.wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
+ await wait(3_000)
+ expect(draftCalls(back.requests).map((request) => `${request.method} ${request.url}`)).toEqual(['PUT /drafts/d2'])
+ })
+
+ it('보내는 중에 떠난 뒤 늦게 끝난 저장은 주소를 건드리지 않는다', async () => {
+ let held: ((result: MockResult) => void) | null = null
+ const { wrapper, router } = await mountCompose(
+ '/mail/compose',
+ server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/drafts'
+ ? new Promise((resolve) => {
+ held = resolve
+ })
+ : undefined,
+ }),
+ )
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ await wait(3_000)
+ // 저장이 나가 있는 동안 보내기를 누르고(그 저장을 기다린다) 다른 화면으로 간다 — 보내는 중이라 묻지 않는다
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ await router.push('/mail/sent')
+ mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
+ wrapper.unmount()
+
+ ;(held as ((result: MockResult) => void) | null)?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
+ await flushPromises()
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
+ expect(router.options.history.location).toBe('/mail/sent')
+ })
+ })
+
+ describe('보내는 중에 떠난 뒤 보내지 못하면', () => {
+ const RouterHost: FunctionalComponent = () => h('div')
+
+ it('보내려던 내용을 조용히 저장본으로 남긴다 — 마지막 자동 저장 뒤에 쓴 글도 (PUT 직전 id, 알림은 실패 알림뿐)', async () => {
+ let releaseSend: ((result: MockResult) => void) | null = null
+ const { wrapper, router, requests } = await mountCompose(
+ '/mail/compose',
+ server({
+ extra: (request) =>
+ request.method === 'POST' && request.url === '/messages'
+ ? new Promise((resolve) => {
+ releaseSend = resolve
+ })
+ : undefined,
+ }),
+ )
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+ await wrapper.get('#compose-subject').setValue('일정 공유')
+ await wait(3_000)
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /drafts'])
+
+ // 자동 저장 뒤에 더 쓰고 보낸다 — 보내는 중에 다른 화면으로 (묻지 않는다)
+ await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ await router.push('/mail/sent')
+ mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
+ wrapper.unmount()
+
+ ;(releaseSend as ((result: MockResult) => void) | null)?.(fail(503, 'MAIL_001'))
+ await flushPromises()
+ await flushPromises()
+
+ const kept = draftCalls(requests).at(-1)
+ expect(`${kept?.method} ${kept?.url}`).toBe('PUT /drafts/d1')
+ expect(kept?.data).toMatchObject({ subject: '일정 공유 (최종)', to: [{ email: 'doyun.lee@arasystem.it.kr' }] })
+ expect(kept?.data).not.toHaveProperty('draftId')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
+ expect(useMailStore().latestDraftId('d1')).toBe('d2')
+ expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
+ })
+ })
+
+ describe('본문이 한도를 넘으면', () => {
+ it('자동 저장을 보내지 않는다 — 상태 글은 실패, 본문 아래 MAIL_007 안내. 내용이 바뀔 때까지 다시 하지 않는다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server())
+ const editor = editorOf(wrapper.element)
+ editor.commands.setContent(`${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)} `)
+ await flushPromises()
+ await wait(3_000)
+
+ expect(draftCalls(requests)).toEqual([])
+ expect(statusText(wrapper)).toBe('임시 저장하지 못했습니다')
+ expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
+ expect(useToastStore().toasts).toEqual([])
+ await wait(120_000)
+ expect(draftCalls(requests)).toEqual([])
+
+ // 줄이면 안내를 내리고 다시 저장한다
+ editor.commands.setContent('줄였습니다 ')
+ await flushPromises()
+ expect(wrapper.find('#compose-body-error').exists()).toBe(false)
+ await wait(3_000)
+ expect(draftCalls(requests)).toHaveLength(1)
+ }, LONG_DRAFT_TIMEOUT_MS)
+ })
+
+ describe('쉬지 않고 쓸 때', () => {
+ it('입력이 멈추지 않아도 처음 바꾼 뒤 30초가 지나면 저장한다 — 10초 간격은 그대로', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server())
+ const subject = wrapper.get('#compose-subject')
+
+ // 2초마다 쳐서 3초 멈춤이 한 번도 없다
+ for (let second = 0; second < 30; second += 2) {
+ await subject.setValue(`제목 ${second}`)
+ await wait(2_000)
+ if (second + 2 < 30) expect(draftCalls(requests)).toHaveLength(0)
+ }
+ expect(draftCalls(requests)).toHaveLength(1)
+ expect(draftCalls(requests)[0]?.data).toMatchObject({ subject: '제목 28' })
+
+ // 계속 쓰면 그 뒤 처음 바꾼 때부터 다시 30초
+ for (let second = 30; second < 60; second += 2) {
+ await subject.setValue(`제목 ${second}`)
+ await wait(2_000)
+ }
+ expect(draftCalls(requests)).toHaveLength(2)
+ })
+ })
+
+ describe('이어 쓰기 (?draft=)', () => {
+ const DRAFT: Draft = {
+ id: 'd7',
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
+ cc: [{ name: null, email: 'infra@arasystem.it.kr' }],
+ bcc: [],
+ subject: 'Re: 메일 서버 이전 일정 공유',
+ body: '확인했습니다.',
+ bodyHtml: '',
+ attachments: [{ id: 'att-7', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }],
+ inReplyTo: 'm1',
+ updatedAt: '2026-10-26T01:00:00Z',
+ }
+
+ it('저장본으로 채우고 답장으로 연다 — 서명은 다시 넣지 않고, 고치면 그 id 로 PUT (inReplyTo 그대로)', async () => {
+ const { wrapper, requests } = await mountCompose(
+ '/mail/compose?draft=d7',
+ server({ draft: DRAFT, signature: SIGNATURE }),
+ )
+
+ expect(wrapper.get('h1').text()).toBe('답장')
+ expect(wrapper.get('#compose-body').text()).toBe('확인했습니다.')
+ expect(wrapper.find('#compose-body img[src]').exists()).toBe(false)
+ expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['체크리스트.xlsx'])
+ expect(statusText(wrapper)).toBe(`${formatDraftSavedTime(DRAFT.updatedAt)}에 임시 저장됨`)
+ // 불러온 그대로는 저장한 상태다
+ await wait(30_000)
+ expect(draftCalls(requests)).toEqual([])
+ expect(blocksUnload()).toBe(false)
+
+ editorOf(wrapper.element).commands.insertContent(' 감사합니다.')
+ await flushPromises()
+ await wait(3_000)
+ const put = draftCalls(requests)[0]
+ expect(put?.method).toBe('PUT')
+ expect(put?.url).toBe('/drafts/d7')
+ expect(put?.data).toMatchObject({
+ inReplyTo: 'm1',
+ attachments: [{ id: 'att-7', name: '체크리스트.xlsx' }],
+ cc: [{ name: null, email: 'infra@arasystem.it.kr' }],
+ })
+ })
+
+ it.each([
+ ['Fwd: 자료', '전달'],
+ ['새 제목', '새 메일'],
+ ])('답장할 메일이 없으면 제목으로 쓰기 종류를 정한다 — %s → %s', async (subject, title) => {
+ const { wrapper } = await mountCompose('/mail/compose?draft=d7', server({ draft: { ...DRAFT, subject, inReplyTo: null } }))
+ expect(wrapper.get('h1').text()).toBe(title)
+ })
+
+ it('우리가 실을 수 없는 첨부가 든 저장본(다른 메일 프로그램)은 지우지 않는다 — 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT', async () => {
+ const foreign: Draft = {
+ ...DRAFT,
+ id: 'outlook-1',
+ attachments: [
+ { id: 'att-exe', name: 'setup.exe', type: 'application/octet-stream', size: 2048 },
+ ...DRAFT.attachments,
+ ],
+ }
+ const { wrapper, router, requests } = await mountCompose('/mail/compose?draft=outlook-1', server({ draft: foreign }))
+ // 실을 수 없는 첨부는 지금처럼 실패로 보인다
+ expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.FILE_001)
+
+ editorOf(wrapper.element).commands.insertContent(' 감사합니다.')
+ await flushPromises()
+ await wait(3_000)
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /drafts'])
+ expect(draftCalls(requests)[0]?.data).toMatchObject({ attachments: [{ id: 'att-7', name: '체크리스트.xlsx' }] })
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d1')
+
+ editorOf(wrapper.element).commands.insertContent(' 또')
+ await flushPromises()
+ await wait(10_000)
+ expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
+ 'POST /drafts',
+ 'PUT /drafts/d1',
+ ])
+ })
+
+ it('실을 수 없는 첨부가 든 저장본을 저장하지 않고 보내면 원본을 가리키지 않는다 (draftId 없음 — 서버가 원본을 지우지 않게)', async () => {
+ const foreign: Draft = {
+ ...DRAFT,
+ id: 'outlook-1',
+ attachments: [{ id: 'att-exe', name: 'setup.exe', type: 'application/octet-stream', size: 2048 }],
+ }
+ const { wrapper, requests } = await mountCompose('/mail/compose?draft=outlook-1', server({ draft: foreign }))
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ await flushPromises()
+
+ const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
+ expect(send?.data).toMatchObject({ attachments: [] })
+ expect(send?.data).not.toHaveProperty('draftId')
+ })
+
+ it('없는 저장본이면(그사이 보냈거나 지웠다) 알리고 임시보관함으로 바꿔 끼운다', async () => {
+ const requests = installMockApi(server())
+ const router = createTestRouter()
+ await router.push('/mail/compose?draft=gone')
+ await router.isReady()
+ await useMailStore().ensureMailboxes()
+ const wrapper = mount(ComposePage, {
+ global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
+ })
+ mountedWrappers.push(wrapper)
+ await flushPromises()
+ await flushPromises()
+
+ expect(requests.some((request) => request.url === '/drafts/gone')).toBe(true)
+ expect(router.currentRoute.value.fullPath).toBe('/mail/drafts')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.RES_001])
+ })
+ })
+
+ describe('서명 (계약 2절 "서명")', () => {
+ it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
+ const paragraphs = wrapper.findAll('#compose-body p')
+
+ expect(paragraphs.map((paragraph) => paragraph.text())).toEqual(['', '', '김아라 · 인프라팀', ''])
+ expect(wrapper.get('#compose-body img[src]').attributes()).toMatchObject({
+ src: `/api/mail-signature/images/${IMAGE_ID}`,
+ width: '144',
+ })
+ await wait(30_000)
+ expect(draftCalls(requests)).toEqual([])
+ expect(blocksUnload()).toBe(false)
+ })
+
+ it('보내면 서명 그림이 서식 본문에 남는다 — 텍스트 대체 본문에는 그림이 없다', async () => {
+ const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
+ const input = wrapper.get('#compose-to')
+ await input.setValue('doyun.lee@arasystem.it.kr')
+ await input.trigger('blur')
+
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ await flushPromises()
+
+ const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
+ const data = send?.data as { body: string; bodyHtml: string }
+ expect(data.body).toBe('\n\n김아라 · 인프라팀')
+ expect(data.bodyHtml).toContain(` `)
+ })
+
+ it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 · 인용', async () => {
+ const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server({ signature: SIGNATURE }))
+ const doc = editorOf(wrapper.element).state.doc
+ const blocks: string[] = []
+ doc.forEach((node) => blocks.push(node.type.name === 'blockquote' ? '인용' : node.textContent))
+ expect(blocks.slice(0, 5)).toEqual(['', '', '김아라 · 인프라팀', '', '인용'])
+ })
+
+ it('답장 · 전달에 넣지 않기로 했으면 넣지 않는다', async () => {
+ const { wrapper } = await mountCompose(
+ '/mail/compose?replyTo=m1',
+ server({ signature: { ...SIGNATURE, isForReply: false } }),
+ )
+ expect(wrapper.find('#compose-body img[src]').exists()).toBe(false)
+ expect(wrapper.get('#compose-body').text()).not.toContain('김아라 · 인프라팀')
+ })
+
+ it('서명을 못 받아도 서명 없이 연다 — 알리지 않는다', async () => {
+ const { wrapper } = await mountCompose(
+ '/mail/compose',
+ server({ extra: (request) => (request.url === '/mail-signature' ? fail(503, 'MAIL_001') : undefined) }),
+ )
+ expect(wrapper.get('#compose-body').text()).toBe('')
+ expect(useToastStore().toasts).toEqual([])
+ })
+ })
+})
diff --git a/frontend/src/pages/mail/ComposePage.spec.ts b/frontend/src/pages/mail/ComposePage.spec.ts
index 5844bc7..ee458cf 100644
--- a/frontend/src/pages/mail/ComposePage.spec.ts
+++ b/frontend/src/pages/mail/ComposePage.spec.ts
@@ -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): Record {
+ 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()
diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue
index 4015e9f..27b5b72 100644
--- a/frontend/src/pages/mail/ComposePage.vue
+++ b/frontend/src/pages/mail/ComposePage.vue
@@ -1,38 +1,73 @@
diff --git a/frontend/src/pages/settings/MailSignaturePage.spec.ts b/frontend/src/pages/settings/MailSignaturePage.spec.ts
new file mode 100644
index 0000000..08d902e
--- /dev/null
+++ b/frontend/src/pages/settings/MailSignaturePage.spec.ts
@@ -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: '',
+ 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
+}
+
+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, 'ARA SYSTEM ')
+ 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(/^/),
+ 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) => `  `
+ const withTwo: MailSignature = {
+ ...SIGNATURE,
+ bodyHtml: ` ${imageTag(IMAGE_ID)}${imageTag(IMAGE_ID)}${imageTag(SECOND_ID)} `,
+ 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, ` ${text} `)
+ 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 ((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('답장 · 전달에는 서명을 넣지 않습니다.')
+ })
+})
diff --git a/frontend/src/pages/settings/MailSignaturePage.vue b/frontend/src/pages/settings/MailSignaturePage.vue
new file mode 100644
index 0000000..977cdaa
--- /dev/null
+++ b/frontend/src/pages/settings/MailSignaturePage.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+ {{ SIGNATURE_TEXT.title }}
+
+
+ {{ SIGNATURE_TEXT.description }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/SettingsLayout.vue b/frontend/src/pages/settings/SettingsLayout.vue
new file mode 100644
index 0000000..76f4bf4
--- /dev/null
+++ b/frontend/src/pages/settings/SettingsLayout.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 9ec9bc5..028c8be 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -48,6 +48,21 @@ const routes: RouteRecordRaw[] = [
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
},
+ {
+ // 설정 — 왼쪽 메뉴(메일 › 서명) + 하위 화면. /settings 는 첫 메뉴로 바꿔 끼운다 (앱 레일의 "설정")
+ path: '/settings',
+ component: () => import('@/pages/settings/SettingsLayout.vue'),
+ meta: { requiresAuth: true, title: '설정' },
+ children: [
+ { path: '', name: 'settings', redirect: { name: 'settings-mail-signature' } },
+ {
+ path: 'mail/signature',
+ name: 'settings-mail-signature',
+ component: () => import('@/pages/settings/MailSignaturePage.vue'),
+ meta: { title: '메일 서명' },
+ },
+ ],
+ },
{
path: '/:pathMatch(.*)*',
name: 'not-found',
diff --git a/frontend/src/shared/constants/app-modules.ts b/frontend/src/shared/constants/app-modules.ts
index 318a04e..85baa99 100644
--- a/frontend/src/shared/constants/app-modules.ts
+++ b/frontend/src/shared/constants/app-modules.ts
@@ -26,7 +26,7 @@ export const APP_MODULES: readonly AppModule[] = [
{ id: 'files', label: '파일', icon: 'folder', routeName: null, placement: 'main' },
{ id: 'erp', label: 'ERP', icon: 'briefcase', routeName: null, placement: 'main' },
{ id: 'contacts', label: '주소록', icon: 'users', routeName: null, placement: 'main' },
- { id: 'settings', label: '설정', icon: 'settings', routeName: null, placement: 'end' },
+ { id: 'settings', label: '설정', icon: 'settings', routeName: 'settings', placement: 'end' },
]
/** 비활성 모듈에 붙이는 안내 (툴팁·스크린리더) */
diff --git a/frontend/src/shared/constants/icons.ts b/frontend/src/shared/constants/icons.ts
index 1d7ef5a..6f3850d 100644
--- a/frontend/src/shared/constants/icons.ts
+++ b/frontend/src/shared/constants/icons.ts
@@ -117,6 +117,12 @@ export const ICONS = {
forward: [polyline('15 14 20 9 15 4'), path('M4 20v-7a4 4 0 0 1 4-4h12')],
shield: [path('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z')],
'alert-circle': [circle(12, 12, 10), ...ALERT_DOT],
+ /** 안내 — 임시 저장 "다른 곳에서 바뀐 저장본" (디자인 원본 ComposeAssist) */
+ info: [circle(12, 12, 10), line(12, 16, 12, 12), line(12, 8, 12.01, 8)],
+ /** 진행 중 — 임시 저장 "저장하는 중…" (원의 3/4) */
+ loader: [path('M21 12a9 9 0 1 1-6.22-8.56')],
+ /** 그림 넣기 — 서명 칸 툴바 (디자인 원본 Settings) */
+ image: [rect(3, 3, 18, 18, 2), circle(8.5, 8.5, 1.5), polyline('21 15 16 10 5 21')],
x: [line(18, 6, 6, 18), line(6, 6, 18, 18)],
download: [
path('M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'),
diff --git a/frontend/src/shared/constants/mail-signature.ts b/frontend/src/shared/constants/mail-signature.ts
new file mode 100644
index 0000000..eae99c4
--- /dev/null
+++ b/frontend/src/shared/constants/mail-signature.ts
@@ -0,0 +1,101 @@
+// 메일 서명 상수 — 계약 2절 "서명" · 디자인 README "2단계 › 서명" (디자인 원본 Settings)
+// 한도는 서버(backend/src/modules/mail-signature/mail-signature.constants.ts)와 같은 값이다 — 서버가 최종 검증한다.
+
+import type { ErrorCode } from '@/shared/constants/error-codes.generated'
+import type { SignatureImageType } from '@/shared/types/mail'
+
+/** 서명 그림 한 장 최대 크기 — 300KB (서버 SIGNATURE_IMAGE_MAX_BYTES = 300 × 1024, 넘으면 413 FILE_002) */
+export const SIGNATURE_IMAGE_MAX_BYTES = 300 * 1024
+/**
+ * 서명에 넣을 수 있는 그림 수 — 서명 칸에 있는 서로 다른 그림으로 센다(뺀 그림은 세지 않는다).
+ * 넘으면 서명 저장이 400 VAL_001 — 클라이언트가 그림을 넣기 전에 먼저 막는다.
+ * (올려 두기만 한 그림까지 한 사람 10장은 서버가 따로 막는다 — 올리기가 400 VAL_001, 알림은 인터셉터)
+ */
+export const SIGNATURE_IMAGE_MAX_COUNT = 3
+/** 서명 서식(HTML) 최대 길이 — 서버는 정화한 뒤 잰다(넘으면 400 VAL_001). 보내기 전에 같은 길이로 먼저 막는다 */
+export const SIGNATURE_HTML_MAX_LENGTH = 10_000
+/** 텍스트 서명 최대 길이 (JS 문자열 길이) */
+export const SIGNATURE_TEXT_MAX_LENGTH = 2_000
+
+/** 받는 그림 형식 — 확장자와 MIME 이 이 짝이어야 한다 (서버는 파일 앞부분까지 본다. WebP · SVG 는 받지 않는다) */
+export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: Readonly> = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+}
+/** 값 */
+export const SIGNATURE_IMAGE_ACCEPT = Object.keys(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
+ .map((extension) => `.${extension}`)
+ .join(',')
+
+/**
+ * 그림 크기 줄 (디자인 원본 Settings) — 작게 96 · 보통 144 · 원본(원래 폭, 600 이하). 새로 넣는 그림은 보통(원래 폭이 더 작으면 원래 폭).
+ * 원본 상한은 서버가 남기는 width(1~600)와 같다 (outgoing-html-rules.ts 의 IMAGE_MAX_WIDTH).
+ */
+export const SIGNATURE_IMAGE_SIZES = {
+ small: 96,
+ medium: 144,
+} as const
+export type SignatureImageSize = keyof typeof SIGNATURE_IMAGE_SIZES | 'original'
+
+/** 그림 올리기의 실패 중 서명 칸 아래에 직접 보이는 코드 (형식 · 크기) — 전역 알림은 끈다. 10장 초과(VAL_001)는 알림으로 */
+export const SIGNATURE_IMAGE_INLINE_CODES: readonly ErrorCode[] = ['FILE_001', 'FILE_002']
+/** 서명 저장의 실패 중 서명 칸 아래에 직접 보이는 코드 (길이 · 그림 3장 초과) — 전역 알림은 끈다 */
+export const SIGNATURE_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
+
+/** 서명을 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) */
+export const SIGNATURE_LEAVE_DIALOG = {
+ title: '저장하지 않은 서명이 있습니다',
+ message: '이 화면을 나가면 바꾼 서명이 사라집니다.',
+ stay: '계속 고치기',
+ leave: '나가기',
+} as const
+
+/** 설정 화면 문구 (디자인 원본 Settings) */
+export const SIGNATURE_TEXT = {
+ title: '서명',
+ description:
+ '보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
+ fieldLabel: '서명 내용',
+ hint:
+ '굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 ' +
+ '붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
+ usageLegend: '서명을 넣을 메일',
+ forNew: '새 메일',
+ forReply: '답장 · 전달',
+ forReplyNote: '— 인용한 원문 위에 넣습니다',
+ save: '저장',
+ saving: '저장하는 중…',
+ saved: '저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.',
+ uploading: '그림을 올리는 중…',
+ imageSizeLabel: '그림 크기',
+ imageSizes: { small: '작게', medium: '보통', original: '원본' },
+ removeImage: '그림 빼기',
+ previewTitle: '미리 보기',
+ previewSwitchLabel: '미리 볼 메일',
+ previewNew: '새 메일',
+ previewReply: '답장',
+ previewBody: '(여기에 쓴 내용)',
+} as const
+
+/** 미리 보기 아래 안내 (디자인 원본 Settings) */
+export const SIGNATURE_PREVIEW_NOTES = {
+ newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
+ newOff: '새 메일에는 서명을 넣지 않습니다.',
+ replyOn: '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.',
+ replyOff: '답장 · 전달에는 서명을 넣지 않습니다.',
+} as const
+
+/**
+ * 답장 미리 보기의 원문 인용 — 실제 메일이 아니라 자리만 보인다 (캔버스의 예시 이름 대신 괄호 글 — 2026-10-27 구현하며 정함).
+ * 머리 줄 이름은 쓰기 화면의 원문 인용과 같다 (mail-quote.ts).
+ */
+export const SIGNATURE_PREVIEW_QUOTE = {
+ rows: [
+ { label: '보낸 사람', value: '(보낸 사람)' },
+ { label: '날짜', value: '(보낸 날짜)' },
+ { label: '제목', value: '(원래 제목)' },
+ ],
+ body: '(원래 메일 내용)',
+} as const
diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts
index 21fa9c3..4142477 100644
--- a/frontend/src/shared/constants/mail.ts
+++ b/frontend/src/shared/constants/mail.ts
@@ -1,5 +1,6 @@
// 메일 화면·검증 상수 — 한도 값은 docs/mail/api-contract.md 와 같다 (서버가 최종 검증한다)
+import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { IconName } from '@/shared/constants/icons'
import type { MailboxRole } from '@/shared/types/mail'
@@ -132,17 +133,63 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
+/** 임시 저장본을 불러오는 동안의 쓰기 화면 제목 — 불러오면 답장 · 전달 · 새 메일로 바뀐다 */
+export const COMPOSE_LOADING_TITLE = '메일 쓰기'
+
/**
- * 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
- * [계속 쓰기]가 처음 초점이고 Esc 와 같다.
+ * 쓰기 화면 나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인") — 떠나기 전에 남은 변경을 먼저 저장하고,
+ * 저장하지 못한 내용이 있을 때만 묻는다. [계속 쓰기]가 처음 초점이고 Esc 와 같다.
*/
export const COMPOSE_LEAVE_DIALOG = {
- title: '보내지 않은 메일이 있습니다',
- message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
+ title: '저장하지 못한 내용이 있습니다',
+ message: '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.',
stay: '계속 쓰기',
leave: '나가기',
} as const
+// ── 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장") ─────────────
+/** 입력이 이만큼 멈추면 자동 저장한다 */
+export const DRAFT_IDLE_SAVE_MS = 3_000
+/** 자동 저장 사이의 최소 간격 — 10초에 한 번까지 */
+export const DRAFT_MIN_SAVE_INTERVAL_MS = 10_000
+/**
+ * 쉬지 않고 쓰는 동안에도 저장한다 — 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 이만큼 지나면
+ * 입력이 멈추기를 기다리지 않는다 (몇 분을 쉬지 않고 쓰다 브라우저가 죽어도 잃는 것이 이만큼이다). 10초 최소 간격은 그대로다
+ */
+export const DRAFT_MAX_WAIT_MS = 30_000
+/**
+ * 떠나기 전 저장(진행 중인 저장 기다리기 + 남은 변경 저장)을 기다리는 시간의 상한 — 넘으면 "저장하지 못한 내용이 있습니다"를 묻는다.
+ * 큰 첨부가 든 저장은 몇십 초씩 걸릴 수 있어 그동안 이동이 멈춘 것처럼 보이지 않게 한다. 저장은 뒤에서 이어진다
+ */
+export const DRAFT_LEAVE_SAVE_WAIT_MS = 5_000
+/**
+ * 자동 저장이 실패했을 때 다시 해 보기까지 — 실패할수록 길게, 마지막 값에서 멈춘다.
+ * 다시 해 보면 나아질 수 있는 실패(DRAFT_RETRY_CODES)에만 쓴다 — 그 밖은 내용을 바꾸면 다시 저장한다
+ */
+export const DRAFT_RETRY_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000]
+/** 저절로 다시 해 볼 실패 — 메일 서버 장애 · 메일함 용량 · 요청 한도 · 네트워크 */
+export const DRAFT_RETRY_CODES: readonly ErrorCode[] = ['MAIL_001', 'MAIL_003', 'REQ_001', 'SYS_001']
+
+/** 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist) — 시각은 formatDraftSavedTime */
+export const DRAFT_STATUS_TEXT = {
+ saving: '저장하는 중…',
+ saved: (time: string) => `${time}에 임시 저장됨`,
+ failed: '임시 저장하지 못했습니다',
+ retry: '다시 시도',
+ replaced: (time: string) => `다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · ${time}`,
+} as const
+
+/** 쓰기 화면 머리의 휴지통 단추 · 버린 뒤 알림 (디자인 원본 ComposeAssist "임시 저장본 삭제") */
+export const DRAFT_DISCARD_LABEL = '이 메일 삭제'
+export const DRAFT_DISCARDED_MESSAGE = '임시 저장한 메일을 휴지통으로 옮겼습니다.'
+export const DRAFT_UNDO_LABEL = '되돌리기'
+/** 되돌리기가 끝났을 때 — 캔버스에는 없다 (2026-10-27 구현하며 정함 — 되돌린 것을 알 수 있게) */
+export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.'
+
+/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
+export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
+export const MAIL_NO_SUBJECT = '(제목 없음)'
+
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts
index bae2ef3..5f99c73 100644
--- a/frontend/src/shared/types/mail.ts
+++ b/frontend/src/shared/types/mail.ts
@@ -45,6 +45,15 @@ export interface Attachment {
size: number
}
+/** 메일 상세의 첨부 — 본문 안에 그림으로 들어간 것인지 함께 온다 (계약 2절 "MessageAttachment") */
+export interface MessageAttachment extends Attachment {
+ /**
+ * 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다.
+ * 전달할 때는 원문 그림을 잃지 않게 함께 붙인다 (인용 본문에서는 그림이 빠진다). 필드가 없으면(이전 서버) false 로 본다
+ */
+ isInline?: boolean
+}
+
export interface MessageDetail extends MessageSummary {
cc: Address[]
bcc: Address[]
@@ -68,7 +77,8 @@ export interface MessageDetail extends MessageSummary {
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
*/
isBodyTruncated?: boolean
- attachments: Attachment[]
+ /** 본문 그림(isInline)도 함께 온다 — 읽기 화면은 listedAttachments 로 거른다 */
+ attachments: MessageAttachment[]
}
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
@@ -117,9 +127,100 @@ export interface SendMessagePayload {
attachments: AttachmentRef[]
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
inReplyTo?: string
+ /** 이어 쓴 임시 저장본 id — 보낸 뒤 서버가 그 초안을 지운다 (계약 2절 "임시 저장") */
+ draftId?: string
}
/** POST /messages 응답 — 보낸편지함에 저장된 메일 id */
export interface SendMessageResult {
id: string
}
+
+// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
+
+/** GET /drafts/{id} — 이어 쓸 임시 저장본 */
+export interface Draft {
+ /** 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다 */
+ id: string
+ to: Address[]
+ cc: Address[]
+ bcc: Address[]
+ subject: string
+ /** 텍스트 대체 본문 */
+ body: string
+ /** 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null) */
+ bodyHtml: string | null
+ /** 이 판의 첨부 — id 가 저장할 때마다 바뀐다 */
+ attachments: Attachment[]
+ /** 답장할 메일 id (없거나 원 메일이 사라졌으면 null) */
+ inReplyTo: string | null
+ /** 마지막으로 저장한 시각 */
+ updatedAt: string
+}
+
+/** POST /drafts · PUT /drafts/{id} 본문 — 발송 본문과 같은 필드 · 한도 (받는 사람 0명 가능, draftId 없음) */
+export type DraftPayload = Omit
+
+/** POST /drafts · PUT /drafts/{id} 응답 — 새 id 와 그 판의 첨부 */
+export interface DraftSaved {
+ id: string
+ attachments: Attachment[]
+ updatedAt: string
+}
+
+// ── 서명 (계약 2절 "서명") ─────────────────────────────
+
+export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
+
+export interface SignatureImage {
+ id: string
+ type: SignatureImageType
+ /** 바이트 */
+ size: number
+ /** 원본 픽셀 */
+ width: number
+ height: number
+}
+
+/** GET · PUT /mail-signature — 사용자마다 하나 */
+export interface MailSignature {
+ /** 서명 서식 — 서식 본문 규칙 + 서명 그림 */
+ bodyHtml: string | null
+ /** 텍스트 서명 (텍스트 대체 본문에 넣는다) */
+ body: string | null
+ /** 새 메일에 넣는다 */
+ isForNew: boolean
+ /** 답장 · 전달에 넣는다 — 인용 위 */
+ isForReply: boolean
+ /** 서명이 쓰는 그림 */
+ images: SignatureImage[]
+ /** 저장한 적 없으면 null */
+ updatedAt: string | null
+}
+
+/** PUT /mail-signature 본문 */
+export interface SaveSignaturePayload {
+ bodyHtml: string | null
+ body: string | null
+ isForNew: boolean
+ isForReply: boolean
+}
+
+// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ────────────────
+
+/** directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소 */
+export type RecipientSuggestionSource = 'directory' | 'recent'
+
+export interface RecipientSuggestion {
+ name: string | null
+ email: string
+ source: RecipientSuggestionSource
+}
+
+/** GET /recipient-suggestions 쿼리 */
+export interface RecipientSuggestionQuery {
+ /** 1~100자 — 이름 · 주소에 이 글자가 든 것만 */
+ q?: string
+ limit?: number
+ cursor?: string
+}
diff --git a/frontend/src/shared/utils/compose-changes.ts b/frontend/src/shared/utils/compose-changes.ts
index 1fd14a6..9f7ac45 100644
--- a/frontend/src/shared/utils/compose-changes.ts
+++ b/frontend/src/shared/utils/compose-changes.ts
@@ -1,5 +1,6 @@
-// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
-// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
+// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "2단계 › 임시 저장")
+// 마지막으로 임시 저장한 양식(저장한 적 없으면 쓰기 화면을 다 연 때 — 답장 · 전달 초안을 채운 뒤)을 찍어 두고,
+// 지금 양식과 다르면 자동 저장하고 떠나기 전에 저장한다 (저장하지 못하면 묻는다).
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
import type { RecipientToken } from '@/shared/utils/mail-address'
@@ -10,7 +11,7 @@ export interface ComposeFormSnapshot {
cc: readonly string[]
bcc: readonly string[]
subject: string
- /** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
+ /** 첨부 목록 항목의 키 — 쓰기 화면은 저장본에 싣는 첨부(올리기를 마친 것 · 원본 첨부)만 넘긴다 */
attachments: readonly number[]
}
diff --git a/frontend/src/shared/utils/format.spec.ts b/frontend/src/shared/utils/format.spec.ts
index de88559..17bfd55 100644
--- a/frontend/src/shared/utils/format.spec.ts
+++ b/frontend/src/shared/utils/format.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
+ formatDraftSavedTime,
formatFileSize,
formatMailDateTime,
formatMailListTime,
@@ -19,6 +20,19 @@ const SEOUL = 'Asia/Seoul'
/** 2026-10-26(월) 오후 3시 (서울) */
const NOW = new Date('2026-10-26T06:00:00Z')
+describe('formatDraftSavedTime (임시 저장 상태 글)', () => {
+ it('오늘은 시각만 — "오후 3:12"', () => {
+ expect(formatDraftSavedTime('2026-10-26T06:12:00Z', NOW, SEOUL)).toBe('오후 3:12')
+ })
+
+ it('어제 · 올해 · 그 이전은 날짜를 붙인다 (이어 쓰는 저장본은 다른 날 저장했을 수 있다)', () => {
+ expect(formatDraftSavedTime('2026-10-25T01:00:00Z', NOW, SEOUL)).toBe('어제 오전 10:00')
+ expect(formatDraftSavedTime('2026-10-24T05:30:00Z', NOW, SEOUL)).toBe('10월 24일 오후 2:30')
+ expect(formatDraftSavedTime('2025-10-24T05:30:00Z', NOW, SEOUL)).toBe('2025. 10. 24. 오후 2:30')
+ expect(formatDraftSavedTime('not-a-date', NOW, SEOUL)).toBe('')
+ })
+})
+
describe('formatMailListTime', () => {
it('오늘 받은 메일은 시각만 보인다', () => {
expect(formatMailListTime('2026-10-26T01:42:00Z', NOW, SEOUL)).toBe('오전 10:42')
diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts
index 91acc02..84685c8 100644
--- a/frontend/src/shared/utils/format.ts
+++ b/frontend/src/shared/utils/format.ts
@@ -186,6 +186,30 @@ export function formatMailTableDate(value: string, now: Date = new Date(), timeZ
return target.year === today.year ? monthDay : `${target.year}.${monthDay}`
}
+/**
+ * 임시 저장 시각 — 쓰기 화면 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist: "오후 3:12에 임시 저장됨")
+ * - 오늘: "오후 3:12"
+ * - 어제: "어제 오후 3:12"
+ * - 올해: "10월 24일 오후 3:12"
+ * - 그 이전: "2025. 10. 24. 오후 3:12"
+ * 이어 쓰는 저장본은 다른 날 저장했을 수 있어 날짜를 붙인다.
+ */
+export function formatDraftSavedTime(value: string, now: Date = new Date(), timeZone?: string): string {
+ const date = parseDate(value)
+ if (!date) return ''
+
+ const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
+ const target = calendarDayOf(date, timeZone)
+ const today = calendarDayOf(now, timeZone)
+ if (isSameDay(target, today)) return time
+ if (isSameDay(target, previousDay(today))) return `어제 ${time}`
+ const dayOptions: Intl.DateTimeFormatOptions =
+ target.year === today.year
+ ? { timeZone, month: 'long', day: 'numeric' }
+ : { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }
+ return `${new Intl.DateTimeFormat('ko-KR', dayOptions).format(date)} ${time}`
+}
+
/** 메일 본문 머리의 날짜 (디자인 원본: "2026. 10. 26. (월) 오전 10:42") */
export function formatMailDateTime(value: string, timeZone?: string): string {
const date = parseDate(value)
diff --git a/frontend/src/shared/utils/keyboard-shortcut.ts b/frontend/src/shared/utils/keyboard-shortcut.ts
index 725e3eb..d4a5206 100644
--- a/frontend/src/shared/utils/keyboard-shortcut.ts
+++ b/frontend/src/shared/utils/keyboard-shortcut.ts
@@ -45,3 +45,14 @@ export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText
const rest = keys.filter((key) => !APPLE_MODIFIERS.some(([modifier]) => modifier === key))
return { label: [...modifiers, ...rest].join(''), aria }
}
+
+/** 입력기가 처리 중인 키의 keyCode — Safari · macOS 한글 입력은 조합을 끝내는 Enter 를 isComposing 없이 이 값으로 보낸다 */
+const IME_PROCESS_KEY_CODE = 229
+
+/**
+ * 한글 등 입력기가 글자를 조합하는 중의 키인가 — 이때의 Enter · Tab 은 글자 확정이다 (토큰 · 추천 · 제출로 쓰지 않는다).
+ * isComposing 만 보면 Safari · macOS 에서 조합을 끝내는 Enter 가 새어 든다 (keyCode 229 로 온다)
+ */
+export function isImeComposing(event: KeyboardEvent): boolean {
+ return event.isComposing || event.keyCode === IME_PROCESS_KEY_CODE
+}
diff --git a/frontend/src/shared/utils/mail-address.ts b/frontend/src/shared/utils/mail-address.ts
index 80181da..61aeb72 100644
--- a/frontend/src/shared/utils/mail-address.ts
+++ b/frontend/src/shared/utils/mail-address.ts
@@ -58,6 +58,11 @@ export function summarizeRecipients(list: readonly Address[]): string {
return rest.length > 0 ? `${displayName(first)} 외 ${rest.length}명` : displayName(first)
}
+/** 임시보관함 목록의 받는 사람 — 이름(없으면 주소)을 쉼표로 잇는다 ("박준호, 인프라팀"). 없으면 빈 문자열 */
+export function draftRecipientNames(list: readonly Address[]): string {
+ return list.map(displayName).filter((name) => name !== '').join(', ')
+}
+
/** 받는 사람 칸의 토큰 하나 */
export interface RecipientToken {
/** 화면에 보이는 글자 — 입력한 그대로 */
diff --git a/frontend/src/shared/utils/mail-attachment.spec.ts b/frontend/src/shared/utils/mail-attachment.spec.ts
index 9c5df45..ce161a3 100644
--- a/frontend/src/shared/utils/mail-attachment.spec.ts
+++ b/frontend/src/shared/utils/mail-attachment.spec.ts
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import { BYTES_PER_MB, MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
+import type { MessageAttachment } from '@/shared/types/mail'
import {
attachmentHint,
extensionOf,
isAllowedAttachmentName,
+ listedAttachments,
precheckAttachment,
type AttachedSummary,
type AttachmentLimits,
@@ -18,6 +20,18 @@ const FORWARDED: AttachmentLimits = { maxBytes: null, totalMaxBytes: TOTAL, maxC
const NONE: AttachedSummary = { count: 0, bytes: 0 }
const bytes = (value: number): AttachedSummary => ({ count: 1, bytes: value })
+describe('listedAttachments — 읽기 화면의 첨부 목록', () => {
+ it('본문 안에 그림으로 보인 첨부(isInline)만 뺀다 — 표시 값이 없으면 첨부로 본다', () => {
+ const attachments: MessageAttachment[] = [
+ { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false },
+ { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true },
+ // 표시 값을 주지 않는(이전) 서버
+ { id: 'a3', name: '자료.zip', type: 'application/zip', size: 4096 },
+ ]
+ expect(listedAttachments(attachments).map((attachment) => attachment.id)).toEqual(['a1', 'a3'])
+ })
+})
+
describe('precheckAttachment', () => {
it('허용하지 않는 확장자는 FILE_001', () => {
expect(precheckAttachment({ name: '설치.exe', size: 10 }, UPLOAD, NONE)).toBe('FILE_001')
diff --git a/frontend/src/shared/utils/mail-attachment.ts b/frontend/src/shared/utils/mail-attachment.ts
index 7476868..2bd2200 100644
--- a/frontend/src/shared/utils/mail-attachment.ts
+++ b/frontend/src/shared/utils/mail-attachment.ts
@@ -6,6 +6,15 @@ import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_ATTACHMENT_EXTENSIONS, MAIL_ATTACHMENT_KINDS_LABEL } from '@/shared/constants/mail'
import { formatMegabytes } from '@/shared/utils/format'
+/**
+ * 읽기 화면의 첨부 목록에 보일 첨부 — 본문 안에 그림으로 들어간 것(isInline)은 뺀다 (계약 2절 "MessageAttachment").
+ * 서명 그림 · 받은 메일의 본문 그림은 본문에 보이므로 첨부가 아니다. 필드가 없으면(이전 서버) 첨부로 본다.
+ * 전달은 이것을 쓰지 않는다 — 인용에서 그림이 빠지므로 원문 그림까지 모두 붙인다 (mail-forward.ts).
+ */
+export function listedAttachments(attachments: readonly T[]): T[] {
+ return attachments.filter((attachment) => attachment.isInline !== true)
+}
+
export function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
diff --git a/frontend/src/shared/utils/mail-forward.spec.ts b/frontend/src/shared/utils/mail-forward.spec.ts
index bafe5b8..a6471b1 100644
--- a/frontend/src/shared/utils/mail-forward.spec.ts
+++ b/frontend/src/shared/utils/mail-forward.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
-import { buildForwardDraft, buildForwardSubject } from './mail-forward'
+import { buildForwardDraft, buildForwardSubject, isForwardSubject } from './mail-forward'
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
@@ -46,6 +46,23 @@ describe('buildForwardSubject', () => {
it('제목이 없어도 접두어만 남는다', () => {
expect(buildForwardSubject('')).toBe('Fwd:')
})
+
+ it('isForwardSubject — 이어 쓰는 저장본의 쓰기 화면 제목(전달)을 정한다', () => {
+ expect(isForwardSubject('Fwd: 일정')).toBe(true)
+ expect(isForwardSubject('전달: 일정')).toBe(true)
+ expect(isForwardSubject('Re: 일정')).toBe(false)
+ expect(isForwardSubject('일정 Fwd:')).toBe(false)
+ })
+})
+
+describe('buildForwardDraft — 본문 그림(isInline)', () => {
+ it('본문에 보인 그림도 원본 첨부로 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게', () => {
+ const report = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false }
+ const logo = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true }
+ const original = message({ attachments: [report, logo] })
+
+ expect(buildForwardDraft(original).attachments.map((attachment) => attachment.id)).toEqual(['a1', 'a2'])
+ })
})
describe('buildForwardDraft', () => {
diff --git a/frontend/src/shared/utils/mail-forward.ts b/frontend/src/shared/utils/mail-forward.ts
index 74f3fa9..9030ff4 100644
--- a/frontend/src/shared/utils/mail-forward.ts
+++ b/frontend/src/shared/utils/mail-forward.ts
@@ -9,26 +9,38 @@ import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/uti
export interface ForwardDraft {
subject: string
- /** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
+ /** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + (서명) + 원문 인용 */
html: string
- /** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
+ /**
+ * 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다.
+ * 본문 그림(isInline)도 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게 (계약 2절 "MessageAttachment")
+ */
attachments: Attachment[]
}
/** 이미 전달 접두어가 있으면 그대로 둔다 ("Fwd:" · "FW:" · "전달:") */
const FORWARD_PREFIX_PATTERN = /^\s*(?:fwd?|전달)\s*:/i
+/** 전달 제목인가 — "Fwd:" · "FW:" · "전달:" 로 시작한다 (이어 쓰는 임시 저장본의 쓰기 화면 제목을 정할 때) */
+export function isForwardSubject(subject: string): boolean {
+ return FORWARD_PREFIX_PATTERN.test(subject)
+}
+
export function buildForwardSubject(subject: string): string {
const trimmed = subject.trim()
if (FORWARD_PREFIX_PATTERN.test(trimmed)) return trimmed
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
}
-/** 전달 초안 — 제목은 255자에 맞춘다 */
-export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
+/** 전달 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
+export function buildForwardDraft(
+ message: MessageDetail,
+ timeZone?: string,
+ signatureHtml: string | null = null,
+): ForwardDraft {
return {
subject: fitDraftSubject(buildForwardSubject(message.subject)),
- html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
+ html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
attachments: [...message.attachments],
}
}
diff --git a/frontend/src/shared/utils/mail-quote.spec.ts b/frontend/src/shared/utils/mail-quote.spec.ts
index d751817..2bf6e11 100644
--- a/frontend/src/shared/utils/mail-quote.spec.ts
+++ b/frontend/src/shared/utils/mail-quote.spec.ts
@@ -190,4 +190,10 @@ describe('buildQuoteHtml', () => {
'원문 ',
)
})
+
+ it('서명은 인용 위 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명")', () => {
+ expect(buildQuoteHtml([{ label: '제목', value: '일정' }], '원문 ', '김아라 ')).toMatch(
+ /^<\/p> <\/p> 김아라<\/p> /,
+ )
+ })
})
diff --git a/frontend/src/shared/utils/mail-quote.ts b/frontend/src/shared/utils/mail-quote.ts
index 61c6ec2..cd074a2 100644
--- a/frontend/src/shared/utils/mail-quote.ts
+++ b/frontend/src/shared/utils/mail-quote.ts
@@ -135,13 +135,18 @@ export function forwardQuoteRows(message: HeaderSource, timeZone?: string): Quot
return rows
}
-/** 내가 쓸 빈 문단 + "원본 메일" 머리 + 원문을 담은 인용 */
-export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string): string {
+/**
+ * 내가 쓸 빈 문단 + (서명) + "원본 메일" 머리 + 원문을 담은 인용.
+ * 서명이 있으면 인용 위에 둔다 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명").
+ * signatureHtml 은 서버가 정화해 준 서명 HTML 이다 (편집기가 자기 스키마로 다시 읽는다).
+ */
+export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string, signatureHtml: string | null = null): string {
const header = rows
.map((row) => `| ${escapeHtml(row.label)} | ${escapeHtml(row.value)} | `)
.join('')
return (
'' +
+ (signatureHtml ? `${signatureHtml}` : '') +
'' +
`${escapeHtml(MAIL_QUOTE_HEADING)} ` +
`` +
diff --git a/frontend/src/shared/utils/mail-reply.spec.ts b/frontend/src/shared/utils/mail-reply.spec.ts
index e1d6fe6..49c2a69 100644
--- a/frontend/src/shared/utils/mail-reply.spec.ts
+++ b/frontend/src/shared/utils/mail-reply.spec.ts
@@ -151,6 +151,12 @@ describe('buildReplyDraft', () => {
)
})
+ it('서명을 주면 인용 위에 넣는다', () => {
+ const { html } = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul', '김아라 · 인프라팀 ')
+
+ expect(html.startsWith('김아라 · 인프라팀 ')).toBe(true)
+ })
+
it('답장 머리에는 받는 사람 · 참조가 없다 (전달에만)', () => {
const { html } = buildReplyDraft(message({ cc: [INFRA] }), ME, 'reply', 'Asia/Seoul')
diff --git a/frontend/src/shared/utils/mail-reply.ts b/frontend/src/shared/utils/mail-reply.ts
index f74b745..bea15f6 100644
--- a/frontend/src/shared/utils/mail-reply.ts
+++ b/frontend/src/shared/utils/mail-reply.ts
@@ -14,7 +14,7 @@ export interface ReplyDraft {
to: Address[]
cc: Address[]
subject: string
- /** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + 원문 인용 */
+ /** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + (서명) + 원문 인용 */
html: string
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
inReplyTo: string
@@ -83,19 +83,20 @@ export function buildReplyRecipients(
return { to, cc }
}
-/** 답장 초안 — 제목은 255자에 맞춘다 */
+/** 답장 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
export function buildReplyDraft(
message: MessageDetail,
myEmail: string,
mode: ReplyMode,
timeZone?: string,
+ signatureHtml: string | null = null,
): ReplyDraft {
const { to, cc } = buildReplyRecipients(message, myEmail, mode)
return {
to,
cc,
subject: fitDraftSubject(buildReplySubject(message.subject)),
- html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message)),
+ html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
inReplyTo: message.id,
}
}
diff --git a/frontend/src/shared/utils/mail-signature.spec.ts b/frontend/src/shared/utils/mail-signature.spec.ts
new file mode 100644
index 0000000..6790b6a
--- /dev/null
+++ b/frontend/src/shared/utils/mail-signature.spec.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+
+import type { MailSignature } from '@/shared/types/mail'
+import {
+ imageSizeOf,
+ imageWidthFor,
+ insertedImageWidth,
+ newMailHtmlWithSignature,
+ originalImageWidth,
+ precheckSignatureImage,
+ signatureHtmlFor,
+ signatureImageUploadName,
+} from './mail-signature'
+
+const BASE: MailSignature = {
+ bodyHtml: '김아라 ',
+ body: '김아라',
+ isForNew: true,
+ isForReply: true,
+ images: [],
+ updatedAt: null,
+}
+
+describe('signatureHtmlFor — 쓰기 화면에 넣을 서명', () => {
+ it('넣기로 한 쓰기 종류에만, 서식 서명을 먼저', () => {
+ expect(signatureHtmlFor(BASE, 'new')).toBe('김아라 ')
+ expect(signatureHtmlFor({ ...BASE, isForNew: false }, 'new')).toBeNull()
+ expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'reply')).toBeNull()
+ expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'new')).toBe('김아라 ')
+ })
+
+ it('서식 서명이 없으면 텍스트 서명을 문단으로, 둘 다 비었으면 넣지 않는다', () => {
+ expect(signatureHtmlFor({ ...BASE, bodyHtml: null, body: '김아라\n인프라팀\n' }, 'new')).toBe('김아라 인프라팀 ')
+ expect(signatureHtmlFor({ ...BASE, bodyHtml: ' ', body: ' ' }, 'new')).toBeNull()
+ expect(signatureHtmlFor(null, 'new')).toBeNull()
+ })
+
+ it('새 메일은 쓸 줄 · 빈 줄 하나 뒤에', () => {
+ expect(newMailHtmlWithSignature('김아라 ')).toBe('김아라 ')
+ })
+})
+
+describe('precheckSignatureImage — PNG · JPG · GIF, 300KB, 3장', () => {
+ it.each([
+ [{ name: '로고.png', type: 'image/png', size: 1000 }, 0, null],
+ [{ name: '로고.JPG', type: 'image/jpeg', size: 1000 }, 2, null],
+ [{ name: '로고.jpeg', type: 'image/jpeg', size: 300 * 1024 }, 0, null],
+ [{ name: '로고.gif', type: 'image/gif', size: 1 }, 0, null],
+ // 붙여 넣은 그림은 이름이 없을 수 있다 — 형식만 본다
+ [{ name: '', type: 'image/png', size: 1 }, 0, null],
+ [{ name: '로고.webp', type: 'image/webp', size: 1 }, 0, 'FILE_001'],
+ [{ name: '로고.svg', type: 'image/svg+xml', size: 1 }, 0, 'FILE_001'],
+ [{ name: '로고.png', type: 'image/gif', size: 1 }, 0, 'FILE_001'],
+ [{ name: '로고.png', type: '', size: 1 }, 0, 'FILE_001'],
+ [{ name: '로고.png', type: 'image/png', size: 300 * 1024 + 1 }, 0, 'FILE_002'],
+ [{ name: '로고.png', type: 'image/png', size: 1 }, 3, 'VAL_001'],
+ ] as const)('%o · 이미 %i장 → %s', (file, occupied, expected) => {
+ expect(precheckSignatureImage(file, occupied)).toBe(expected)
+ })
+
+ it('올릴 이름 — 확장자가 없으면 형식에 맞는 확장자를 붙인다', () => {
+ expect(signatureImageUploadName({ name: '로고.png', type: 'image/png', size: 1 })).toBe('로고.png')
+ expect(signatureImageUploadName({ name: '', type: 'image/jpeg', size: 1 })).toBe('서명 그림.jpg')
+ expect(signatureImageUploadName({ name: 'logo', type: 'image/gif', size: 1 })).toBe('logo.gif')
+ })
+})
+
+describe('그림 크기 — 작게 96 · 보통 144 · 원본(600 이하)', () => {
+ it('넣을 때는 보통, 원래 폭이 더 작으면 원래 폭', () => {
+ expect(insertedImageWidth(480)).toBe(144)
+ expect(insertedImageWidth(80)).toBe(80)
+ })
+
+ it('원본은 원래 폭, 600 을 넘으면 600', () => {
+ expect(originalImageWidth(1200)).toBe(600)
+ expect(originalImageWidth(320)).toBe(320)
+ expect(imageWidthFor('original', 800)).toBe(600)
+ expect(imageWidthFor('small', 800)).toBe(96)
+ })
+
+ it('지금 폭이 어느 칸인가 — 작게 · 보통 · 원본 순, 맞는 칸이 없으면 null', () => {
+ expect(imageSizeOf(96, 480)).toBe('small')
+ expect(imageSizeOf(144, 144)).toBe('medium')
+ expect(imageSizeOf(480, 480)).toBe('original')
+ expect(imageSizeOf(600, 1200)).toBe('original')
+ expect(imageSizeOf(200, 480)).toBeNull()
+ expect(imageSizeOf(null, 480)).toBe('original')
+ })
+})
diff --git a/frontend/src/shared/utils/mail-signature.ts b/frontend/src/shared/utils/mail-signature.ts
new file mode 100644
index 0000000..8342036
--- /dev/null
+++ b/frontend/src/shared/utils/mail-signature.ts
@@ -0,0 +1,102 @@
+// 메일 서명 — 순수 함수 (계약 2절 "서명" · 디자인 README "2단계 › 서명")
+
+import type { ErrorCode } from '@/shared/constants/error-codes.generated'
+import {
+ SIGNATURE_IMAGE_MAX_BYTES,
+ SIGNATURE_IMAGE_MAX_COUNT,
+ SIGNATURE_IMAGE_SIZES,
+ SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
+ type SignatureImageSize,
+} from '@/shared/constants/mail-signature'
+import type { MailSignature } from '@/shared/types/mail'
+import { extensionOf } from '@/shared/utils/mail-attachment'
+import { textToParagraphHtml } from '@/shared/utils/mail-quote'
+import { IMAGE_MAX_WIDTH } from '@/shared/utils/outgoing-html-rules'
+
+export type SignatureUse = 'new' | 'reply'
+
+/** 받는 그림 MIME 형식 */
+const SIGNATURE_IMAGE_TYPES: readonly string[] = Object.values(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
+
+/**
+ * 쓰기 화면에 넣을 서명 HTML — 그 쓰기 종류에 넣기로 했고(isForNew · isForReply) 내용이 있을 때만.
+ * 서식 서명(bodyHtml)이 있으면 그것, 없으면 텍스트 서명(body)을 문단으로. 둘 다 비었으면 null.
+ */
+export function signatureHtmlFor(signature: MailSignature | null, use: SignatureUse): string | null {
+ if (!signature) return null
+ const enabled = use === 'new' ? signature.isForNew : signature.isForReply
+ if (!enabled) return null
+ const html = signature.bodyHtml?.trim() ?? ''
+ if (html !== '') return html
+ const text = signature.body?.replace(/\s+$/, '') ?? ''
+ return text.trim() === '' ? null : textToParagraphHtml(text)
+}
+
+/** 새 메일 본문 — 쓸 줄 · 빈 줄 하나 · 서명 (서명은 본문 끝, 빈 줄 하나 뒤) */
+export function newMailHtmlWithSignature(signatureHtml: string): string {
+ return `${signatureHtml}`
+}
+
+/** 그림 파일 사전 검사에 쓰는 모양 — File 과 같은 이름 · 형식 · 크기 */
+export interface SignatureImageCandidate {
+ name: string
+ type: string
+ size: number
+}
+
+/**
+ * 서명 그림 사전 검사 — 걸리면 그 에러 코드 (문구는 사전에서), 통과하면 null. 서버도 같은 규칙으로 거절한다.
+ * - 형식: 확장자와 MIME 이 PNG · JPG · GIF 짝 (FILE_001)
+ * - 크기: 300KB 이하 (FILE_002)
+ * - 개수: 서명 칸에 이미 있는 서로 다른 그림 + 올리는 중인 그림(occupied) + 이 그림이 3장 이하 (VAL_001 — 서명 저장의 한도)
+ * 붙여 넣은 그림은 이름이 없을 수 있다 — 그때는 MIME 만 본다 (올릴 때 이름을 붙인다)
+ */
+export function precheckSignatureImage(file: SignatureImageCandidate, occupied: number): ErrorCode | null {
+ const extension = extensionOf(file.name)
+ const byExtension = extension === '' ? null : (SIGNATURE_IMAGE_TYPES_BY_EXTENSION[extension] ?? null)
+ if (!SIGNATURE_IMAGE_TYPES.includes(file.type) || (extension !== '' && byExtension !== file.type)) {
+ return 'FILE_001'
+ }
+ if (file.size > SIGNATURE_IMAGE_MAX_BYTES) return 'FILE_002'
+ if (occupied >= SIGNATURE_IMAGE_MAX_COUNT) return 'VAL_001'
+ return null
+}
+
+/** 이름 없이 붙여 넣은 그림의 이름 */
+const SIGNATURE_IMAGE_DEFAULT_NAME = '서명 그림'
+
+/**
+ * 올릴 때의 파일 이름 — 서버는 확장자와 MIME 이 맞아야 받는다. 확장자가 없으면(붙여 넣은 그림 등) 형식에 맞는 확장자를 붙인다.
+ * 사전 검사(precheckSignatureImage)를 통과한 파일에만 쓴다 — 확장자가 있으면 이미 형식과 맞다.
+ */
+export function signatureImageUploadName(file: SignatureImageCandidate): string {
+ if (extensionOf(file.name) !== '') return file.name
+ const extension =
+ Object.entries(SIGNATURE_IMAGE_TYPES_BY_EXTENSION).find(([, type]) => type === file.type)?.[0] ?? 'png'
+ return `${file.name.trim() || SIGNATURE_IMAGE_DEFAULT_NAME}.${extension}`
+}
+
+/** 원본 폭 — 그림의 원래 폭, 600px 이하 (서버가 남기는 width 상한) */
+export function originalImageWidth(naturalWidth: number): number {
+ const width = Math.round(naturalWidth)
+ return Math.max(1, Math.min(IMAGE_MAX_WIDTH, Number.isFinite(width) && width > 0 ? width : IMAGE_MAX_WIDTH))
+}
+
+/** 크기 줄의 폭 */
+export function imageWidthFor(size: SignatureImageSize, naturalWidth: number): number {
+ return size === 'original' ? originalImageWidth(naturalWidth) : SIGNATURE_IMAGE_SIZES[size]
+}
+
+/** 새로 넣는 그림의 폭 — 보통(144), 원래 폭이 더 작으면 원래 폭 (작은 그림을 늘리지 않는다) */
+export function insertedImageWidth(naturalWidth: number): number {
+ return Math.min(SIGNATURE_IMAGE_SIZES.medium, originalImageWidth(naturalWidth))
+}
+
+/** 지금 폭이 크기 줄의 어느 칸인가 — 작게 · 보통 · 원본 순으로 먼저 맞는 칸, 어디에도 맞지 않으면 null */
+export function imageSizeOf(width: number | null, naturalWidth: number | null): SignatureImageSize | null {
+ if (width === null) return naturalWidth === null ? null : 'original'
+ if (width === SIGNATURE_IMAGE_SIZES.small) return 'small'
+ if (width === SIGNATURE_IMAGE_SIZES.medium) return 'medium'
+ if (naturalWidth !== null && width === originalImageWidth(naturalWidth)) return 'original'
+ return null
+}
diff --git a/frontend/src/shared/utils/outgoing-html-rules.spec.ts b/frontend/src/shared/utils/outgoing-html-rules.spec.ts
index 3b8d9cf..b52fa2f 100644
--- a/frontend/src/shared/utils/outgoing-html-rules.spec.ts
+++ b/frontend/src/shared/utils/outgoing-html-rules.spec.ts
@@ -12,13 +12,19 @@ import {
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
-import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
+import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID, STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import {
acceptStyleValue,
cellSpanOf,
+ imageAltOf,
+ imageWidthOf,
+ isOutgoingImageAttribute,
isOutgoingStyleValue,
lengthToPx,
listStartOf,
+ normalizeSignatureImageSrc,
+ signatureImageIdOf,
+ signatureImageSrc,
} from './outgoing-html-rules'
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
@@ -119,3 +125,33 @@ describe('숫자 속성 — 서버가 남기는 1~4자리 숫자', () => {
for (const value of ['0', '2;', 'abc', '', null, -1, 10_000]) expect(cellSpanOf(value)).toBe(1)
})
})
+
+describe('그림 — 서명 그림만 남는다 (계약 2절 "서식 본문"의 그림 예외, 서버와 같은 표본)', () => {
+ it.each(IMAGE_SAMPLES)('%s: %s → 남는다 %s', (attribute, value, kept) => {
+ expect(isOutgoingImageAttribute(attribute, value)).toBe(kept)
+ })
+
+ it('그 밖의 속성은 남지 않는다', () => {
+ for (const name of ['style', 'height', 'class', 'onerror', 'srcset', 'title']) {
+ expect(isOutgoingImageAttribute(name, '1')).toBe(false)
+ }
+ })
+
+ it('주소는 서버가 다시 쓰는 모양으로 맞춘다 — 앞뒤 공백 없이, id 는 소문자', () => {
+ const path = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
+ expect(normalizeSignatureImageSrc(` /api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()} `)).toBe(path)
+ expect(signatureImageIdOf(path)).toBe(SAMPLE_IMAGE_ID)
+ expect(signatureImageSrc(SAMPLE_IMAGE_ID.toUpperCase())).toBe(path)
+ expect(signatureImageSrc('../x')).toBeNull()
+ expect(normalizeSignatureImageSrc(42)).toBeNull()
+ })
+
+ it('폭은 숫자로, 설명은 그대로 — 형식이 틀리면 null (그 속성만 뺀다)', () => {
+ expect(imageWidthOf(' 144 ')).toBe(144)
+ expect(imageWidthOf(96)).toBe(96)
+ expect(imageWidthOf(600.5)).toBeNull()
+ expect(imageWidthOf(null)).toBeNull()
+ expect(imageAltOf(' 로고 ')).toBe(' 로고 ')
+ expect(imageAltOf(undefined)).toBeNull()
+ })
+})
diff --git a/frontend/src/shared/utils/outgoing-html-rules.ts b/frontend/src/shared/utils/outgoing-html-rules.ts
index ad2e790..92446ff 100644
--- a/frontend/src/shared/utils/outgoing-html-rules.ts
+++ b/frontend/src/shared/utils/outgoing-html-rules.ts
@@ -193,3 +193,67 @@ export function cellSpanOf(value: unknown): number {
const span = smallNumber(value)
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
}
+
+// ── 그림 (계약 2절 "서식 본문" — 보내는 사람 자신의 서명 그림만 남는다) ─────────────
+// 의 src · width · alt 만 남고, 형식이 틀린 width · alt 는 그 속성만 지운다.
+// src 가 서명 그림 주소가 아니면(외부 주소 · data: · 쿼리 · 다른 경로) 그림째 지운다.
+// ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.ts 의 parseSignatureImageId · sanitize-outgoing-html.ts 의 keepSignatureImage)와
+// 같은 규칙이다 — src · width 는 앞뒤 공백을 떼고 보고, id 는 대소문자를 가리지 않고 소문자로 맞춘다. alt 는 적힌 그대로 본다.
+// 같은 표본 표를 양쪽 테스트에 둔다 (test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES).
+// 서버는 거기에 더해 그 id 가 보내는 사람의 그림인지 본다 — 편집기는 모르므로, 남의(또는 지운) 그림은 편집기에 깨진 그림으로 보이고 보낼 때 빠진다.
+
+/** 서명 그림 주소의 앞부분 — 그림 주소는 이 뒤에 id 하나만 붙는다 (편집기 · 미리 보기가 이 주소로 그림을 받는다) */
+export const SIGNATURE_IMAGE_PATH = '/api/mail-signature/images/'
+/** 서명 그림 주소 — id 는 서버가 만든 UUID (쿼리 · 조각 · 하위 경로 없이). 대소문자는 가리지 않는다 */
+const SIGNATURE_IMAGE_SRC =
+ /^\/api\/mail-signature\/images\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i
+/** 그림 폭 — 1~600 의 정수, 앞자리 0 · 단위 · 소수 없이 */
+const IMAGE_WIDTH = /^(?:[1-9]\d?|[1-5]\d\d|600)$/
+/** 그림 설명(alt) 최대 길이 — JS 문자열 길이 */
+export const IMAGE_ALT_MAX_LENGTH = 200
+/** 그림 폭 상한(px) — 원본이 더 커도 이 폭으로 줄여 넣는다 */
+export const IMAGE_MAX_WIDTH = 600
+
+/** 그림 주소 → 서명 그림 id (소문자). 서명 그림 주소가 아니면 null */
+export function signatureImageIdOf(src: unknown): string | null {
+ if (typeof src !== 'string') return null
+ const match = SIGNATURE_IMAGE_SRC.exec(src.trim())
+ return match?.[1] ? match[1].toLowerCase() : null
+}
+
+/** 남는 그림 주소인가 — 보내는 사람 자신의 서명 그림 주소 형식 */
+export function isSignatureImageSrc(value: unknown): value is string {
+ return signatureImageIdOf(value) !== null
+}
+
+/** 서명 그림 id → 그림 주소 (id 형식이 틀리면 null) */
+export function signatureImageSrc(id: string): string | null {
+ const normalized = signatureImageIdOf(`${SIGNATURE_IMAGE_PATH}${id}`)
+ return normalized === null ? null : `${SIGNATURE_IMAGE_PATH}${normalized}`
+}
+
+/** 남는 그림 주소 — 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로. 서명 그림 주소가 아니면 null */
+export function normalizeSignatureImageSrc(src: unknown): string | null {
+ const id = signatureImageIdOf(src)
+ return id === null ? null : `${SIGNATURE_IMAGE_PATH}${id}`
+}
+
+/** 남는 그림 폭 — 1~600 정수(숫자 또는 숫자 글자, 앞뒤 공백은 뗀다)가 아니면 null (폭 속성을 두지 않는다) */
+export function imageWidthOf(value: unknown): number | null {
+ const text = typeof value === 'number' ? String(value) : value
+ if (typeof text !== 'string' || !IMAGE_WIDTH.test(text.trim())) return null
+ return Number(text.trim())
+}
+
+/** 남는 그림 설명 — 200자를 넘거나 글자가 아니면 null (설명 속성을 두지 않는다) */
+export function imageAltOf(value: unknown): string | null {
+ return typeof value === 'string' && value.length <= IMAGE_ALT_MAX_LENGTH ? value : null
+}
+
+/** 그림 속성 하나가 남는가 — 표본 표(test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES)로 서버와 맞춰 본다 */
+export function isOutgoingImageAttribute(name: string, value: string): boolean {
+ if (name === 'src') return isSignatureImageSrc(value)
+ if (name === 'width') return imageWidthOf(value) !== null
+ if (name === 'alt') return imageAltOf(value) !== null
+ return false
+}
diff --git a/frontend/src/shared/utils/recipient-match.spec.ts b/frontend/src/shared/utils/recipient-match.spec.ts
new file mode 100644
index 0000000..edf5015
--- /dev/null
+++ b/frontend/src/shared/utils/recipient-match.spec.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+
+import type { RecipientSuggestion } from '@/shared/types/mail'
+import {
+ RECIPIENT_SUGGESTION_LIMIT,
+ filterRecipientSuggestions,
+ hasHangulJamo,
+ highlightParts,
+ matchEmail,
+ matchName,
+} from './recipient-match'
+
+const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
+const DOYUN_KIM: RecipientSuggestion = { name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' }
+const PARTNER: RecipientSuggestion = { name: '최도윤', email: 'choi@partner.example', source: 'recent' }
+const NO_NAME: RecipientSuggestion = { name: null, email: 'ops@vendor.example', source: 'recent' }
+
+/** 맞은 부분 글자 */
+function matched(text: string, range: { start: number; end: number } | null): string | null {
+ return range ? text.slice(range.start, range.end) : null
+}
+
+describe('matchName — 이름 (대소문자 무시 · 초성 · 조합 중인 마지막 글자)', () => {
+ it.each([
+ ['도윤', '이도윤', '도윤'],
+ ['이도', '이도윤', '이도'],
+ ['ㄷㅇ', '이도윤', '도윤'],
+ ['ㅇㄷㅇ', '이도윤', '이도윤'],
+ ['도ㅇ', '이도윤', '도윤'],
+ ['ㅇ도', '이도윤', '이도'],
+ // 마지막 글자가 조합 중 — "윤" 을 치는 중이면 "유", 다음 글자의 ㅇ 이 받침으로 붙으면 "동"
+ ['이도유', '이도윤', '이도윤'],
+ ['이동', '이도윤', '이도윤'],
+ ['읻', '이도윤', '이도'],
+ ['닭', '달기봉', '달기'],
+ ['kim', 'Ara KIM', 'KIM'],
+ [' 도윤 ', '이도윤', '도윤'],
+ ])('%s → %s 에서 "%s"', (query, name, expected) => {
+ expect(matched(name, matchName(query, name))).toBe(expected)
+ })
+
+ it.each([
+ ['도윤', '김아라'],
+ ['ㄷㅈ', '이도윤'],
+ ['동윤', '이도윤'],
+ ['이동', '이도'],
+ ['윤도', '이도윤'],
+ ['', '이도윤'],
+ ])('%s 는 %s 에 맞지 않는다', (query, name) => {
+ expect(matchName(query, name)).toBeNull()
+ })
+
+ it('조합 중인 글자는 마지막에만 — 가운데 글자는 그대로 맞아야 한다', () => {
+ expect(matchName('유ㄴ', '윤나')).toBeNull()
+ expect(matchName('윤ㄴ', '윤나')).toEqual({ start: 0, end: 2 })
+ })
+
+ it('이모지 같은 두 칸 글자가 있어도 범위는 UTF-16 위치다', () => {
+ const name = '😀이도윤'
+ expect(matched(name, matchName('도윤', name))).toBe('도윤')
+ })
+})
+
+describe('matchEmail — 주소 (대소문자 무시)', () => {
+ it('친 글자가 들어 있는 곳', () => {
+ expect(matched(DOYUN.email, matchEmail('doyun', DOYUN.email))).toBe('doyun')
+ expect(matched(DOYUN.email, matchEmail('ARASYSTEM', DOYUN.email))).toBe('arasystem')
+ expect(matchEmail('xyz', DOYUN.email)).toBeNull()
+ })
+
+ it('완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 (디자인 원본 ComposeAssist)', () => {
+ expect(matched(DOYUN.email, matchEmail('doyun.lee@partner.example', DOYUN.email))).toBe('doyun.lee@')
+ expect(matchEmail('@partner.example', DOYUN.email)).toBeNull()
+ })
+})
+
+describe('filterRecipientSuggestions', () => {
+ const pool = [PARTNER, DOYUN, NO_NAME, DOYUN_KIM]
+
+ it('사내를 먼저, 그 안에서는 받은 순서대로', () => {
+ const result = filterRecipientSuggestions('도윤', pool, new Set())
+ expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, DOYUN_KIM.email, PARTNER.email])
+ })
+
+ it('이미 넣은 주소(대소문자 무시)는 빼고, 같은 주소는 한 번만', () => {
+ const result = filterRecipientSuggestions('도윤', [...pool, { ...DOYUN, source: 'recent' }], new Set([DOYUN_KIM.email]))
+ expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, PARTNER.email])
+ })
+
+ it('이름이 없으면 주소로만 맞춘다', () => {
+ expect(filterRecipientSuggestions('vendor', pool, new Set()).map((match) => match.suggestion)).toEqual([NO_NAME])
+ })
+
+ it('빈 글자는 아무것도 보이지 않고, 최대 8개까지', () => {
+ expect(filterRecipientSuggestions(' ', pool, new Set())).toEqual([])
+ const many = Array.from({ length: 12 }, (_, index): RecipientSuggestion => ({
+ name: `도윤${index}`,
+ email: `doyun${index}@arasystem.it.kr`,
+ source: 'directory',
+ }))
+ expect(filterRecipientSuggestions('도윤', many, new Set())).toHaveLength(RECIPIENT_SUGGESTION_LIMIT)
+ })
+})
+
+describe('보조 함수', () => {
+ it('highlightParts — 맞은 부분만 strong', () => {
+ expect(highlightParts('이도윤', { start: 1, end: 3 })).toEqual([
+ { text: '이', strong: false },
+ { text: '도윤', strong: true },
+ ])
+ expect(highlightParts('abc', null)).toEqual([{ text: 'abc', strong: false }])
+ })
+
+ it('hasHangulJamo — 초성 · 홀로 쓴 모음', () => {
+ expect(hasHangulJamo('ㄷㅇ')).toBe(true)
+ expect(hasHangulJamo('도ㅇ')).toBe(true)
+ expect(hasHangulJamo('도윤')).toBe(false)
+ expect(hasHangulJamo('doyun')).toBe(false)
+ })
+})
diff --git a/frontend/src/shared/utils/recipient-match.ts b/frontend/src/shared/utils/recipient-match.ts
new file mode 100644
index 0000000..acde24d
--- /dev/null
+++ b/frontend/src/shared/utils/recipient-match.ts
@@ -0,0 +1,214 @@
+// 받는 사람 자동 완성의 거르기 — 순수 함수 (디자인 README "2단계 › 받는 사람 자동 완성")
+// - 이름 · 주소에 친 글자가 들어 있으면 맞는다 (대소문자 무시). 맞는 부분은 굵게 보이도록 범위를 준다.
+// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤".
+// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다.
+// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다.
+// - 이미 넣은 주소는 뺀다. 순서는 받은 그대로(서버: 사내 → 최근 주고받은 주소, 주고받은 횟수 · 최근성), 최대 8개.
+
+import type { RecipientSuggestion } from '@/shared/types/mail'
+import { normalizeEmail } from '@/shared/utils/mail-address'
+
+/** 추천 목록에 보이는 최대 개수 */
+export const RECIPIENT_SUGGESTION_LIMIT = 8
+
+/** 글자 범위 — UTF-16 위치, end 는 들지 않는다 */
+export interface MatchRange {
+ start: number
+ end: number
+}
+
+export interface RecipientMatch {
+ suggestion: RecipientSuggestion
+ /** 이름에서 맞은 범위 (없으면 null) */
+ name: MatchRange | null
+ /** 주소에서 맞은 범위 (없으면 null) */
+ email: MatchRange | null
+}
+
+/** 굵게 보일 조각 — 맞은 부분만 strong */
+export interface HighlightPart {
+ text: string
+ strong: boolean
+}
+
+// ── 한글 ─────────────────────────────────────────────────
+
+const SYLLABLE_START = 0xac00
+const SYLLABLE_END = 0xd7a3
+const MEDIAL_COUNT = 21
+const FINAL_COUNT = 28
+/** 초성 — 호환 자모 */
+const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ')
+/** 받침 — 0 은 받침 없음 */
+const FINALS = ['', ...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ')]
+/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */
+const SPLIT_FINALS: Readonly> = {
+ ㄳ: ['ㄱ', 'ㅅ'],
+ ㄵ: ['ㄴ', 'ㅈ'],
+ ㄶ: ['ㄴ', 'ㅎ'],
+ ㄺ: ['ㄹ', 'ㄱ'],
+ ㄻ: ['ㄹ', 'ㅁ'],
+ ㄼ: ['ㄹ', 'ㅂ'],
+ ㄽ: ['ㄹ', 'ㅅ'],
+ ㄾ: ['ㄹ', 'ㅌ'],
+ ㄿ: ['ㄹ', 'ㅍ'],
+ ㅀ: ['ㄹ', 'ㅎ'],
+ ㅄ: ['ㅂ', 'ㅅ'],
+}
+
+interface Syllable {
+ initial: number
+ medial: number
+ final: number
+}
+
+function syllableOf(char: string): Syllable | null {
+ const code = char.codePointAt(0) ?? 0
+ if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) return null
+ const offset = code - SYLLABLE_START
+ return {
+ initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)),
+ medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT),
+ final: offset % FINAL_COUNT,
+ }
+}
+
+function composeSyllable(syllable: Syllable): string {
+ return String.fromCharCode(
+ SYLLABLE_START + (syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT + syllable.final,
+ )
+}
+
+/** 초성(호환 자모) 하나인가 */
+function isInitialJamo(char: string): boolean {
+ return INITIALS.includes(char)
+}
+
+/** 호환 자모(ㄱ~ㅎ · ㅏ~ㅣ)가 들었는가 — 서버 검색은 초성을 모른다 */
+export function hasHangulJamo(text: string): boolean {
+ return /[ㄱ-ㆎ]/.test(text)
+}
+
+function initialOf(char: string): string | null {
+ const syllable = syllableOf(char)
+ return syllable ? (INITIALS[syllable.initial] ?? null) : null
+}
+
+// ── 맞추기 ───────────────────────────────────────────────
+
+/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */
+function matchesChar(typed: string, char: string): boolean {
+ if (typed.toLowerCase() === char.toLowerCase()) return true
+ return isInitialJamo(typed) && initialOf(char) === typed
+}
+
+/**
+ * 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0.
+ * - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다
+ * - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤)
+ */
+function matchesLastChar(typed: string, char: string, next: string | undefined): number {
+ if (matchesChar(typed, char)) return 1
+ const typedSyllable = syllableOf(typed)
+ const syllable = syllableOf(char)
+ if (!typedSyllable || !syllable) return 0
+ if (typedSyllable.initial !== syllable.initial || typedSyllable.medial !== syllable.medial) return 0
+ if (typedSyllable.final === 0) return 1
+ if (next === undefined) return 0
+ const finalJamo = FINALS[typedSyllable.final] ?? ''
+ const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]
+ const restSyllable = composeSyllable({ ...typedSyllable, final: FINALS.indexOf(rest) })
+ return restSyllable === char && initialOf(next) === moved ? 2 : 0
+}
+
+/** chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null */
+function matchFrom(typed: readonly string[], chars: readonly string[], start: number): number | null {
+ let index = start
+ for (let at = 0; at < typed.length; at += 1) {
+ const char = chars[index]
+ const typedChar = typed[at] ?? ''
+ if (char === undefined) return null
+ if (at === typed.length - 1) {
+ const used = matchesLastChar(typedChar, char, chars[index + 1])
+ return used === 0 ? null : index + used
+ }
+ if (!matchesChar(typedChar, char)) return null
+ index += 1
+ }
+ return index
+}
+
+/** 글자 단위 위치 → UTF-16 위치 */
+function utf16Offset(chars: readonly string[], index: number): number {
+ return chars.slice(0, index).join('').length
+}
+
+/** 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위 */
+export function matchName(query: string, name: string): MatchRange | null {
+ const typed = Array.from(query.trim())
+ const chars = Array.from(name)
+ if (typed.length === 0) return null
+ for (let start = 0; start < chars.length; start += 1) {
+ const end = matchFrom(typed, chars, start)
+ if (end !== null) return { start: utf16Offset(chars, start), end: utf16Offset(chars, end) }
+ }
+ return null
+}
+
+/** 주소에서 친 글자가 처음 들어 있는 범위 (대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 */
+export function matchEmail(query: string, email: string): MatchRange | null {
+ const typed = query.trim().toLowerCase()
+ const target = email.toLowerCase()
+ // 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다
+ if (typed === '' || target.length !== email.length) return null
+ const at = target.indexOf(typed)
+ if (at >= 0) return { start: at, end: at + typed.length }
+ const localEnd = typed.indexOf('@')
+ if (localEnd <= 0) return null
+ const local = typed.slice(0, localEnd + 1)
+ const localAt = target.indexOf(local)
+ return localAt >= 0 ? { start: localAt, end: localAt + local.length } : null
+}
+
+/** 추천 하나가 친 글자에 맞는가 — 이름 · 주소 중 하나라도 맞으면 */
+export function matchRecipient(query: string, suggestion: RecipientSuggestion): RecipientMatch | null {
+ const name = suggestion.name ? matchName(query, suggestion.name) : null
+ const email = matchEmail(query, suggestion.email)
+ return name || email ? { suggestion, name, email } : null
+}
+
+/**
+ * 친 글자에 맞는 추천 — 이미 넣은 주소(excluded, 정규화한 주소)는 빼고, 같은 주소는 처음 것만, 사내를 먼저, 최대 limit 개.
+ * 빈 글자면 아무것도 보이지 않는다 (한 글자 이상 쳤을 때만 목록을 연다).
+ */
+export function filterRecipientSuggestions(
+ query: string,
+ suggestions: readonly RecipientSuggestion[],
+ excluded: ReadonlySet,
+ limit = RECIPIENT_SUGGESTION_LIMIT,
+): RecipientMatch[] {
+ if (query.trim() === '') return []
+ const seen = new Set(excluded)
+ const matches: RecipientMatch[] = []
+ for (const suggestion of suggestions) {
+ const key = normalizeEmail(suggestion.email)
+ if (seen.has(key)) continue
+ const match = matchRecipient(query, suggestion)
+ if (!match) continue
+ seen.add(key)
+ matches.push(match)
+ }
+ const directory = matches.filter((match) => match.suggestion.source === 'directory')
+ const recent = matches.filter((match) => match.suggestion.source !== 'directory')
+ return [...directory, ...recent].slice(0, limit)
+}
+
+/** 굵게 보일 조각으로 나눈다 */
+export function highlightParts(text: string, range: MatchRange | null): HighlightPart[] {
+ if (!range || range.start >= range.end) return [{ text, strong: false }]
+ return [
+ { text: text.slice(0, range.start), strong: false },
+ { text: text.slice(range.start, range.end), strong: true },
+ { text: text.slice(range.end), strong: false },
+ ].filter((part) => part.text !== '')
+}
diff --git a/frontend/src/stores/mail-signature.store.spec.ts b/frontend/src/stores/mail-signature.store.spec.ts
new file mode 100644
index 0000000..e5bc111
--- /dev/null
+++ b/frontend/src/stores/mail-signature.store.spec.ts
@@ -0,0 +1,72 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { nextTick } from 'vue'
+
+import type { MailSignature } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
+import { useMailSignatureStore } from './mail-signature.store'
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const SIGNATURE: MailSignature = {
+ bodyHtml: '김아라 ',
+ body: '김아라',
+ isForNew: true,
+ isForReply: false,
+ images: [],
+ updatedAt: '2026-10-27T05:00:00Z',
+}
+
+describe('mail-signature.store (계약 2절 "서명")', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated(USER)
+ })
+
+ afterEach(() => {
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ it('쓰기 화면은 받아 둔 서명을 쓴다 — 받는 중이면 그 결과를 기다리고, 다시 묻지 않는다', async () => {
+ const requests = installMockApi(() => ok(SIGNATURE))
+ const store = useMailSignatureStore()
+
+ const [first, second] = await Promise.all([store.ensureSignature(), store.ensureSignature()])
+ await store.ensureSignature()
+
+ expect(first).toEqual(SIGNATURE)
+ expect(second).toEqual(SIGNATURE)
+ expect(requests).toHaveLength(1)
+ })
+
+ it('못 받으면 알리지 않고 null — 다음에 다시 받아 본다', async () => {
+ let result: MockResult = fail(503, 'MAIL_001')
+ const requests = installMockApi(() => result)
+ const store = useMailSignatureStore()
+
+ await expect(store.ensureSignature()).resolves.toBeNull()
+ expect(store.status).toBe('error')
+ expect(store.error?.code).toBe('MAIL_001')
+ expect(useToastStore().toasts).toEqual([])
+
+ result = ok(SIGNATURE)
+ await expect(store.ensureSignature()).resolves.toEqual(SIGNATURE)
+ expect(requests).toHaveLength(2)
+ })
+
+ it('저장한 값이 곧 다음 쓰기 화면의 서명이다 — 사용자가 바뀌면 비운다', async () => {
+ const requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...SIGNATURE, isForReply: true }) : ok(SIGNATURE)))
+ const store = useMailSignatureStore()
+
+ await store.save({ bodyHtml: '김아라 ', body: '김아라', isForNew: true, isForReply: true })
+ await expect(store.ensureSignature()).resolves.toMatchObject({ isForReply: true })
+ expect(requests.map((request) => request.method)).toEqual(['PUT'])
+
+ useAuthStore().clearAuth()
+ await nextTick()
+ expect(store.signature).toBeNull()
+ expect(store.status).toBe('idle')
+ })
+})
diff --git a/frontend/src/stores/mail-signature.store.ts b/frontend/src/stores/mail-signature.store.ts
new file mode 100644
index 0000000..8629c49
--- /dev/null
+++ b/frontend/src/stores/mail-signature.store.ts
@@ -0,0 +1,85 @@
+import { ref, watch } from 'vue'
+import { defineStore } from 'pinia'
+
+import { toApiError, type ApiError } from '@/composables/useApi'
+import { useMail, type MailRequestOptions } from '@/composables/useMail'
+import type { MailSignature, SaveSignaturePayload } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import type { LoadStatus } from '@/stores/mail.store'
+
+// 메일 서명 스토어 — 계약 2절 "서명" (사용자마다 하나)
+// - 쓰기 화면은 열 때 서명을 넣는다 — 메일 화면에 들어올 때 미리 받아 두고(ensureSignature), 쓰기 화면은 그 값을 쓴다.
+// 못 받았으면 서명 없이 연다 (알리지 않는다 — 서명은 쓰기를 막을 일이 아니다)
+// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창에서 고쳤을 수 있다. 오류는 화면이 직접 그린다
+// - 저장(save)한 값이 곧 다음 쓰기 화면의 서명이다
+export const useMailSignatureStore = defineStore('mail-signature', () => {
+ const mailApi = useMail()
+ const authStore = useAuthStore()
+
+ const signature = ref(null)
+ const status = ref('idle')
+ const error = ref(null)
+
+ let seq = 0
+ let pending: Promise | null = null
+
+ /** 서명을 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
+ function load(): Promise {
+ seq += 1
+ const current = seq
+ status.value = 'loading'
+ error.value = null
+ const request = (async () => {
+ try {
+ const result = await mailApi.getSignature({ silent: true })
+ if (current !== seq) return signature.value
+ signature.value = result
+ status.value = 'ready'
+ return result
+ } catch (caught) {
+ if (current !== seq) return signature.value
+ status.value = 'error'
+ error.value = toApiError(caught)
+ return null
+ } finally {
+ if (current === seq) pending = null
+ }
+ })()
+ pending = request
+ return request
+ }
+
+ /** 받아 둔 서명 — 아직 없거나 실패했으면 받는다 (받는 중이면 그 결과를 기다린다) */
+ function ensureSignature(): Promise {
+ if (status.value === 'ready') return Promise.resolve(signature.value)
+ if (pending) return pending
+ return load()
+ }
+
+ /** 서명 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다) */
+ async function save(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise {
+ const result = await mailApi.saveSignature(payload, options)
+ seq += 1
+ pending = null
+ signature.value = result
+ status.value = 'ready'
+ error.value = null
+ return result
+ }
+
+ function reset() {
+ seq += 1
+ pending = null
+ signature.value = null
+ status.value = 'idle'
+ error.value = null
+ }
+
+ // 로그아웃 · 다른 계정 로그인 — 이전 사용자의 서명이 남지 않게
+ watch(
+ () => authStore.user?.email ?? null,
+ () => reset(),
+ )
+
+ return { signature, status, error, load, ensureSignature, save, reset }
+})
diff --git a/frontend/src/stores/mail.store.spec.ts b/frontend/src/stores/mail.store.spec.ts
index 0774fb6..0cb9650 100644
--- a/frontend/src/stores/mail.store.spec.ts
+++ b/frontend/src/stores/mail.store.spec.ts
@@ -249,6 +249,35 @@ describe('mail.store', () => {
])
})
+ it('다른 메일함으로 옮긴다(되돌리기) — 개수를 다시 맞추고, 보이는 목록이 옮긴 곳 · 휴지통이면 새로 고친다', async () => {
+ const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
+ let patchResult: MockResult = ok(summary('d1', { mailboxIds: ['drafts'] }))
+ const requests = installMockApi(({ method, url }) => {
+ if (method === 'PATCH') return patchResult
+ if (url === '/mailboxes') {
+ return ok({ items: [INBOX, DRAFTS, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
+ }
+ if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
+ return page([summary('m1')])
+ })
+ const store = useMailStore()
+ await store.loadMailboxes()
+ await store.openMailbox('trash')
+ const listsBefore = requests.filter((request) => request.url === '/messages').length
+
+ await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(true)
+ await flushPromises()
+
+ const patch = requests.find((request) => request.method === 'PATCH')
+ expect(patch?.url).toBe('/messages/d1')
+ expect(patch?.data).toEqual({ mailboxId: 'drafts' })
+ expect(requests.filter((request) => request.url === '/messages').length).toBe(listsBefore + 1)
+ expect(requests.filter((request) => request.url === '/mailboxes').length).toBe(2)
+
+ patchResult = fail(404, 'RES_001')
+ await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(false)
+ })
+
describe('사용량 (GET /mail-quota)', () => {
const QUOTA = {
usedBytes: 966_367_641,
@@ -313,6 +342,22 @@ describe('mail.store', () => {
})
})
+ it('다시 저장해 바뀐 임시 저장본 id 를 끝까지 따라간다 — 모르는 id 는 그대로, 사용자가 바뀌면 잊는다', async () => {
+ const store = useMailStore()
+ store.recordDraftSuccessor('d1', 'd2')
+ store.recordDraftSuccessor('d2', 'd3')
+ store.recordDraftSuccessor('d3', 'd3')
+
+ expect(store.latestDraftId('d1')).toBe('d3')
+ expect(store.latestDraftId('d2')).toBe('d3')
+ expect(store.latestDraftId('d3')).toBe('d3')
+ expect(store.latestDraftId('other')).toBe('other')
+
+ useAuthStore().expireSession()
+ await nextTick()
+ expect(store.latestDraftId('d1')).toBe('d1')
+ })
+
it('로그아웃·세션 만료로 사용자가 바뀌면 이전 사용자의 메일을 비운다', async () => {
installMockApi(() => page([summary('m1')]))
const store = useMailStore()
diff --git a/frontend/src/stores/mail.store.ts b/frontend/src/stores/mail.store.ts
index 599df5d..a3cadc4 100644
--- a/frontend/src/stores/mail.store.ts
+++ b/frontend/src/stores/mail.store.ts
@@ -63,6 +63,13 @@ export const useMailStore = defineStore('mail', () => {
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
const lastBrowseLocation = ref(null)
+ /**
+ * 이 탭에서 다시 저장해 바뀐 임시 저장본 id — 옛 id → 새 id (계약 2절 "임시 저장": 저장할 때마다 id 가 바뀌고 옛것은 지운다).
+ * 방문 기록에 남은 옛 주소(?draft=<옛 id>)로 돌아오면 쓰기 화면이 마지막 저장본을 연다 — 떠나기 전 저장처럼
+ * 주소를 맞추지 못한 채 떠난 경우다 (떠나는 중에 주소를 바꾸면 가던 이동이 취소된다). 화면에 그리지 않으므로 반응형이 아니다
+ */
+ const draftSuccessors = new Map()
+
let mailboxSeq = 0
let quotaSeq = 0
let listSeq = 0
@@ -105,10 +112,10 @@ export const useMailStore = defineStore('mail', () => {
let mailboxLoadsInFlight = 0
/**
- * 메일함 목록.
+ * 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
*/
- async function loadMailboxes(options: { background?: boolean } = {}): Promise {
+ async function loadMailboxes(options: { background?: boolean } = {}): Promise {
mailboxSeq += 1
const seq = mailboxSeq
if (!options.background) {
@@ -118,16 +125,19 @@ export const useMailStore = defineStore('mail', () => {
mailboxLoadsInFlight += 1
try {
const result = await mailApi.listMailboxes({ silent: true })
- if (seq !== mailboxSeq) return
+ if (seq !== mailboxSeq) return null
mailboxes.value = result.items
mailboxesStatus.value = 'ready'
mailboxesError.value = null
+ return null
} catch (error) {
- if (seq !== mailboxSeq) return
+ const apiError = toApiError(error)
+ if (seq !== mailboxSeq) return apiError
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
- if (options.background && mailboxesStatus.value === 'ready') return
+ if (options.background && mailboxesStatus.value === 'ready') return apiError
mailboxesStatus.value = 'error'
- mailboxesError.value = toApiError(error)
+ mailboxesError.value = apiError
+ return apiError
} finally {
mailboxLoadsInFlight -= 1
}
@@ -341,10 +351,45 @@ export const useMailStore = defineStore('mail', () => {
return next?.id ?? null
}
+ /**
+ * 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
+ * 개수를 서버 기준으로 다시 맞추고, 지금 보이는 목록이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
+ * 실패하면 false (알림은 인터셉터가 띄웠다).
+ */
+ async function moveMessage(id: string, mailboxId: string): Promise {
+ try {
+ await mailApi.updateMessage(id, { mailboxId })
+ } catch {
+ return false
+ }
+ syncCounts()
+ const shown = currentMailboxId.value
+ const trashId = mailboxByRole('trash')?.id ?? null
+ if (shown !== null && (shown === mailboxId || shown === trashId)) void openMailbox(shown)
+ return true
+ }
+
function rememberBrowseLocation(fullPath: string) {
lastBrowseLocation.value = fullPath
}
+ /** 임시 저장본을 다시 저장해 새 id 를 받았다 — 옛 id 로 열면 새 id 를 연다 */
+ function recordDraftSuccessor(previousId: string, nextId: string) {
+ if (previousId !== nextId) draftSuccessors.set(previousId, nextId)
+ }
+
+ /** 이 탭이 아는 마지막 저장본 id — 다시 저장한 적 없으면 그대로 */
+ function latestDraftId(id: string): string {
+ let latest = id
+ // 새 id 는 늘 새것이라 고리가 생기지 않지만, 만에 하나를 위해 기록 수만큼만 따라간다
+ for (let step = 0; step < draftSuccessors.size; step += 1) {
+ const next = draftSuccessors.get(latest)
+ if (next === undefined) break
+ latest = next
+ }
+ return latest
+ }
+
function reset() {
mailboxSeq += 1
quotaSeq += 1
@@ -366,6 +411,7 @@ export const useMailStore = defineStore('mail', () => {
selectedStatus.value = 'idle'
selectedError.value = null
lastBrowseLocation.value = null
+ draftSuccessors.clear()
}
// 로그아웃·세션 만료·다른 계정 로그인 때 이전 사용자의 메일이 화면에 남지 않게 비운다
@@ -411,7 +457,10 @@ export const useMailStore = defineStore('mail', () => {
markRead,
toggleFlag,
removeMessage,
+ moveMessage,
rememberBrowseLocation,
+ recordDraftSuccessor,
+ latestDraftId,
reset,
}
})
diff --git a/frontend/src/stores/toast.store.spec.ts b/frontend/src/stores/toast.store.spec.ts
new file mode 100644
index 0000000..d6dcc65
--- /dev/null
+++ b/frontend/src/stores/toast.store.spec.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+
+import ToastHost from '@/components/feedback/ToastHost.vue'
+import { createTestRouter } from '@/test-utils/router'
+import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
+
+// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
+
+describe('toast.store', () => {
+ let pinia: Pinia
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ useToastStore().clear()
+ vi.useRealTimers()
+ })
+
+ const messages = () => useToastStore().toasts.map((toast) => toast.message)
+ const undo = { label: '되돌리기', run: () => undefined }
+
+ it('단추 없는 알림은 종류별 시간, 단추가 있는 알림은 10초 보인다', () => {
+ const store = useToastStore()
+ store.show({ kind: 'success', message: '보냈습니다' })
+ store.show({ kind: 'success', message: '옮겼습니다', action: undo })
+
+ vi.advanceTimersByTime(TOAST_DURATION_MS.success)
+ expect(messages()).toEqual(['옮겼습니다'])
+ vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - TOAST_DURATION_MS.success - 1)
+ expect(messages()).toEqual(['옮겼습니다'])
+ vi.advanceTimersByTime(1)
+ expect(messages()).toEqual([])
+ })
+
+ it('붙잡은 동안은 닫지 않고, 까닭이 모두 사라지면 시간을 처음부터 다시 센다', () => {
+ const store = useToastStore()
+ const id = store.show({ kind: 'success', message: '옮겼습니다', action: undo })
+
+ vi.advanceTimersByTime(9_000)
+ store.hold(id, 'pointer')
+ store.hold(id, 'focus')
+ vi.advanceTimersByTime(60_000)
+ expect(messages()).toEqual(['옮겼습니다'])
+
+ store.release(id, 'pointer')
+ vi.advanceTimersByTime(60_000)
+ expect(messages()).toEqual(['옮겼습니다'])
+
+ store.release(id, 'focus')
+ vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - 1)
+ expect(messages()).toEqual(['옮겼습니다'])
+ vi.advanceTimersByTime(1)
+ expect(messages()).toEqual([])
+ })
+
+ it('ToastHost — 마우스가 올라가 있거나 초점이 안에 있는 동안은 닫히지 않는다 (알림 안에서 옮긴 초점은 그대로)', async () => {
+ const router = createTestRouter()
+ const wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, router] } })
+ useToastStore().show({ kind: 'success', message: '옮겼습니다', action: undo })
+ await wrapper.vm.$nextTick()
+ const toast = wrapper.get('.toast')
+
+ await toast.trigger('mouseenter')
+ vi.advanceTimersByTime(60_000)
+ expect(messages()).toEqual(['옮겼습니다'])
+ await toast.trigger('focusin')
+ await toast.trigger('mouseleave')
+ // 알림 안의 단추로 초점이 옮겨 갔다 — 놓지 않는다
+ await toast.trigger('focusout', { relatedTarget: toast.get('.toast__action').element })
+ vi.advanceTimersByTime(60_000)
+ expect(messages()).toEqual(['옮겼습니다'])
+
+ await toast.trigger('focusout', { relatedTarget: document.body })
+ vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS)
+ expect(messages()).toEqual([])
+ wrapper.unmount()
+ })
+})
diff --git a/frontend/src/stores/toast.store.ts b/frontend/src/stores/toast.store.ts
index 1004cba..153b49c 100644
--- a/frontend/src/stores/toast.store.ts
+++ b/frontend/src/stores/toast.store.ts
@@ -4,13 +4,13 @@ import type { RouteLocationRaw } from 'vue-router'
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
+// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
+// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
export type ToastKind = 'error' | 'success'
-export interface ToastAction {
- label: string
- to: RouteLocationRaw
-}
+/** 알림의 단추 — 다른 화면으로 가는 링크(to) 또는 그 자리에서 하는 일(run, 예: 되돌리기). 누르면 알림을 닫는다 */
+export type ToastAction = { label: string; to: RouteLocationRaw } | { label: string; run: () => void }
export interface Toast {
id: number
@@ -38,9 +38,18 @@ export const TOAST_DURATION_MS: Readonly> = {
success: 5000,
}
+/** 단추가 있는 알림(되돌리기 · 보낸편지함 보기 등)이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히 둔다 */
+export const TOAST_ACTION_DURATION_MS = 10_000
+
+/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
+export type ToastHoldReason = 'pointer' | 'focus'
+
export const useToastStore = defineStore('toast', () => {
const toasts = ref([])
const timers = new Map>()
+ /** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
+ const durations = new Map()
+ const holds = new Map>()
let nextId = 1
function stopTimer(id: number) {
@@ -49,11 +58,45 @@ export const useToastStore = defineStore('toast', () => {
timers.delete(id)
}
- function dismiss(id: number) {
+ function startTimer(id: number) {
stopTimer(id)
+ const duration = durations.get(id)
+ if (duration === undefined) return
+ timers.set(
+ id,
+ setTimeout(() => dismiss(id), duration),
+ )
+ }
+
+ /** 알림을 잊는다 — 타이머 · 붙잡은 까닭까지 */
+ function forget(id: number) {
+ stopTimer(id)
+ durations.delete(id)
+ holds.delete(id)
+ }
+
+ function dismiss(id: number) {
+ forget(id)
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
+ /** 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 */
+ function hold(id: number, reason: ToastHoldReason) {
+ if (!durations.has(id)) return
+ const reasons = holds.get(id) ?? new Set()
+ reasons.add(reason)
+ holds.set(id, reasons)
+ stopTimer(id)
+ }
+
+ /** 놓는다 — 붙잡은 까닭이 모두 사라지면 그 알림의 시간을 처음부터 다시 센다 */
+ function release(id: number, reason: ToastHoldReason) {
+ const reasons = holds.get(id)
+ if (!reasons?.delete(reason) || reasons.size > 0) return
+ holds.delete(id)
+ startTimer(id)
+ }
+
function show(input: ToastInput): number {
const id = nextId
nextId += 1
@@ -67,21 +110,21 @@ export const useToastStore = defineStore('toast', () => {
}
const all = [...toasts.value, next]
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
- overflow.forEach((toast) => stopTimer(toast.id))
+ overflow.forEach((toast) => forget(toast.id))
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
- timers.set(
- id,
- setTimeout(() => dismiss(id), TOAST_DURATION_MS[input.kind]),
- )
+ durations.set(id, next.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[input.kind])
+ startTimer(id)
return id
}
function clear() {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
+ durations.clear()
+ holds.clear()
toasts.value = []
}
- return { toasts, show, dismiss, clear }
+ return { toasts, show, dismiss, hold, release, clear }
})
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index 81ce362..dc76633 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -63,6 +63,35 @@ a:hover {
outline: none;
}
+/*
+ * 둘 · 셋 중 하나를 고르는 전환 단추 — 설정 화면의 그림 크기 · 미리 볼 메일 (디자인 원본 Settings 의 .seg)
+ * 회색 바탕 묶음 안에서 고른 칸만 흰 바탕 + 그림자. 단추 높이 36 — 캔버스 확정값, 44px 기준의 밀도 예외(--size-segment-item-lg).
+ * 사용:
+ */
+.segmented {
+ display: inline-flex;
+ gap: var(--space-2);
+ padding: var(--space-2);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-muted);
+}
+
+.segmented__item {
+ min-height: var(--size-segment-item-lg);
+ padding: 0 var(--space-12);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-weight: 600;
+}
+
+.segmented__item[aria-pressed='true'] {
+ background: var(--color-surface);
+ color: var(--color-text);
+ box-shadow: var(--shadow-segment);
+}
+
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
.visually-hidden {
position: absolute;
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index bf3958f..501fe2a 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -22,6 +22,7 @@
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
+ --font-size-title-lg: 1.125rem; /* 18px — 설정 화면 제목 (디자인 원본 Settings) */
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
--font-size-display: 1.875rem; /* 30px — 로그인 소개 문구 */
@@ -73,7 +74,8 @@
--color-danger-surface: #fef3f2;
--color-danger-border: #fbd7d3;
--color-warning-surface: #fff7e6;
- --color-warning-text: #7a5200;
+ --color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
+ --color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
--color-star: #b7791f;
/* ── 간격 (이름의 숫자 = px) ─────────────── */
@@ -116,16 +118,19 @@
--shadow-toast: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.12);
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
+ --shadow-segment: 0 0.0625rem 0.125rem rgba(16, 24, 28, 0.14); /* 전환 단추의 고른 칸 (디자인 원본 Settings) */
--color-backdrop: rgba(11, 54, 64, 0.32);
/* ── 크기 ───────────────────────────────── */
--size-touch: 2.75rem; /* 44px — 누를 수 있는 최소 영역 */
/*
* 밀도를 위한 작은 조작 영역 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "디자인 원칙", 사용자 확정).
- * 목록 표의 별 버튼(폭 32px · 행 높이를 채움)과 "분할 | 목록" 전환(바깥 36px)에만 쓴다.
+ * 목록 표의 별 버튼(폭 32px · 행 높이를 채움), "분할 | 목록" 전환(바깥 36px),
+ * 설정 화면의 전환 단추 · 서명 그림의 크기 줄(작게 · 보통 · 원본 · 그림 빼기 — 36px, 캔버스 확정값)에만 쓴다.
*/
--size-compact-control: 2rem; /* 32px */
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
+ --size-segment-item-lg: 2.25rem; /* 36px — 밀도 예외: 설정 화면의 전환 단추(그림 크기 · 미리 볼 메일) · 그림 빼기 (디자인 원본 Settings) */
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
--size-rail-item: 3.75rem; /* 60px */
diff --git a/frontend/src/test-utils/manual-clock.ts b/frontend/src/test-utils/manual-clock.ts
new file mode 100644
index 0000000..adfa6ce
--- /dev/null
+++ b/frontend/src/test-utils/manual-clock.ts
@@ -0,0 +1,63 @@
+// 단위 테스트 전용 — 손으로 돌리는 시계. advance(ms) 를 부를 때만 시간이 흐르고 그 사이에 닿는 타이머를 차례로 부른다.
+// 화면에는 CLOCK 으로 provide 한다: mount(Page, { global: { provide: { [CLOCK as symbol]: clock } } })
+// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
+import type { Clock, ClockTimer } from '@/composables/useClock'
+
+interface PendingTimer {
+ id: symbol
+ due: number
+ /** 같은 시각이면 먼저 건 것부터 */
+ order: number
+ callback: () => void
+}
+
+export interface ManualClock extends Clock {
+ /** 시간을 ms 만큼 넘긴다 — 그 사이에 닿는 타이머(넘기는 동안 새로 건 것 포함)를 시각 순서대로 부른다 */
+ advance: (ms: number) => void
+ /** 아직 부르지 않은 타이머 수 */
+ pendingCount: () => number
+}
+
+export function createManualClock(start = 0): ManualClock {
+ let current = start
+ let nextOrder = 0
+ const timers: PendingTimer[] = []
+
+ function nextDue(limit: number): PendingTimer | null {
+ let found: PendingTimer | null = null
+ for (const timer of timers) {
+ if (timer.due > limit) continue
+ if (!found || timer.due < found.due || (timer.due === found.due && timer.order < found.order)) found = timer
+ }
+ return found
+ }
+
+ return {
+ now: () => current,
+ setTimeout: (callback, ms) => {
+ const timer: PendingTimer = {
+ id: Symbol('timer'),
+ due: current + Math.max(0, ms),
+ order: nextOrder,
+ callback,
+ }
+ nextOrder += 1
+ timers.push(timer)
+ return timer.id
+ },
+ clearTimeout: (handle: ClockTimer) => {
+ const index = timers.findIndex((timer) => timer.id === handle)
+ if (index >= 0) timers.splice(index, 1)
+ },
+ advance: (ms) => {
+ const target = current + ms
+ for (let timer = nextDue(target); timer; timer = nextDue(target)) {
+ timers.splice(timers.indexOf(timer), 1)
+ current = Math.max(current, timer.due)
+ timer.callback()
+ }
+ current = target
+ },
+ pendingCount: () => timers.length,
+ }
+}
diff --git a/frontend/src/test-utils/outgoing-style-samples.ts b/frontend/src/test-utils/outgoing-style-samples.ts
index 7b327ef..e307272 100644
--- a/frontend/src/test-utils/outgoing-style-samples.ts
+++ b/frontend/src/test-utils/outgoing-style-samples.ts
@@ -122,3 +122,56 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['text-decoration', 'underline', false],
]
+
+/** 표본 그림 id — 서버가 만드는 UUID 모양 */
+export const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
+
+/**
+ * 그림(img) 속성 표본 — [속성, 값, 서버가 남기는가]. 계약 2절 "서식 본문"의 그림 예외 (서명 그림만).
+ * src 가 남지 않으면 그림째 지운다. width · alt 가 남지 않으면 그 속성만 지운다.
+ * ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.spec.ts · sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
+ * 서버는 여기에 더해 id 가 보내는 사람의 그림인지 본다 (표의 id 는 보내는 사람의 그림이라고 본다).
+ */
+export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'alt', value: string, kept: boolean])[] = [
+ // 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`, true],
+ ['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
+ ['src', '', false],
+ ['src', '/api/mail-signature/images/', false],
+ ['src', '/api/mail-signature/images/not-a-uuid', false],
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
+ ['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
+ ['src', `https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
+ ['src', `//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
+ ['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
+ ['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
+ ['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
+ ['src', `cid:${SAMPLE_IMAGE_ID}`, false],
+ ['src', 'data:image/png;base64,iVBORw0KGgo=', false],
+ ['src', 'https://evil.example/t.png', false],
+ // 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
+ ['width', '1', true],
+ ['width', '96', true],
+ ['width', '144', true],
+ ['width', '600', true],
+ ['width', ' 144 ', true],
+ ['width', '0', false],
+ ['width', '601', false],
+ ['width', '1000', false],
+ ['width', '096', false],
+ ['width', '-1', false],
+ ['width', '+5', false],
+ ['width', '14.5', false],
+ ['width', '144px', false],
+ ['width', '50%', false],
+ ['width', '1e2', false],
+ ['width', '', false],
+ // 설명 — 200자 이하면 그대로 (빈 글자 포함)
+ ['alt', '', true],
+ ['alt', '회사 로고', true],
+ ['alt', 'ㄱ'.repeat(200), true],
+ ['alt', 'ㄱ'.repeat(201), false],
+]
diff --git a/frontend/src/test-utils/router.ts b/frontend/src/test-utils/router.ts
index 3fac003..957dc62 100644
--- a/frontend/src/test-utils/router.ts
+++ b/frontend/src/test-utils/router.ts
@@ -28,6 +28,13 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
+ { path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
+ {
+ path: '/settings/mail/signature',
+ name: 'settings-mail-signature',
+ component: Empty,
+ meta: { requiresAuth: true },
+ },
],
})
}
|