feat(frontend): 메일 쓰기 서식 편집기와 입력칸 포커스 통일

메일 쓰기 화면은 입력칸마다 포커스 모양이 달랐고, 본문은 서식 없는 글만 쓸 수 있었다.

- 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)의 포커스를 아래 선 하나(.compose-field)로 통일했다
- 본문을 TipTap 서식 편집기로 바꿨다 — 글꼴·크기·굵게·색·목록·링크·정렬·들여쓰기·인용·표·가로줄, 단축키,
  좁은 폭에서 묶음째 접는 툴바. 편집기는 쓰기 화면에서 따로 불러온다(지연 로딩)
- 보낼 때는 기본 글꼴(맑은 고딕) div 와 인라인 style 의 HTML 에 텍스트 대체 본문을 함께 보낸다 (여러 칸 공백은 nbsp, 탭은 8칸 자리까지)
- 붙여 넣은 글·원문 인용은 서버 정화와 같은 값 형식만 읽는다 — 편집기에서 숨은 글이 받는 사람에게 보이지 않게
- 답장·전달 인용은 "원본 메일" 머리와 함께 서식째 옮긴다. 초안은 내 글 몫 5%를 남기고, 보내기 직전에 다시 재서 인용을 줄인다
- 본문에 붙여 넣거나 끌어다 놓은 이미지·파일은 첨부로 올린다 — 화면 어디에 놓아도 (브라우저가 파일을 열어 쓰던 메일을 잃지 않게)

