feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람

- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창.
  보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다).
  대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다
- 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로)
- 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다.
  인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로
- 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행)
- 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:00:09 +09:00
parent fbfad8d0ca
commit bbd88e707a
39 changed files with 1912 additions and 196 deletions
@@ -8,7 +8,7 @@ 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 { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
/**
@@ -41,7 +41,11 @@ vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) =>
})
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const EDITOR_LOAD_TIMEOUT_MS = 10_000
/**
* 편집기 청크 · 편집기 자리를 기다리는 시간 — ComposePage.spec.ts 와 같은 값.
* 두 겹 부하에서 이 파일의 테스트가 12.3초까지 걸렸다 (2026-10-27) — 테스트 한도(vitest.config.ts, 20초)보다는 짧게 둔다.
*/
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const ORIGINAL: MessageDetail = {
id: 'm1',
@@ -109,6 +113,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
attachTo: document.body,
global: {
plugins: [pinia, router],
provide: routeViewProvide(router),
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
config: { errorHandler: (error) => loadErrors.push(error) },
},
@@ -117,11 +122,17 @@ describe('ComposePage — 서식 편집기 청크', () => {
return wrapper
}
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
/**
* 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤.
* (답장은 원문을 받아 초안을 만들고 편집기 자리를 다시 그린 뒤에야 내려받기를 시작한다 — 편집기 기다림과 같은 시간을 준다)
*/
async function releaseEditor() {
await vi.waitFor(() => {
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
})
await vi.waitFor(
() => {
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
loader.release?.()
}
@@ -139,9 +150,13 @@ describe('ComposePage — 서식 편집기 청크', () => {
loader.mode = 'fail-once'
const view = await mountCompose('/mail/compose')
await vi.waitFor(() => {
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
})
// 청크 내려받기가 실패로 끝나고 오류 화면이 그려질 때까지 — 편집기 기다림과 같은 시간을 준다
await vi.waitFor(
() => {
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
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()
+568 -9
View File
@@ -1,7 +1,14 @@
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 { defineComponent, h, type FunctionalComponent } from 'vue'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import {
AUTH_CHANNEL_NAME,
closeLogoutChannel,
useLogoutSync,
} from '@/composables/useLogoutBroadcast'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import {
MAIL_BODY_MAX_LENGTH,
@@ -13,6 +20,7 @@ import type { MessageDetail } from '@/shared/types/mail'
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 {
fail,
flushPromises,
@@ -24,12 +32,17 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { createTestRouter } from '@/test-utils/router'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
const EDITOR_LOAD_TIMEOUT_MS = 10_000
/**
* 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다.
* 전체 실행 두 개를 함께 돌린 부하에서 편집기를 띄우는 테스트가 12.8초까지 걸렸다 (2026-10-27) — 15초로 두되,
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬 편집기가 끝내 뜨지 않으면 이 기다림이 먼저 알린다.
*/
const EDITOR_LOAD_TIMEOUT_MS = 15_000
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
const ORIGINAL: MessageDetail = {
@@ -87,15 +100,20 @@ describe('ComposePage', () => {
vi.useRealTimers()
})
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
async function mountCompose(path: string, handler: MockHandler, attach = false) {
/**
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
*/
async function mountCompose(path: string, handler: MockHandler, attach = false, visited: readonly string[] = []) {
const requests = installMockApi(handler)
const router = createTestRouter()
// 방문 기록 — 뒤로 가기를 확인할 때 쓰기 화면 앞에 둘 화면들
for (const earlier of visited) await router.push(earlier)
await router.push(path)
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router] },
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
@@ -625,7 +643,7 @@ describe('ComposePage', () => {
await input.trigger('blur')
}
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(MAIL_007) — 본문으로 초점, 고치면 내린다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
await addRecipient(wrapper)
const editor = editorOf(wrapper.element)
@@ -635,8 +653,9 @@ describe('ComposePage', () => {
await flushPromises()
expect(sendRequest(requests)).toBeUndefined()
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('compose-body')
expect(useToastStore().toasts).toEqual([])
@@ -646,6 +665,45 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('서버가 본문을 MAIL_007 로 거절하면(정화 한도 등) 토스트 없이 같은 띠 · 잘못된 입력 · 본문으로 초점 — 줄여 다시 보내면 같은 키', async () => {
const outcomes: MockResult[] = [fail(400, 'MAIL_007'), ok({ id: 'sent-1' }, 201)]
const { wrapper, requests } = await mountCompose(
'/mail/compose',
(request) =>
request.method === 'POST' && request.url === '/messages'
? (outcomes.shift() ?? ok({ id: 'sent-2' }, 201))
: replyApi(request),
true,
)
await addRecipient(wrapper)
const editor = editorOf(wrapper.element)
editor.commands.setContent('<p>서식이 아주 복잡한 본문</p>')
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('compose-body')
expect(useToastStore().toasts).toEqual([])
// 줄이면 띠를 내리고, 다시 보내면 같은 키로 간다 (서버가 엔진에 넘기기 전에 거절했다)
editor.commands.setContent('<p>짧게 줄인 본문</p>')
await flushPromises()
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
await wrapper.get('form').trigger('submit')
await flushPromises()
const keys = requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
expect(keys).toHaveLength(2)
expect(keys[1]).toBe(keys[0])
wrapper.unmount()
})
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
@@ -662,7 +720,7 @@ describe('ComposePage', () => {
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)
})
}, LONG_DRAFT_TIMEOUT_MS)
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
@@ -847,4 +905,505 @@ describe('ComposePage', () => {
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
})
})
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
const DIALOG = '#compose-leave-dialog'
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const forwardApi: MockHandler = (request) =>
request.method === 'GET' && request.url === '/messages/m1'
? ok({ ...ORIGINAL, attachments: [CHECKLIST] })
: replyApi(request)
/** 이 탭에서 useLogoutSync 를 부르는 최소 화면 (App.vue 대신) */
const LogoutSyncHost = defineComponent({
setup() {
useLogoutSync()
return () => h('div')
},
})
afterEach(() => {
closeLogoutChannel()
FakeBroadcastChannel.closeAll()
vi.unstubAllGlobals()
})
function dialogOpen(wrapper: VueWrapper): boolean {
return wrapper.get(DIALOG).attributes('open') !== undefined
}
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
async function writeSubject(wrapper: VueWrapper, text: string) {
await wrapper.get('#compose-subject').setValue(text)
}
function choose(file: File, wrapper: VueWrapper) {
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
return picker.trigger('change')
}
it('손대지 않은 답장 초안은 바꾼 것이 아니다 — 편집기가 인용 뒤에 끝 빈 줄을 붙여도 묻지 않고 떠난다', async () => {
const { wrapper, router } = 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 },
)
await flushPromises()
// 초점을 옮기는 첫 거래에 편집기가 인용(맨 끝 블록) 뒤에 빈 문단을 붙였다
expect(editorOf(wrapper.element).state.doc.lastChild?.type.name).toBe('paragraph')
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
wrapper.unmount()
})
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
expect(blocksUnload()).toBe(false)
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(true)
void router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
})
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
await writeSubject(wrapper, '일정 공유')
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
subjectInput.focus()
const navigation = router.push('/mail/inbox')
await flushPromises()
const dialog = wrapper.get(DIALOG)
expect(dialogOpen(wrapper)).toBe(true)
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-stay').text()).toBe('계속 쓰기')
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('compose-leave-stay')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
await wrapper.get('#compose-leave-stay').trigger('click')
await navigation
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(subjectInput.value).toBe('일정 공유')
expect(document.activeElement).toBe(subjectInput)
wrapper.unmount()
})
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__cancel').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(dialogOpen(wrapper)).toBe(false)
})
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '일정 공유')
const navigation = router.push('/mail/inbox')
await flushPromises()
const cancel = new Event('cancel', { cancelable: true })
wrapper.get(DIALOG).element.dispatchEvent(cancel)
await navigation
await flushPromises()
expect(cancel.defaultPrevented).toBe(true)
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
})
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
await writeSubject(wrapper, '초안')
router.back()
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(router.options.history.location).toBe('/mail/compose')
router.back()
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-confirm').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(router.options.history.location).toBe('/mail/inbox')
})
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '일정 공유')
const first = router.push('/mail/inbox')
await flushPromises()
const second = router.push('/mail/sent')
await flushPromises()
expect(wrapper.findAll('dialog[open]')).toHaveLength(1)
await wrapper.get('#compose-leave-confirm').trigger('click')
await Promise.all([first, second])
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
expect(dialogOpen(wrapper)).toBe(false)
})
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
await writeSubject(wrapper, 'Re: 고친 제목')
const navigation = router.push('/mail/compose')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
await navigation
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('Re: 고친 제목')
})
it('받는 사람 칸에 쓰던 글 · 본문 · 첨부(올리는 중 포함)도 바꿈이다 — 지우거나 되돌리면 묻지 않는다', async () => {
// 올리기는 끝내지 않고 붙잡아 둔다 — "올리는 중"인 첨부도 바꿈이다
const upload: { finish: ((result: MockResult) => void) | null } = { finish: null }
const { wrapper } = await mountCompose('/mail/compose', (request) =>
request.method === 'POST' && request.url === '/mail-attachments'
? new Promise<MockResult>((resolve) => {
upload.finish = resolve
})
: replyApi(request),
)
expect(blocksUnload()).toBe(false)
// 받는 사람 칸에 아직 토큰이 되지 않은 글
await wrapper.get('#compose-to').setValue('kim@ara')
expect(blocksUnload()).toBe(true)
await wrapper.get('#compose-to').setValue('')
expect(blocksUnload()).toBe(false)
// 본문 — 쓰고 실행 취소하면 처음과 같다
const editor = editorOf(wrapper.element)
editor.commands.insertContent('안녕하세요')
await flushPromises()
expect(blocksUnload()).toBe(true)
editor.commands.undo()
await flushPromises()
expect(blocksUnload()).toBe(false)
// 첨부 — 올리는 중이어도 바꿈, 빼면 처음과 같다
await choose(new File(['x'], '회의록.pdf'), wrapper)
await flushPromises()
expect(wrapper.findAll('.upload-item--uploading')).toHaveLength(1)
expect(blocksUnload()).toBe(true)
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(false)
upload.finish?.(ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201))
})
it('창 닫기 · 새로고침 확인(beforeunload)은 바꾼 동안만 건다 — 처음으로 돌아오거나 화면을 내리면 뗀다', async () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(blocksUnload()).toBe(false)
await writeSubject(wrapper, '제목')
expect(blocksUnload()).toBe(true)
await writeSubject(wrapper, '')
expect(blocksUnload()).toBe(false)
await writeSubject(wrapper, '다시')
expect(blocksUnload()).toBe(true)
wrapper.unmount()
expect(blocksUnload()).toBe(false)
})
it('보낸 뒤에는 묻지 않는다 — 보던 화면으로 바로 돌아가고 창 닫기도 막지 않는다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
await writeSubject(wrapper, '일정 공유')
expect(blocksUnload()).toBe(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
})
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
const RouterHost: FunctionalComponent = () => h('div')
/** 첫 발송의 응답을 붙잡아 두는 서버 — 그다음 발송은 outcomes 를 차례로 돌려준다 */
function heldSendApi(outcomes: MockResult[] = []) {
const held: { release: ((result: MockResult) => void) | null } = { release: null }
let first = true
const handler: MockHandler = (request) => {
if (request.method !== 'POST' || request.url !== '/messages') return replyApi(request)
if (!first) return outcomes.shift() ?? ok({ id: 'sent-2' }, 201)
first = false
return new Promise<MockResult>((resolve) => {
held.release = resolve
})
}
return { handler, held }
}
function sentKeys(requests: MockRequest[]): unknown[] {
return requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
}
function toastMessages(): string[] {
return useToastStore().toasts.map((toast) => toast.message)
}
/**
* 쓰기 화면만 내린다 — 앱(App.vue)처럼 라우터를 쓰는 화면 하나를 남겨 둔다.
* (라우터를 쓰는 앱이 모두 내려가면 vue-router 가 지금 경로를 처음('/')으로 되돌려 "끌고 오지 않았다"를 볼 수 없다)
*/
function leaveScreen(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
}
/** 고친 답장을 보내 놓는다 — 응답은 붙잡혀 있다 */
async function startSending(wrapper: VueWrapper) {
await writeSubject(wrapper, 'Re: 고친 제목')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('.compose__send').text()).toBe('보내는 중…')
}
it('보내는 중에는 앱 안 이동을 묻지 않는다 — 떠난 뒤 보내지면 성공 토스트만, 간 곳에서 끌고 오지 않는다 (창 닫기는 묻는다)', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
// 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다 — 창 닫기 · 새로고침은 보내는 중에도 묻는다
expect(blocksUnload()).toBe(true)
await router.push('/mail/sent')
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
// RouterView 안이라면 화면이 내려간다
leaveScreen(wrapper, router)
held.release?.(ok({ id: 'sent-1' }, 201))
await flushPromises()
expect(toastMessages()).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
it.each([
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/inbox')
leaveScreen(wrapper, router)
held.release?.(failure)
await flushPromises()
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES[code]])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('보내는 중에 쓰기 종류가 바뀌면(답장 → 새 메일) 묻지 않고 새 양식을 연다 — 앞 보내기가 끝나도 새 양식과 그 키는 그대로', async () => {
const { handler, held } = heldSendApi(['network', ok({ id: 'sent-3' }, 201)])
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/compose')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(wrapper.get('h1').text()).toBe('새 메일')
// 앞 요청에 묶이지 않는다 — 새 양식은 바로 보낼 수 있다
expect(wrapper.get('.compose__send').text()).toBe('보내기')
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await writeSubject(wrapper, '새 메일 제목')
// 새 양식의 첫 보내기 — 응답 없음(키를 그대로 둔다)
await wrapper.get('form').trigger('submit')
await flushPromises()
// 앞 양식의 보내기가 이제 끝난다 — 알리기만 한다
held.release?.(ok({ id: 'sent-1' }, 201))
await flushPromises()
expect(toastMessages()).toContain('메일을 보냈습니다.')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
expect(chips(wrapper, 'compose-to')).toEqual(['junho.park@arasystem.it.kr'])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('새 메일 제목')
// 앞 양식의 성공이 새 양식의 키를 바꾸지 않았다 — 다시 보내면 같은 키
await wrapper.get('form').trigger('submit')
await flushPromises()
const [oldKey, newKey, retryKey] = sentKeys(requests)
expect(newKey).not.toBe(oldKey)
expect(retryKey).toBe(newKey)
})
it('쓰기 종류가 바뀐 뒤 앞 보내기가 MAIL_002 로 끝나면 — 새 양식에 받는 사람 오류를 띄우지 않고 토스트로 알린다', async () => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
await startSending(wrapper)
await router.push('/mail/compose')
await flushPromises()
held.release?.(fail(400, 'MAIL_002'))
await flushPromises()
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_002])
expect(wrapper.get('.compose__send').text()).toBe('보내기')
})
})
it.each([
['이 탭의 로그아웃', (store: ReturnType<typeof useAuthStore>) => store.clearAuth()],
['세션 만료', (store: ReturnType<typeof useAuthStore>) => store.expireSession()],
])('세션이 끝나면(%s) 묻지 않는다 — 로그인 화면으로 가는 이동이 이긴다', async (_label, end) => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
await writeSubject(wrapper, '쓰던 메일')
expect(blocksUnload()).toBe(true)
end(useAuthStore())
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push({ name: 'login' })
expect(router.currentRoute.value.name).toBe('login')
expect(dialogOpen(wrapper)).toBe(false)
})
it('다른 탭이 로그아웃하면 묻지 않고 로그인 화면으로 간다', async () => {
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
mountedWrappers.push(mount(LogoutSyncHost, { global: { plugins: [pinia, router] } }))
await writeSubject(wrapper, '쓰던 메일')
new FakeBroadcastChannel(AUTH_CHANNEL_NAME).postMessage({ type: 'logout' })
await flushPromises()
await flushPromises()
expect(useAuthStore().isLoggedIn).toBe(false)
expect(router.currentRoute.value.name).toBe('login')
expect(dialogOpen(wrapper)).toBe(false)
})
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
mountedWrappers.push(sessionDialog)
await writeSubject(wrapper, '쓰던 메일')
const navigation = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
useAuthStore().expireSession()
await navigation
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(sessionDialog.get('dialog').attributes('open')).toBeDefined()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
// 세션 만료 대화상자의 "로그인 화면으로"는 묻지 않고 간다 — 다시 로그인하면 쓰기 화면으로 돌아온다
await sessionDialog.get('button').trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
})
it('원문을 불러오는 동안 붙인 파일은 처음 채운 첨부가 아니다 — 바꿈으로 남는다', async () => {
// 원문 응답을 붙잡아 둔다 — 그동안 양식은 막혀 있지만 첨부는 붙일 수 있다
const original: { release: (() => void) | null } = { release: null }
const requests = installMockApi((request) => {
if (request.method === 'GET' && request.url === '/messages/m1') {
return new Promise<MockResult>((resolve) => {
original.release = () => resolve(ok({ ...ORIGINAL, attachments: [CHECKLIST] }))
})
}
if (request.method === 'POST' && request.url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201)
}
return replyApi(request)
})
const router = createTestRouter()
await router.push('/mail/compose?forward=m1')
await router.isReady()
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
mountedWrappers.push(wrapper)
await flushPromises()
await choose(new File(['x'], '회의록.pdf'), wrapper)
await flushPromises()
original.release?.()
await editorLoaded(wrapper)
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(true)
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['회의록.pdf', '체크리스트.xlsx'])
expect(blocksUnload()).toBe(true)
// 올린 파일을 빼면 처음 채운 모양(원본 첨부만)과 같다
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(false)
})
})
})
+165 -30
View File
@@ -7,21 +7,27 @@
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
// 연 뒤에 받는 사람 · 제목 · 본문 · 첨부를 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인", useLeaveConfirm) —
// 보내는 중 · 보낸 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.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 { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
@@ -36,7 +42,13 @@ import {
type ComposeMode,
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { SendMessagePayload } from '@/shared/types/mail'
import {
isSameComposeForm,
snapshotComposeForm,
type ComposeFormSnapshot,
} from '@/shared/utils/compose-changes'
import { isImageFile } from '@/shared/utils/editor-files'
import {
createIdempotencyKey,
@@ -110,8 +122,30 @@ const sending = ref<boolean>(false)
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
/**
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
*/
const lengthError = ref<'subject' | 'body' | null>(null)
/**
* 쓰기 화면을 다 연 때의 양식(받는 사람 · 제목 · 첨부) — 답장 · 전달로 처음 채운 내용은 "바꿈"이 아니다.
* 이것과 달라졌거나 본문을 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인"). 본문은 편집기가 잰다(bodyChanged)
*/
const openedForm = shallowRef<ComposeFormSnapshot>(
snapshotComposeForm({ to: [], cc: [], bcc: [], subject: '', attachments: [] }),
)
/** 편집기를 연 뒤 본문을 바꿨다 — 편집기를 새로 만들면 처음부터 다시 잰다 */
const bodyChanged = ref<boolean>(false)
/** 보냈다 — 쓴 내용이 메일로 나갔으므로 떠날 때 묻지 않는다 (양식을 새로 채우면 풀린다) */
const sent = ref<boolean>(false)
/**
* 양식 세대 — 양식을 새로 채울 때(resetForm)와 화면을 내릴 때 하나씩 올린다.
* 보내기는 시작할 때 세대를 잡아 두고, 응답이 왔을 때 바뀌었으면 그 양식은 이미 없다 (보내는 중에 떠났거나 쓰기 종류가 바뀌었다).
* 그때는 양식 상태(보냄 표시 · 발송 키 · 칸 안내 · 첨부 표시)를 건드리지 않고 이동하지도 않는다 — 결과는 토스트로만 알린다.
*/
let formGeneration = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -138,12 +172,23 @@ function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
}
/** 답장·전달할 원본 메일 id */
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
const mode = computed<ComposeMode>(() => {
if (queryId(route.query.forward)) return 'forward'
if (!queryId(route.query.replyTo)) return 'new'
return route.query.all === '1' ? 'replyAll' : 'reply'
})
function sourceIdOf(query: LocationQuery): string | null {
return queryId(query.forward) ?? queryId(query.replyTo)
}
function modeOf(query: LocationQuery): ComposeMode {
if (queryId(query.forward)) return 'forward'
if (!queryId(query.replyTo)) return 'new'
return query.all === '1' ? 'replyAll' : 'reply'
}
/** 쓰기 종류(새 메일·답장·전달)와 원본 메일 — 바뀌면 양식을 새로 채운다 (같은 화면 안의 이동도 쓰던 내용을 버린다) */
function draftKeyOf(query: LocationQuery): string {
return `${modeOf(query)}:${sourceIdOf(query) ?? ''}`
}
const sourceId = computed(() => sourceIdOf(route.query))
const mode = computed<ComposeMode>(() => modeOf(route.query))
const title = computed(() => COMPOSE_TITLES[mode.value])
const attachmentHintText = computed(() =>
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
@@ -164,17 +209,56 @@ const showRecipientError = computed(
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const lengthErrorMessage = getErrorMessage('VAL_001')
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const subjectErrorMessage = getErrorMessage('VAL_001')
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
const bodyErrorMessage = getErrorMessage('MAIL_007')
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
const canSend = computed(
() =>
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
)
/** 지금 양식(받는 사람 · 제목 · 첨부) — 연 때의 양식(openedForm)과 견준다 */
function snapshotForm(): ComposeFormSnapshot {
return snapshotComposeForm({
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
attachments: uploadItems.value,
})
}
const formChanged = computed(() => !isSameComposeForm(openedForm.value, snapshotForm()))
/** 받는 사람 칸에 아직 토큰이 되지 않은 글이 있다 — 칸을 벗어나지 않고 떠나면(뒤로 가기 등) 확정되지 않은 채 사라진다 */
const typingRecipient = computed(() =>
[toField.value, ccField.value, bccField.value].some((field) => field?.hasDraft === true),
)
/** 떠나면 잃는 것이 있다 — 연 뒤에 바꿨고 아직 보내지 않았다 */
const hasUnsentChanges = computed(
() => !sent.value && (formChanged.value || bodyChanged.value || typingRecipient.value),
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자(ComposeLeaveDialog), 창 닫기 · 새로고침은 브라우저 확인창.
* 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
* 묻는 중에 세션이 끝나면 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않는다.
* 같은 쓰기 화면 안의 이동도 쓰기 종류나 원본이 바뀌면 양식을 새로 채우므로 묻는다 (답장 쓰다 "쓰기" 등).
* 보내는 중에는 앱 안 이동을 묻지 않는다 — 이미 보내기로 했고, 요청은 이어지며 결과는 토스트로 알린다 (formGeneration).
* 창 닫기 · 새로고침은 보내는 중에도 묻는다 — 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다.
*/
const { confirming: leaveConfirming, stay: stayOnCompose, leave: leaveCompose } = useLeaveConfirm({
active: () => hasUnsentChanges.value && authStore.isLoggedIn && !sending.value,
unloadActive: () => hasUnsentChanges.value && authStore.isLoggedIn,
leavesOnUpdate: (next, current) => draftKeyOf(next.query) !== draftKeyOf(current.query),
})
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
function renewEditor() {
editorKey.value += 1
editorReady.value = false
bodyChanged.value = false
}
/**
@@ -192,6 +276,8 @@ function resetForm() {
to.value = []
cc.value = []
bcc.value = []
// 칸에 쓰다 만 주소도 버린다 — 새로 채운 양식이 처음부터 "바꿈"이 되지 않게
for (const field of [toField.value, ccField.value, bccField.value]) field?.clearDraft()
showCopies.value = false
subject.value = ''
draftHtml.value = ''
@@ -202,8 +288,12 @@ function resetForm() {
rejectedByServer.value = false
lengthError.value = null
resetUploads()
// 양식을 새로 채우면 다른 메일이다
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
formGeneration += 1
sending.value = false
sendKey = createIdempotencyKey()
sent.value = false
openedForm.value = snapshotForm()
}
let prepareSeq = 0
@@ -216,12 +306,14 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
try {
const original = await getMessage(messageId)
if (seq !== prepareSeq) return
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
let filledAttachments: readonly number[] = []
if (sourceMode === 'forward') {
const draft = buildForwardDraft(original)
subject.value = draft.subject
draftHtml.value = draft.html
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
addExisting(draft.attachments)
filledAttachments = addExisting(draft.attachments).map((item) => item.key)
} else {
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
to.value = draft.to.map(tokenFromAddress)
@@ -232,6 +324,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
focusBodyWhenReady = true
}
// 처음 채운 내용은 "바꿈"이 아니다 — 받는 사람 · 제목은 불러오는 동안 막혀 있어 지금 값이 곧 채운 값이다
openedForm.value = { ...snapshotForm(), attachments: filledAttachments }
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
fitDraft.value = true
renewEditor()
@@ -256,6 +350,7 @@ function isFocusFree(): boolean {
function onEditorReady() {
editorReady.value = true
bodyChanged.value = false
if (!focusBodyWhenReady) return
focusBodyWhenReady = false
void nextTick(() => {
@@ -288,11 +383,11 @@ function settleSendKey(outcome: SendOutcome) {
}
function notifySent() {
const sent = mailStore.mailboxByRole('sent')
const sentMailbox = mailStore.mailboxByRole('sent')
toast.success(
MAIL_SENT_MESSAGE,
sent
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
sentMailbox
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
: undefined,
)
}
@@ -308,7 +403,7 @@ async function onSubmit() {
}
if (!canSend.value || !editorRef.value) return
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
const subjectText = subject.value.trim()
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
lengthError.value = 'subject'
@@ -319,8 +414,7 @@ async function onSubmit() {
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
if (content === null) {
lengthError.value = 'body'
editorRef.value.focus()
showBodyTooLong()
return
}
lengthError.value = null
@@ -337,25 +431,41 @@ async function onSubmit() {
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
}
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
const generation = formGeneration
sending.value = true
try {
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
settleSendKey('sent')
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
notifySent()
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
settleSendKey('sent')
// 쓴 내용이 메일로 나갔다 — 이제 떠날 때 묻지 않는다
sent.value = true
await router.push(backTo.value)
} catch (error) {
const { code } = toApiError(error)
const apiError = toApiError(error)
if (generation !== formGeneration) {
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
const { code } = apiError
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
settleSendKey(code)
if (code === 'MAIL_002') {
rejectedByServer.value = true
toField.value?.focus()
} else if (code === 'MAIL_007') {
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
showBodyTooLong()
} else if (code === 'MAIL_004') {
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
const marked = markMissing()
@@ -370,7 +480,8 @@ async function onSubmit() {
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
} finally {
sending.value = false
// 양식이 바뀌었으면 손대지 않는다 — 새 양식은 resetForm 이 이미 풀었고, 그 양식의 보내기가 진행 중일 수 있다
if (generation === formGeneration) sending.value = false
}
}
@@ -381,11 +492,23 @@ function onSubjectEnter(event: KeyboardEvent) {
editorRef.value?.focus()
}
/** 본문이 너무 길다(MAIL_007) — 본문 아래 띠를 보이고 본문으로 초점 (본문이 그 띠를 가리키고 잘못된 입력으로 읽힌다) */
function showBodyTooLong() {
lengthError.value = 'body'
editorRef.value?.focus()
}
/** 길이 안내는 그 칸을 고치면 내린다 */
function clearLengthError(field: 'subject' | 'body') {
if (lengthError.value === field) lengthError.value = null
}
/** 본문이 바뀌었다 — 길이 안내를 내리고, 연 때와 다른지 다시 잰다 (나가기 전 확인) */
function onEditorChange() {
clearLengthError('body')
bodyChanged.value = editorRef.value?.isChanged() ?? false
}
function pickFiles() {
fileInputRef.value?.click()
}
@@ -397,8 +520,13 @@ function onFilesChosen(event: Event) {
input.value = ''
}
onBeforeUnmount(() => {
// 화면을 떠났다 — 진행 중인 보내기가 끝나도 이 양식을 건드리거나 사용자를 끌고 오지 않는다
formGeneration += 1
})
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
const draftKey = computed(() => draftKeyOf(route.query))
watch(
draftKey,
() => {
@@ -531,7 +659,7 @@ watch(allRecipients, () => {
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
<span>{{ subjectErrorMessage }}</span>
</p>
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
@@ -546,7 +674,7 @@ watch(allRecipients, () => {
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
@ready="onEditorReady"
@files="attachFromBody"
@change="clearLengthError('body')"
@change="onEditorChange"
/>
</div>
<p
@@ -559,7 +687,7 @@ watch(allRecipients, () => {
name="alert-circle"
:size="15"
/>
<span>{{ lengthErrorMessage }}</span>
<span>{{ bodyErrorMessage }}</span>
</p>
<div class="compose__attachments">
@@ -625,6 +753,13 @@ watch(allRecipients, () => {
</span>
</div>
</form>
<!-- 바꾼 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<ComposeLeaveDialog
:open="leaveConfirming"
@stay="stayOnCompose"
@leave="leaveCompose"
/>
</main>
</template>