의존성: @tiptap/* 3.31.4 (MIT 무료 패키지만, 모두 같은 판으로 고정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 07:49:40 +09:00
parent 8fc73aa541
commit f77179a508
63 changed files with 8310 additions and 387 deletions
@@ -0,0 +1,193 @@
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 { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
/**
* 편집기 청크 내려받기를 마음대로 — 실패시키거나(첫 번째만) 사용자가 놓아 줄 때까지 붙잡는다.
* ComposePage 는 mail-editor-handle.ts 의 loadMailEditor 로 청크를 받는다.
*/
const loader = vi.hoisted(() => ({
mode: 'normal' as 'normal' | 'fail-once' | 'hold',
calls: 0,
release: null as null | (() => void),
}))
vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/components/mail/editor/mail-editor-handle')>()
return {
...actual,
loadMailEditor: () => {
loader.calls += 1
if (loader.mode === 'fail-once' && loader.calls === 1) {
return Promise.reject(new Error('Failed to fetch dynamically imported module'))
}
if (loader.mode === 'hold') {
return new Promise((resolve) => {
loader.release = () => resolve(actual.loadMailEditor())
})
}
return actual.loadMailEditor()
},
}
})
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const EDITOR_LOAD_TIMEOUT_MS = 10_000
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: '<m1@arasystem.it.kr>',
references: [],
htmlBody: null,
textBody: '김아라님, 안녕하세요.',
hasBlockedImages: false,
attachments: [],
}
const api: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
describe('ComposePage — 서식 편집기 청크', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
const loadErrors: unknown[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
loader.mode = 'normal'
loader.calls = 0
loader.release = null
loadErrors.length = 0
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
useToastStore().clear()
restoreApiAdapter()
})
async function mountCompose(path: string): Promise<VueWrapper> {
installMockApi(api)
const router = createTestRouter()
await router.push(path)
await router.isReady()
wrapper = mount(ComposePage, {
attachTo: document.body,
global: {
plugins: [pinia, router],
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
config: { errorHandler: (error) => loadErrors.push(error) },
},
})
await flushPromises()
return wrapper
}
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
async function releaseEditor() {
await vi.waitFor(() => {
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
})
loader.release?.()
}
async function editorLoaded(view: VueWrapper) {
await vi.waitFor(
() => {
if (!view.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
}
it('청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도" — 보내기는 막힌다. 다시 시도하면 편집기가 뜬다', async () => {
loader.mode = 'fail-once'
const view = await mountCompose('/mail/compose')
await vi.waitFor(() => {
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
})
const alert = view.get('.compose__editor [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
// 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
expect(alert.attributes('initial-html')).toBeUndefined()
expect(loadErrors).toHaveLength(1)
const retry = alert.findAll('button').find((button) => button.text() === '다시 시도')
// 누르면 단추에 초점이 간다 (브라우저처럼)
;(retry?.element as HTMLElement | undefined)?.focus()
await retry?.trigger('click')
await editorLoaded(view)
expect(loader.calls).toBe(2)
expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
// 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
await vi.waitFor(() => {
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
})
})
it('답장 — 청크가 늦게 와도 이미 다른 칸(제목)에 쓰고 있으면 본문으로 초점을 빼앗지 않는다', async () => {
loader.mode = 'hold'
const view = await mountCompose('/mail/compose?replyTo=m1')
const subject = view.get('#compose-subject').element as HTMLInputElement
subject.focus()
await releaseEditor()
await editorLoaded(view)
await flushPromises()
expect(document.activeElement).toBe(subject)
})
it('답장 — 아무 칸에도 초점이 없으면 편집기가 준비될 때 본문 맨 위로', async () => {
loader.mode = 'hold'
const view = await mountCompose('/mail/compose?replyTo=m1')
;(document.activeElement as HTMLElement | null)?.blur()
await releaseEditor()
await editorLoaded(view)
await vi.waitFor(() => {
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
})
expect(document.activeElement?.id).toBe('compose-body')
})
})
+305 -10
View File
@@ -1,8 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 {
MAIL_BODY_MAX_LENGTH,
MAIL_QUOTE_OMITTED_MARK,
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -17,10 +23,13 @@ import {
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
const EDITOR_LOAD_TIMEOUT_MS = 10_000
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
const ORIGINAL: MessageDetail = {
@@ -51,6 +60,12 @@ const ORIGINAL: MessageDetail = {
describe('ComposePage', () => {
let pinia: Pinia
beforeAll(installProseMirrorDom)
// 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간(한꺼번에 돌리면 수 초)이 첫 테스트의 시간 한도(기본 5초)에 잡히지 않게
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
@@ -60,13 +75,19 @@ describe('ComposePage', () => {
vi.setSystemTime(clock)
})
/** 이 테스트에서 띄운 화면 — 쓰기 화면은 window 에 파일 끌기 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
const mountedWrappers: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mountedWrappers.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
/** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
async function mountCompose(path: string, handler: MockHandler, attach = false) {
const requests = installMockApi(handler)
const router = createTestRouter()
@@ -76,10 +97,28 @@ describe('ComposePage', () => {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router] },
})
mountedWrappers.push(wrapper)
await flushPromises()
await editorLoaded(wrapper)
return { wrapper, router, requests }
}
/** 서식 편집기는 지연 로딩한다 — 불러와 준비될 때까지 기다린다 (처음 불러올 때는 변환에 시간이 걸린다) */
async function editorLoaded(wrapper: VueWrapper) {
await vi.waitFor(
() => {
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
}
/** 본문 편집 영역에 보이는 글 */
function bodyText(wrapper: VueWrapper): string {
return wrapper.get('#compose-body').text()
}
function chips(wrapper: VueWrapper, inputId: string): string[] {
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
@@ -105,10 +144,25 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Re: 메일 서버 이전 일정 공유',
)
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
expect(body).toContain('──────────')
expect(body).toContain('이도윤 <doyun.lee@arasystem.it.kr> 작성:')
expect(body).toContain('> 김아라님, 안녕하세요.')
// 원문은 "원본 메일" 머리(보낸 사람 · 날짜 · 제목)와 함께 인용 막대 안에 (디자인 README "원문 인용")
const quote = wrapper.get('#compose-body blockquote[data-mail-quote]')
expect(quote.get('p[data-quote-header]').text()).toBe('원본 메일')
expect(quote.get('table[data-quote-header]').text()).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
expect(quote.text()).toContain('김아라님, 안녕하세요.')
expect(quote.text()).not.toContain('받는 사람')
})
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
await vi.waitFor(
() => {
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
expect(editorOf(wrapper.element).state.selection.from).toBe(1)
wrapper.unmount()
})
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
@@ -130,7 +184,11 @@ describe('ComposePage', () => {
cc: [],
bcc: [],
subject: 'Re: 메일 서버 이전 일정 공유',
// 서식 본문과 같은 내용의 텍스트 — 인용은 "> " (계약 2절 "서식 본문")
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
bodyHtml: expect.stringMatching(
/^<div style="font-family: 'Malgun Gothic'.*<blockquote style="margin: 24px 0 0; .*원본 메일.*<\/div>$/,
),
attachments: [],
inReplyTo: 'm1',
})
@@ -202,10 +260,13 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Fwd: 메일 서버 이전 일정 공유',
)
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
expect(body).toContain('전달된 메일')
expect(body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
expect(body).toContain('김아라님, 안녕하세요.')
// 전달 머리에는 받는 사람 · 참조도 있다
const header = wrapper.get('#compose-body table[data-quote-header]').text()
expect(bodyText(wrapper)).toContain('원본 메일')
expect(header).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
expect(header).toContain('받는 사람김아라 <ara.kim@arasystem.it.kr>')
expect(header).toContain('참조인프라팀 <infra@arasystem.it.kr>')
expect(bodyText(wrapper)).toContain('김아라님, 안녕하세요.')
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
})
@@ -399,6 +460,240 @@ describe('ComposePage', () => {
expect(sendRequest(requests)).toBeUndefined()
})
describe('서식 본문 (계약 2절 "서식 본문")', () => {
const uploadApi: MockHandler = (request) => {
if (request.method === 'POST' && request.url === '/mail-attachments') {
const file = (request.data as FormData).get('file') as File
return ok({ id: `up-${file.name}`, name: file.name, type: file.type, size: file.size }, 201)
}
return replyApi(request)
}
async function addRecipient(wrapper: VueWrapper) {
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
}
function uploadedNames(requests: MockRequest[]): string[] {
return requests
.filter((request) => request.method === 'POST' && request.url === '/mail-attachments')
.map((request) => ((request.data as FormData).get('file') as File).name)
}
it('쓴 서식은 서식 본문(bodyHtml)으로, 같은 내용은 텍스트(body)로 함께 보낸다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
await addRecipient(wrapper)
editorOf(wrapper.element).commands.setContent('<p>확인했습니다.</p><ol><li><p>DNS 낮추기</p></li></ol>')
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
expect(payload?.body).toBe('확인했습니다.\n1. DNS 낮추기')
expect(payload?.bodyHtml).toBe(
`<div style="font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75">` +
'<p style="margin: 0">확인했습니다.</p>' +
'<ol style="margin: 0; padding-left: 24px"><li><p style="margin: 0">DNS 낮추기</p></li></ol></div>',
)
})
it('본문이 비었으면 서식 본문 없이 빈 텍스트로 보낸다 (텍스트 메일)', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
await addRecipient(wrapper)
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
expect(payload?.body).toBe('')
expect(payload).not.toHaveProperty('bodyHtml')
})
it('본문에 붙여 넣은 그림은 "붙여넣은 이미지 1.png" 첨부로 올리고 알린다 — 본문에는 넣지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
wrapper
.get('.ProseMirror')
.element.dispatchEvent(pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })] }))
await flushPromises()
expect(uploadedNames(requests)).toEqual(['붙여넣은 이미지 1.png'])
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['붙여넣은 이미지 1.png'])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
])
expect(wrapper.find('#compose-body img').exists()).toBe(false)
})
it('파일을 끄는 동안 본문 위에 덮개를 보이고, 쓰기 화면 어디에 놓아도 첨부로 올린다 — 이미지가 아니면 알리지 않는다', async () => {
// 끌기는 화면(window) 전체에서 받는다 — 문서에 붙여야 window 까지 올라간다
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
const report = new File(['pdf'], '보고서.pdf', { type: 'application/pdf' })
wrapper.get('#compose-subject').element.dispatchEvent(fileDragEvent('dragenter'))
await flushPromises()
expect(wrapper.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
// 첨부 영역에 놓는다 (본문 밖)
wrapper.get('.compose__attachments').element.dispatchEvent(fileDragEvent('drop', [report]))
await flushPromises()
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
expect(uploadedNames(requests)).toEqual(['보고서.pdf'])
expect(useToastStore().toasts).toEqual([])
// 칸 사이를 지나도 깜빡이지 않는다 — 들어온 만큼 나가야(쓰기 화면 밖으로) 끈다
const subjectInput = wrapper.get('#compose-subject').element
const body = wrapper.get('.ProseMirror').element
subjectInput.dispatchEvent(fileDragEvent('dragenter'))
body.dispatchEvent(fileDragEvent('dragenter'))
subjectInput.dispatchEvent(fileDragEvent('dragleave'))
await flushPromises()
expect(wrapper.find('.mail-editor__drop').exists()).toBe(true)
body.dispatchEvent(fileDragEvent('dragleave', [], { relatedTarget: null }))
await flushPromises()
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
wrapper.unmount()
})
it('쓰기 칸(폼) 밖 빈 곳에 놓아도 첨부로 올리고 브라우저가 파일을 열지 않게 한다 — 화면을 떠나면 받지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
const report = new File(['pdf'], '회의록.pdf', { type: 'application/pdf' })
const over = fileDragEvent('dragover')
document.body.dispatchEvent(over)
const drop = fileDragEvent('drop', [report])
document.body.dispatchEvent(drop)
await flushPromises()
expect(over.defaultPrevented).toBe(true)
expect(drop.defaultPrevented).toBe(true)
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
wrapper.unmount()
const later = fileDragEvent('drop', [report])
document.body.dispatchEvent(later)
await flushPromises()
expect(later.defaultPrevented).toBe(false)
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
})
it('붙여 넣은 그림이 사전 검사에 걸리면(허용 밖 형식) 올리지 않고 "첨부로 올렸습니다" 알림도 띄우지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
wrapper
.get('.ProseMirror')
.element.dispatchEvent(pasteEvent({ files: [new File(['bmp'], 'image.bmp', { type: 'image/bmp' })] }))
await flushPromises()
expect(uploadedNames(requests)).toEqual([])
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
expect(useToastStore().toasts).toEqual([])
})
it('본문에 놓은 파일은 편집기가 넘긴 한 번만 올린다 (화면 전체의 놓기 처리는 건너뛴다)', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
const photo = new File(['png'], '현장.png', { type: 'image/png' })
wrapper.get('.ProseMirror').element.dispatchEvent(fileDragEvent('drop', [photo]))
await flushPromises()
expect(uploadedNames(requests)).toEqual(['현장.png'])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
])
wrapper.unmount()
})
it('입력칸마다 같은 포커스 표시 — 받는 사람 · 참조 · 숨은참조 · 제목 행과 본문 편집 영역에 compose-field', async () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.findAll('.compose-field')).toHaveLength(5)
expect(wrapper.get('#compose-subject').element.closest('.compose-field')).not.toBeNull()
expect(wrapper.get('#compose-body').element.closest('.compose-field')).not.toBeNull()
// 툴바(링크 팝오버의 입력칸)는 본문 선 밖이다 — 테두리 있는 칸은 고리를 쓴다
expect(wrapper.get('[role="toolbar"]').element.closest('.compose-field')).toBeNull()
})
})
describe('보내기 직전의 길이 — 제목 255 · 본문 텍스트 100,000 · 서식 500,000 (계약 2절)', () => {
async function addRecipient(wrapper: VueWrapper) {
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
}
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
await addRecipient(wrapper)
const editor = editorOf(wrapper.element)
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(sendRequest(requests)).toBeUndefined()
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(document.activeElement?.id).toBe('compose-body')
expect(useToastStore().toasts).toEqual([])
editor.commands.insertContent('나')
await flushPromises()
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
wrapper.unmount()
})
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
request.method === 'GET' && request.url === '/messages/m1' ? ok(longOriginal) : replyApi(request),
)
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
editorOf(wrapper.element).commands.insertContentAt(1, mine)
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = sendRequest(requests)?.data as { body: string; bodyHtml: string } | undefined
expect(payload?.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
await addRecipient(wrapper)
await wrapper.get('#compose-subject').setValue('제'.repeat(MAIL_SUBJECT_MAX_LENGTH + 1))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(sendRequest(requests)).toBeUndefined()
expect(wrapper.get('#compose-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#compose-subject').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('compose-subject')
wrapper.unmount()
})
it('제목에서 Enter 를 누르면 본문의 커서 자리 그대로 본문으로 간다', async () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi, true)
const editor = editorOf(wrapper.element)
editor.commands.setContent('<p>첫 줄</p><p>둘째 줄</p>')
editor.commands.setTextSelection(8)
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
await wrapper.get('#compose-subject').trigger('keydown', { key: 'Enter' })
expect(document.activeElement?.id).toBe('compose-body')
expect(editor.state.selection.from).toBe(8)
wrapper.unmount()
})
})
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+185 -51
View File
@@ -1,22 +1,33 @@
<script setup lang="ts">
// 메일 쓰기 (디자인 원본 Compose) — /mail/compose
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 인용 본문을 채운다.
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 본문 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
// 메일 쓰기 (디자인 원본 Compose · ComposeEditor) — /mail/compose
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 원문 인용을 채운다.
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 인용 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
import { computed, nextTick, ref, watch } from 'vue'
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
loadMailEditor,
type MailEditorHandle,
} from '@/components/mail/editor/mail-editor-handle'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
import { useMail } from '@/composables/useMail'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
COMPOSE_TITLES,
MAIL_ATTACHMENT_ACCEPT,
MAIL_BODY_MAX_LENGTH,
MAIL_MAX_ATTACHMENTS,
MAIL_MAX_RECIPIENTS,
MAIL_SENT_ACTION_LABEL,
@@ -24,7 +35,9 @@ import {
MAIL_SUBJECT_MAX_LENGTH,
type ComposeMode,
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import type { SendMessagePayload } from '@/shared/types/mail'
import { isImageFile } from '@/shared/utils/editor-files'
import {
createIdempotencyKey,
shouldRenewSendKey,
@@ -37,8 +50,20 @@ import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/**
* 서식 편집기 — TipTap 을 쓰기 화면에서만 내려받는다.
* 못 받으면 본문 자리에 사전 문구와 "다시 시도" — 다시 시도하면 편집기를 새로 만들어 청크를 다시 받는다 (MAIL_EDITOR_RETRY).
*/
const MailEditor = defineAsyncComponent({
loader: loadMailEditor,
errorComponent: ComposeEditorLoadError,
})
/** 받는 사람 오류 안내 문단 — 세 입력칸이 함께 가리킨다 */
const RECIPIENT_ERROR_ID = 'compose-recipient-error'
/** 길이 오류 안내 문단 — 제목 · 본문이 각각 가리킨다 */
const SUBJECT_ERROR_ID = 'compose-subject-error'
const BODY_ERROR_ID = 'compose-body-error'
const route = useRoute()
const router = useRouter()
@@ -68,7 +93,16 @@ const cc = ref<RecipientToken[]>([])
const bcc = ref<RecipientToken[]>([])
const showCopies = ref<boolean>(false)
const subject = ref<string>('')
const body = ref<string>('')
/** 편집기의 처음 내용 — 답장·전달의 원문 인용 (편집기를 새로 만들 때만 읽는다) */
const draftHtml = ref<string>('')
/** 처음 내용을 본문 한도에 맞출지 — 답장·전달 초안 */
const fitDraft = ref<boolean>(false)
/** 양식을 새로 채울 때마다 바꿔 편집기를 새로 만든다 (실행 취소 기록도 새로 시작한다) */
const editorKey = ref<number>(0)
/** 편집기를 다 불러왔다 — 그 전에는 본문을 읽을 수 없어 보내지 않는다 */
const editorReady = ref<boolean>(false)
/** 편집기가 준비되면 본문 맨 위로 커서를 옮긴다 (답장) */
let focusBodyWhenReady = false
/** 답장할 원문을 불러오는 중 — 그동안 입력을 막아 미리 채운 값이 사용자 입력을 덮지 않게 한다 */
const preparing = ref<boolean>(false)
const sending = ref<boolean>(false)
@@ -76,6 +110,8 @@ const sending = ref<boolean>(false)
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
const lengthError = ref<'subject' | 'body' | null>(null)
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -87,7 +123,8 @@ let sendKey = createIdempotencyKey()
const toField = ref<InstanceType<typeof RecipientField> | null>(null)
const ccField = ref<InstanceType<typeof RecipientField> | null>(null)
const bccField = ref<InstanceType<typeof RecipientField> | null>(null)
const bodyRef = ref<HTMLTextAreaElement | null>(null)
const editorRef = ref<MailEditorHandle | null>(null)
const subjectInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
@@ -127,10 +164,29 @@ const showRecipientError = computed(
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const lengthErrorMessage = getErrorMessage('VAL_001')
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
const canSend = computed(
() => !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
() =>
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
)
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
function renewEditor() {
editorKey.value += 1
editorReady.value = false
}
/**
* 편집기 청크를 못 받았을 때의 "다시 시도" — 편집기를 새로 만들어 다시 받는다.
* 누른 단추가 편집기로 바뀌며 사라지므로, 준비되면 초점을 본문으로 옮긴다 (다른 칸에 초점이 없을 때만).
*/
function retryEditor() {
focusBodyWhenReady = true
renewEditor()
}
provide(MAIL_EDITOR_RETRY, retryEditor)
function resetForm() {
to.value = []
@@ -138,9 +194,13 @@ function resetForm() {
bcc.value = []
showCopies.value = false
subject.value = ''
body.value = ''
draftHtml.value = ''
fitDraft.value = false
focusBodyWhenReady = false
renewEditor()
attempted.value = false
rejectedByServer.value = false
lengthError.value = null
resetUploads()
// 양식을 새로 채우면 다른 메일이다
sendKey = createIdempotencyKey()
@@ -159,7 +219,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
if (sourceMode === 'forward') {
const draft = buildForwardDraft(original)
subject.value = draft.subject
body.value = draft.body
draftHtml.value = draft.html
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
addExisting(draft.attachments)
} else {
@@ -168,25 +228,54 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
cc.value = draft.cc.map(tokenFromAddress)
showCopies.value = cc.value.length > 0
subject.value = draft.subject
body.value = draft.body
draftHtml.value = draft.html
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
focusBodyWhenReady = true
}
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
fitDraft.value = true
renewEditor()
} catch {
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
} finally {
if (seq === prepareSeq) preparing.value = false
}
if (seq !== prepareSeq) return
await nextTick()
if (sourceMode === 'forward') {
// 전달은 받는 사람부터 정한다
await nextTick()
toField.value?.focus()
return
}
// 답장은 커서를 본문 맨 앞(인용 위)에 둔다
bodyRef.value?.focus()
bodyRef.value?.setSelectionRange(0, 0)
}
/** 아직 어느 칸에도 초점이 없다 — 사용자가 받는 사람 · 제목에 쓰기 시작했으면 본문으로 옮기지 않는다 (한글 조합이 끊긴다) */
function isFocusFree(): boolean {
const active = document.activeElement
return active === null || active === document.body
}
function onEditorReady() {
editorReady.value = true
if (!focusBodyWhenReady) return
focusBodyWhenReady = false
void nextTick(() => {
if (isFocusFree()) editorRef.value?.focus('start')
})
}
/**
* 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올리고, 이미지를 첨부로 돌렸을 때만 알린다 (디자인 README "본문 이미지 → 첨부 파일").
* 사전 검사(형식 · 크기 · 개수)에 걸린 이미지는 올리지 않으므로 알리지 않는다 — 첨부 목록에 실패로 보인다.
*/
function attachFromBody(files: File[]) {
if (files.length === 0) return
const accepted = addFiles(files)
if (accepted.some(isImageFile)) toast.success(IMAGE_AS_ATTACHMENT_MESSAGE)
}
// 쓰기 화면이 떠 있는 동안 어디에 놓아도 첨부로 올린다 — 끄는 동안 본문 위에 덮개를 보인다
const { dragging } = useWindowFileDrop(attachFromBody)
function commitRecipientInputs() {
toField.value?.commit()
ccField.value?.commit()
@@ -217,14 +306,31 @@ async function onSubmit() {
toField.value?.focus()
return
}
if (!canSend.value) return
if (!canSend.value || !editorRef.value) return
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
const subjectText = subject.value.trim()
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
lengthError.value = 'subject'
subjectInputRef.value?.focus()
return
}
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
if (content === null) {
lengthError.value = 'body'
editorRef.value.focus()
return
}
lengthError.value = null
const payload: SendMessagePayload = {
to: to.value.map((token) => token.address),
cc: cc.value.map((token) => token.address),
bcc: bcc.value.map((token) => token.address),
subject: subject.value.trim(),
body: body.value,
subject: subjectText,
body: content.text,
...(content.html ? { bodyHtml: content.html } : {}),
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id 를 그대로 보낸다 (계약 2절 "전달")
attachments: uploaded.value.map(({ id, name }) => ({ id, name })),
// 스레드 연결은 답장만 — 전달에는 inReplyTo 를 넣지 않는다
@@ -268,11 +374,16 @@ async function onSubmit() {
}
}
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 */
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 — 커서는 본문에 있던 자리 그대로 (처음이면 맨 앞) */
function onSubjectEnter(event: KeyboardEvent) {
if (event.isComposing) return
event.preventDefault()
bodyRef.value?.focus()
editorRef.value?.focus()
}
/** 길이 안내는 그 칸을 고치면 내린다 */
function clearLengthError(field: 'subject' | 'body') {
if (lengthError.value === field) lengthError.value = null
}
function pickFiles() {
@@ -389,13 +500,14 @@ watch(allRecipients, () => {
<span>{{ recipientErrorMessage }}</span>
</p>
<div class="compose__row">
<div class="compose__row compose-field">
<label
for="compose-subject"
class="compose__label"
>제목</label>
<input
id="compose-subject"
ref="subjectInputRef"
v-model="subject"
type="text"
class="compose__subject"
@@ -403,20 +515,52 @@ watch(allRecipients, () => {
autocomplete="off"
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
:disabled="preparing"
:aria-invalid="lengthError === 'subject'"
:aria-describedby="lengthError === 'subject' ? SUBJECT_ERROR_ID : undefined"
@keydown.enter="onSubjectEnter"
@input="clearLengthError('subject')"
>
</div>
<p
v-if="lengthError === 'subject'"
:id="SUBJECT_ERROR_ID"
class="compose__alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
</p>
<textarea
ref="bodyRef"
v-model="body"
class="compose__body"
aria-label="본문"
rows="13"
placeholder="내용을 입력하세요"
:maxlength="MAIL_BODY_MAX_LENGTH"
:disabled="preparing"
/>
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
<div class="compose__editor">
<MailEditor
v-if="!preparing"
ref="editorRef"
:key="editorKey"
:initial-html="draftHtml"
:fit-to-limits="fitDraft"
:dragging="dragging"
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
@ready="onEditorReady"
@files="attachFromBody"
@change="clearLengthError('body')"
/>
</div>
<p
v-if="lengthError === 'body'"
:id="BODY_ERROR_ID"
class="compose__alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
</p>
<div class="compose__attachments">
<div class="compose__attach-bar">
@@ -587,33 +731,23 @@ watch(allRecipients, () => {
font-weight: 600;
}
.compose__body {
/*
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
*/
.compose__editor {
flex: 1 1 auto;
width: 100%;
min-height: 18.75rem;
max-width: var(--compose-body-max-width);
padding: var(--space-16) var(--content-inline);
border: 0;
resize: vertical;
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
/* 테두리 없는 넓은 입력칸이라 초점 표시는 1px 로 가볍게, 안쪽에 둔다 */
.compose__subject:focus-visible,
.compose__body:focus-visible {
outline-width: var(--border-width);
outline-offset: calc(var(--border-width) * -1);
display: flex;
flex-direction: column;
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min) + var(--border-width));
}
/* 위 선은 없다 — 편집 영역의 아래 선이 경계다 (디자인 README "글자 입력칸 포커스") */
.compose__attachments {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline) var(--space-14);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.compose__attach-bar {