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
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { flushPromises } from '@/test-utils/mock-api'
import ModalDialog from './ModalDialog.vue'
// jsdom 에는 showModal 이 없다 — 대화상자는 open 속성만 붙였다 뗀다 (ModalDialog 의 대체 경로)
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
})
function mountDialog(open: boolean) {
const wrapper = mount(ModalDialog, {
attachTo: document.body,
props: { open, title: '보내지 않은 메일이 있습니다', message: '글', titleId: 't', messageId: 'm', role: 'alertdialog' },
slots: {
actions: () => [
h('button', { id: 'stay', type: 'button', autofocus: true }, '계속 쓰기'),
h('button', { id: 'leave', type: 'button' }, '나가기'),
],
},
})
mounted.push(wrapper)
return wrapper
}
describe('ModalDialog', () => {
it('제목 · 글을 aria 로 잇고, 열면 autofocus 단추로 · 닫으면 연 때의 자리로 초점을 옮긴다', async () => {
const before = document.createElement('button')
document.body.append(before)
before.focus()
const wrapper = mountDialog(false)
const dialog = wrapper.get('dialog')
expect(dialog.attributes()).toMatchObject({ role: 'alertdialog', 'aria-labelledby': 't', 'aria-describedby': 'm' })
expect(dialog.attributes('open')).toBeUndefined()
await wrapper.setProps({ open: true })
await flushPromises()
expect(dialog.attributes('open')).toBeDefined()
expect(document.activeElement?.id).toBe('stay')
await wrapper.setProps({ open: false })
expect(dialog.attributes('open')).toBeUndefined()
expect(document.activeElement).toBe(before)
before.remove()
})
it('Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 닫을지는 쓰는 쪽이 정한다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const cancel = new Event('cancel', { cancelable: true })
wrapper.get('dialog').element.dispatchEvent(cancel)
expect(cancel.defaultPrevented).toBe(true)
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.get('dialog').attributes('open')).toBeDefined()
})
it('열려 있어야 하는데 브라우저가 스스로 닫으면(close) cancel 로 알린다 — 쓰는 쪽이 닫았을 때는 알리지 않는다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const dialog = wrapper.get('dialog').element
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
await wrapper.setProps({ open: false })
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
const first = ref<boolean>(true)
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
const Pair = defineComponent({
setup() {
return () => [
h(ModalDialog, { open: !first.value, title: 'B', message: 'b', titleId: 'b-t', messageId: 'b-m', id: 'b' }),
h(ModalDialog, { open: first.value, title: 'A', message: 'a', titleId: 'a-t', messageId: 'a-m', id: 'a' }),
]
},
})
const wrapper = mount(Pair, { attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
const changes: string[] = []
const observer = new MutationObserver((records) => {
for (const record of records) {
const target = record.target as HTMLDialogElement
changes.push(`${target.id}:${target.hasAttribute('open') ? 'open' : 'closed'}`)
}
})
for (const dialog of wrapper.findAll('dialog')) {
observer.observe(dialog.element, { attributes: true, attributeFilter: ['open'] })
}
first.value = false
await nextTick()
await flushPromises()
observer.disconnect()
expect(changes).toEqual(['a:closed', 'b:open'])
})
})
@@ -0,0 +1,182 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
import { onMounted, ref, watch } from 'vue'
interface Props {
open: boolean
title: string
message: string
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
titleId: string
messageId: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
}
interface Emits {
/** Esc 로 닫으려 했다 — 브라우저가 막을 수 없게 스스로 닫았을 때도 같다 */
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
let returnFocus: HTMLElement | null = null
function show() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
// showModal 은 autofocus 단추로 초점을 옮긴다 — 없는 환경에서도 같게
dialog.querySelector<HTMLElement>('[autofocus]')?.focus()
}
function hide() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
// 초점이 대화상자 안에 남았거나 갈 곳을 잃었을 때만 돌려준다 (브라우저가 이미 돌려줬으면 그대로)
const active = document.activeElement
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
returnFocus = null
}
function onCancel(event: Event) {
event.preventDefault()
emit('cancel')
}
/** 열려 있어야 하는데 닫혔다 — 브라우저가 스스로 닫았다 (Esc 를 거듭 누르는 등). 쓰는 쪽의 상태와 맞춘다 */
function onClose() {
if (props.open) emit('cancel')
}
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
watch(
() => props.open,
(open) => {
if (!open) hide()
},
)
watch(
() => props.open,
(open) => {
if (open) show()
},
{ flush: 'post' },
)
onMounted(() => {
if (props.open) show()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@cancel="onCancel"
@close="onClose"
>
<h2
:id="titleId"
class="modal-dialog__title"
>
{{ title }}
</h2>
<p
:id="messageId"
class="modal-dialog__message"
>
{{ message }}
</p>
<div class="modal-dialog__actions">
<slot name="actions" />
</div>
</dialog>
</template>
<style scoped>
.modal-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.modal-dialog::backdrop {
background: var(--color-backdrop);
}
.modal-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.modal-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
/* 단추 줄 — 오른쪽 정렬 */
.modal-dialog__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-8);
margin-top: var(--space-4);
}
/* 주 단추 — 브랜드색 (슬롯으로 받은 단추라 :slotted 로 꾸민다) */
:slotted(.modal-dialog__action) {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
:slotted(.modal-dialog__action:hover) {
background: var(--color-brand-strong);
}
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
:slotted(.modal-dialog__action--secondary) {
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -2,9 +2,10 @@
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
import { onMounted, ref, watch } from 'vue'
// 대화상자 틀(열기 · 닫기 · Esc · 모양)은 ModalDialog 가 맡는다.
import { useRoute, useRouter } from 'vue-router'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { getErrorMessage } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth.store'
@@ -12,125 +13,34 @@ const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 문구는 에러 코드 사전에서만 온다 */
const message = getErrorMessage('AUTH_002')
function open() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
}
function close() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
}
async function goToLogin() {
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
authStore.acknowledgeSessionExpired()
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
}
function onCancel(event: Event) {
event.preventDefault()
void goToLogin()
}
watch(
() => authStore.sessionExpired,
(expired) => (expired ? open() : close()),
{ flush: 'post' },
)
onMounted(() => {
if (authStore.sessionExpired) open()
})
</script>
<template>
<dialog
ref="dialogRef"
class="session-dialog"
aria-labelledby="session-expired-title"
aria-describedby="session-expired-message"
@cancel="onCancel"
<ModalDialog
:open="authStore.sessionExpired"
title="다시 로그인해 주세요"
:message="message"
title-id="session-expired-title"
message-id="session-expired-message"
@cancel="goToLogin"
>
<h2
id="session-expired-title"
class="session-dialog__title"
>
다시 로그인해 주세요
</h2>
<p
id="session-expired-message"
class="session-dialog__message"
>
{{ message }}
</p>
<button
type="button"
class="session-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</dialog>
<template #actions>
<button
type="button"
class="modal-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.session-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.session-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.session-dialog::backdrop {
background: var(--color-backdrop);
}
.session-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.session-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
.session-dialog__action {
align-self: flex-end;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
margin-top: var(--space-4);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.session-dialog__action:hover {
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
open: boolean
}
interface Emits {
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
(e: 'leave'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
id="compose-leave-dialog"
:open="open"
role="alertdialog"
:title="COMPOSE_LEAVE_DIALOG.title"
:message="COMPOSE_LEAVE_DIALOG.message"
title-id="compose-leave-title"
message-id="compose-leave-message"
@cancel="emit('stay')"
>
<template #actions>
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 쓰던 내용이 남는다 -->
<button
id="compose-leave-stay"
type="button"
class="modal-dialog__action"
autofocus
@click="emit('stay')"
>
{{ COMPOSE_LEAVE_DIALOG.stay }}
</button>
<button
id="compose-leave-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('leave')"
>
{{ COMPOSE_LEAVE_DIALOG.leave }}
</button>
</template>
</ModalDialog>
</template>
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -98,6 +99,68 @@ describe('MessageReader 도구 모음', () => {
})
})
describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountWith(detail: MessageDetail) {
const store = useMailStore()
store.selected = detail
store.selectedId = detail.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
return mount(MessageReader, { global: { plugins: [pinia, router] } })
}
function notices(wrapper: Awaited<ReturnType<typeof mountWith>>) {
return wrapper.findAll('.reader__notice').map((notice) => ({
kind: notice.attributes('data-notice'),
text: notice.text(),
}))
}
// 화면들이 같은 스토어(선택한 메일)를 보므로 한 번 띄울 때마다 바로 확인한다
it('다 받은 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 응답도 같다', async () => {
expect(notices(await mountWith(DETAIL))).toEqual([])
expect(notices(await mountWith({ ...DETAIL, isHtmlBodyOmitted: false, isBodyTruncated: false }))).toEqual([])
})
it('서식 본문을 비웠으면(isHtmlBodyOmitted) 글자 본문이 있을 때 · 없을 때 다른 문구', async () => {
const withText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: '글자 본문' })
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
})
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
const wrapper = await mountWith({
...DETAIL,
htmlBody: '<p>앞부분</p>',
isHtmlBodyOmitted: false,
isBodyTruncated: true,
hasBlockedImages: true,
})
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
// 안내 줄 모양은 하나 — 아이콘 + 글
for (const notice of both.findAll('.reader__notice')) {
expect(notice.element.tagName).toBe('P')
expect(notice.find('svg').exists()).toBe(true)
}
})
})
describe('MessageReader 불러오기 실패', () => {
let pinia: Pinia
@@ -15,6 +15,7 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -42,6 +43,8 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
async function onDelete() {
const current = message.value
@@ -203,9 +206,25 @@ function leaveToList() {
</div>
</div>
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="reader__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="reader__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<p
v-if="message.hasBlockedImages"
class="reader__notice"
data-notice="blockedImages"
>
<AppIcon
name="shield"
@@ -41,6 +41,8 @@ const draft = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '')
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
@@ -90,7 +92,12 @@ function focus() {
inputRef.value?.focus()
}
defineExpose({ commit, focus })
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
draft.value = ''
}
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
<template>
@@ -45,7 +45,7 @@ interface Emits {
(e: 'ready'): void
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
(e: 'files', files: File[]): void
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) — 연 때와 다른지는 isChanged 로 묻는다 */
(e: 'change'): void
}
@@ -65,6 +65,12 @@ const revision = ref<number>(0)
let pastedImageCount = 0
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
let mounted = false
/**
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
function initialContent(extensions: AnyExtension[]): Content {
if (!props.initialHtml) return ''
@@ -176,6 +182,13 @@ function getContentForSend(): MailEditorContent | null {
return content
}
/** 연 뒤에 본문을 바꿨는가 — 지웠다 다시 써 처음과 같아졌으면 바꾸지 않은 것이다 */
function isChanged(): boolean {
if (!opened) return false
const { doc } = editor.state
return !doc.eq(opened.doc) && !doc.eq(opened.settled)
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -194,8 +207,11 @@ onMounted(() => {
editor.mount(bodyRef.value)
mounted = true
}
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
// 붙이면서 플러그인(실행 취소 기록 · 끝 빈 줄 등)이 켜진다 — 툴바가 다시 읽게 하고, 연 때의 문서를 찍어 둔다.
// settled 는 빈 거래 하나를 적용해 본 모양이다 (화면에 보내지 않는다 — 플러그인이 붙일 것만 미리 본다)
revision.value += 1
const { state } = editor
opened = { doc: state.doc, settled: state.apply(state.tr).doc }
emit('ready')
})
@@ -211,7 +227,7 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus })
defineExpose({ getContent, getContentForSend, focus, isChanged })
</script>
<template>
@@ -116,7 +116,7 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
})
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 쓰기 화면이 보내지 않고, 보내도 서버가 MAIL_007 로 막는다)', () => {
const doc = createDocument(lines(200), schema)
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
@@ -16,7 +16,7 @@ export interface DraftLimits {
html: number
}
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
/**
@@ -19,6 +19,11 @@ export interface MailEditorHandle {
getContentForSend: () => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
*/
isChanged: () => boolean
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -79,6 +79,22 @@ describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
})
it('보이지 않을 만큼 작은 글자(8px 미만) · 거의 투명한 글자 색(알파 0.3 미만)도 서식째 버린다 — 배경 색의 알파는 그대로', () => {
const tiny = parse('<p><span style="font-size: 1px">작은 글</span></p>')
const pale = parse('<p><span style="color: rgba(0, 0, 0, 0.1)">흐린 글</span></p>')
const word = parse('<p><span style="font-size: 5.25pt">워드 7px</span></p>')
const highlight = parse('<p><span style="background-color: rgba(0, 0, 0, 0)">배경</span></p>')
expect(firstTextStyle(tiny)?.fontSize ?? null).toBeNull()
expect(firstTextStyle(pale)?.color ?? null).toBeNull()
expect(firstTextStyle(word)?.fontSize ?? null).toBeNull()
expect(outgoingHtml(tiny)).not.toContain('font-size: 1px')
expect(outgoingHtml(pale)).not.toContain('rgba')
expect(docToText(tiny)).toBe('작은 글')
expect(firstTextStyle(highlight)?.backgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(firstTextStyle(parse('<p><span style="font-size: 6pt">a</span></p>'))?.fontSize).toBe('8px')
})
it('pt · 긴 소수의 글자 크기는 px 로 맞춰 읽는다 (워드 10pt · 아웃룩 13.3333px), !important 는 뗀다', () => {
expect(firstTextStyle(parse('<p><span style="font-size: 10pt">a</span></p>'))?.fontSize).toBe('13.33px')
expect(firstTextStyle(parse('<p><span style="font-size: 13.3333px">a</span></p>'))?.fontSize).toBe('13.33px')
+1
View File
@@ -59,6 +59,7 @@ describe('에러 코드 사전', () => {
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
+9
View File
@@ -115,6 +115,15 @@ const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
useToastStore().show({ kind: 'error', message, traceId })
}
/**
* 알림을 끄고(silent) 보낸 요청의 실패를 나중에 알린다 — 그 실패를 칸 안에서 안내하려던 화면이 이미 사라졌을 때
* (예: 보내는 중에 쓰기 화면을 떠났다). 다른 오류 알림과 같은 길(showErrorUI — 같은 코드 중복 억제 · 세션 만료는 대화상자)이다.
*/
export function notifyApiError(error: ApiError): void {
if (error.canceled) return
showErrorUI(error.code, error.message, error.traceId)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -171,6 +171,18 @@ describe('useAttachmentUploads', () => {
stop()
})
it('올린 항목을 돌려준다 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
uploads.addFiles([file('회의록.pdf')])
const added = uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
expect(added.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
expect(uploads.items.value.slice(1).map((item) => item.key)).toEqual(added.map((item) => item.key))
stop()
})
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
@@ -162,8 +162,9 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
* (파일당 한도는 업로드에만 있어 보지 않는다)
* @returns 목록에 올린 항목 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)
*/
function addExisting(attachments: readonly Attachment[]) {
function addExisting(attachments: readonly Attachment[]): UploadItem[] {
const running: AttachedSummary = { ...attached.value }
const added = attachments.map((attachment): UploadItem => {
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
@@ -184,6 +185,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
}
})
items.value = [...items.value, ...added]
return added
}
/**
@@ -0,0 +1,97 @@
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
export interface LeaveConfirmOptions {
/**
* 지금 앱 안에서 떠나면 잃는 것이 있다 — 이때만 묻는다. 반응형으로 읽는다.
* 묻는 중에 false 가 되면(세션이 끝남 등) 대화상자를 닫고 그 이동은 취소한다 — 그 뒤의 이동은 다른 흐름(로그인 화면으로)이 한다.
*/
active: () => boolean
/**
* 창 닫기 · 새로고침 확인(beforeunload)을 걸 때 — 주지 않으면 active 와 같다. 반응형으로 읽는다.
* 앱 안 이동은 묻지 않아도 창은 닫으면 안 되는 때가 있다 — 예: 보내는 중 (앱 안에서는 요청이 이어지고 결과를 토스트로 알리지만,
* 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳도 없다)
*/
unloadActive?: () => boolean
/**
* 같은 경로 안의 이동(쿼리만 바뀜)도 이 화면을 떠나는 것인가 — 예: 답장 쓰기 → 새 메일 쓰기는 양식을 새로 채운다.
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
/** 대화상자를 띄우고 답을 기다리는 중 */
const confirming = ref<boolean>(false)
/** 기다리는 답 — 뒤로 가기를 거듭 눌러 이동이 겹쳐도 대화상자는 하나, 답도 하나다 */
let pending: Promise<boolean> | null = null
let resolvePending: ((leave: boolean) => void) | null = null
function ask(): Promise<boolean> {
if (!pending) {
pending = new Promise<boolean>((resolve) => {
resolvePending = resolve
})
confirming.value = true
}
return pending
}
/** 답한다 — true: 떠난다 · false: 남는다 (이동을 취소한다) */
function answer(leave: boolean) {
const resolve = resolvePending
pending = null
resolvePending = null
confirming.value = false
resolve?.(leave)
}
function confirmLeave(): true | Promise<boolean> {
return options.active() ? ask() : true
}
onBeforeRouteLeave(() => confirmLeave())
onBeforeRouteUpdate((to, from) => (options.leavesOnUpdate?.(to, from) ? confirmLeave() : true))
/** 창 닫기 · 새로고침 — 브라우저 기본 확인창 (문구는 브라우저가 정한다) */
function onBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault()
// 옛 브라우저(Chrome · Edge 119 미만)는 returnValue 를 채워야 묻는다
event.returnValue = true
}
let listening = false
function listenBeforeUnload(on: boolean) {
if (on === listening) return
listening = on
if (on) window.addEventListener('beforeunload', onBeforeUnload)
else window.removeEventListener('beforeunload', onBeforeUnload)
}
// 지킬 것이 있는 동안만 건다 — 늘 걸어 두면 일부 브라우저가 페이지를 뒤로 가기 캐시(bfcache)에 두지 않는다 (MDN 권고)
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
if (!active && pending) answer(false)
})
onBeforeUnmount(() => {
listenBeforeUnload(false)
// 다른 이동이 이겨 화면이 사라진다 — 기다리던 이동은 취소로 끝낸다
if (pending) answer(false)
})
return {
confirming: readonly(confirming),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
leave: () => answer(true),
}
}
@@ -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>
+1 -1
View File
@@ -24,7 +24,7 @@ export const MAIL_BODY_FONT_SIZE_PX = 14
*/
export const MAIL_BODY_LINE_HEIGHT = 1.75
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
+26
View File
@@ -45,7 +45,9 @@ export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
export const OUTGOING_MAILBOX_ROLES: readonly MailboxRole[] = ['sent', 'drafts']
// ── 발송 한도 (계약 2절 "발송 본문") ─────────────────────────
/** 제목 — 넘기면 400 VAL_001 (입력칸이 이 길이에서 더 받지 않는다) */
export const MAIL_SUBJECT_MAX_LENGTH = 255
/** 텍스트 본문(body) — 넘기면 400 MAIL_007 (서식 본문 한도는 mail-editor.ts 의 MAIL_BODY_HTML_MAX_LENGTH) */
export const MAIL_BODY_MAX_LENGTH = 100_000
export const MAIL_MAX_RECIPIENTS = 100
/**
@@ -129,3 +131,27 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
/** 발송 완료 토스트 (디자인 원본 States "보내기 완료") */
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
/**
* 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
* [계속 쓰기]가 처음 초점이고 Esc 와 같다.
*/
export const COMPOSE_LEAVE_DIALOG = {
title: '보내지 않은 메일이 있습니다',
message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
stay: '계속 쓰기',
leave: '나가기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
*/
export const MAIL_BODY_NOTICES = {
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
truncated: '본문이 길어 앞부분만 표시합니다.',
} as const
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
+10
View File
@@ -58,6 +58,16 @@ export interface MessageDetail extends MessageSummary {
textBody: string | null
/** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
hasBlockedImages: boolean
/**
* HTML 이 있었지만 서버의 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true
* → textBody 를 서식 없이 보이며 안내한다 (textBody 도 없으면 "표시할 수 없음"). 필드가 없으면(이전 서버) false 로 본다
*/
isHtmlBodyOmitted?: boolean
/**
* 보여 주는 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 서버의 읽기 한도(2MB)에서 잘렸으면 true
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
*/
isBodyTruncated?: boolean
attachments: Attachment[]
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { isSameComposeForm, snapshotComposeForm, type ComposeFormFields } from './compose-changes'
import { parseRecipient, tokenFromAddress, type RecipientToken } from './mail-address'
const DOYUN = tokenFromAddress({ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' })
function token(text: string): RecipientToken {
const parsed = parseRecipient(text)
if (!parsed) throw new Error('빈 토큰')
return parsed
}
function fields(partial: Partial<ComposeFormFields> = {}): ComposeFormFields {
return { to: [DOYUN], cc: [], bcc: [], subject: 'Re: 일정', attachments: [{ key: 1 }], ...partial }
}
describe('쓰기 양식의 바꿈 판정 (디자인 README "나가기 전 확인")', () => {
const opened = snapshotComposeForm(fields())
it('연 때와 같으면 바꾸지 않은 것이다 — 썼다 지워 돌아온 것도 같다', () => {
expect(isSameComposeForm(opened, snapshotComposeForm(fields()))).toBe(true)
expect(isSameComposeForm(opened, snapshotComposeForm(fields({ to: [tokenFromAddress(DOYUN.address)] })))).toBe(true)
})
it.each<[string, Partial<ComposeFormFields>]>([
['받는 사람을 더함', { to: [DOYUN, token('junho.park@arasystem.it.kr')] }],
['받는 사람을 뺌', { to: [] }],
['같은 주소를 이름 없이 다시 씀', { to: [token('doyun.lee@arasystem.it.kr')] }],
['참조를 더함', { cc: [token('infra@arasystem.it.kr')] }],
['숨은참조를 더함', { bcc: [token('ceo@arasystem.it.kr')] }],
['제목을 고침', { subject: 'Re: 일정 ' }],
['첨부를 더함(올리는 중 포함)', { attachments: [{ key: 1 }, { key: 2 }] }],
['전달 원본 첨부를 뺌', { attachments: [] }],
])('%s', (_label, change) => {
expect(isSameComposeForm(opened, snapshotComposeForm(fields(change)))).toBe(false)
})
})
@@ -0,0 +1,50 @@
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
import type { RecipientToken } from '@/shared/utils/mail-address'
export interface ComposeFormSnapshot {
/** 받는 사람 · 참조 · 숨은참조 — 토큰에 보이는 글자 (이름 · 주소를 고치면 달라진다) */
to: readonly string[]
cc: readonly string[]
bcc: readonly string[]
subject: string
/** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
attachments: readonly number[]
}
export interface ComposeFormFields {
to: readonly RecipientToken[]
cc: readonly RecipientToken[]
bcc: readonly RecipientToken[]
subject: string
attachments: readonly { key: number }[]
}
const labels = (tokens: readonly RecipientToken[]) => tokens.map((token) => token.label)
export function snapshotComposeForm(fields: ComposeFormFields): ComposeFormSnapshot {
return {
to: labels(fields.to),
cc: labels(fields.cc),
bcc: labels(fields.bcc),
subject: fields.subject,
attachments: fields.attachments.map((item) => item.key),
}
}
function sameList<T>(a: readonly T[], b: readonly T[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
/** 같은 양식인가 — 순서까지 본다 (썼다 지워 처음으로 돌아왔으면 같다) */
export function isSameComposeForm(a: ComposeFormSnapshot, b: ComposeFormSnapshot): boolean {
return (
a.subject === b.subject &&
sameList(a.to, b.to) &&
sameList(a.cc, b.cc) &&
sameList(a.bcc, b.bcc) &&
sameList(a.attachments, b.attachments)
)
}
@@ -67,6 +67,10 @@ describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계
},
)
it('MAIL_007(본문이 너무 김) 뒤에도 같은 키 — 서버가 엔진에 넘기기 전에 거절했으므로 줄인 뒤 같은 키로 보낸다 (계약 2절)', () => {
expect(shouldRenewSendKey('MAIL_007')).toBe(false)
})
it('사전의 다른 모든 코드도 키를 유지한다 — 새 코드가 생겨도 바꾸는 경우는 늘지 않는다', () => {
const renewing = (Object.keys(ERROR_CODE_MESSAGES) as ErrorCode[]).filter(shouldRenewSendKey)
expect(renewing).toEqual(['MAIL_005'])
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { mailBodyNotices } from './mail-body-notices'
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
expect(texts({ textBody: '본문' })).toEqual([])
})
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
expect(texts({ textBody: '글자 본문', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.htmlOmitted])
expect(texts({ textBody: null, isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
expect(texts({ textBody: ' \n ', isHtmlBodyOmitted: true })).toEqual([MAIL_BODY_NOTICES.unavailable])
})
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
])
})
it('둘 다면 생략 → 잘림 순서 — 보여 줄 본문이 없으면 잘림 안내는 두지 않는다', () => {
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
MAIL_BODY_NOTICES.htmlOmitted,
MAIL_BODY_NOTICES.truncated,
])
expect(texts({ textBody: '', isHtmlBodyOmitted: true, isBodyTruncated: true })).toEqual([
MAIL_BODY_NOTICES.unavailable,
])
})
})
@@ -0,0 +1,30 @@
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
export interface MailBodyNotice {
kind: MailBodyNoticeKind
text: string
}
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
return { kind, text: MAIL_BODY_NOTICES[kind] }
}
/**
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
*/
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
const notices: MailBodyNotice[] = []
const omitted = message.isHtmlBodyOmitted === true
const hasText = (message.textBody ?? '').trim() !== ''
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
return notices
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
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'
@@ -88,4 +88,10 @@ describe('buildForwardDraft', () => {
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시', () => {
const { html } = buildForwardDraft(message({ textBody: '앞부분', isBodyTruncated: true }), 'Asia/Seoul')
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
})
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
import { MAIL_FRAME_CSP, buildMailDocument, displayedHtmlBody, escapeHtml, sanitizeMailHtml } from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -126,6 +126,20 @@ describe('buildMailDocument', () => {
})
})
describe('displayedHtmlBody — 보여 줄 서식 본문 (읽기 화면 · 답장 · 전달 인용이 같은 규칙)', () => {
it('null 이거나 공백뿐이면 없다 — 그때는 텍스트를 보인다', () => {
expect(displayedHtmlBody(null)).toBeNull()
expect(displayedHtmlBody(undefined)).toBeNull()
expect(displayedHtmlBody('')).toBeNull()
// 외부 그림 하나뿐이던 HTML 을 서버가 정화하면 줄바꿈만 남는다
expect(displayedHtmlBody('\n\n\n\n\n')).toBeNull()
})
it('글이 있으면 받은 그대로 돌려준다', () => {
expect(displayedHtmlBody('\n<p>본문</p>\n')).toBe('\n<p>본문</p>\n')
})
})
describe('escapeHtml', () => {
it('특수 문자를 모두 바꾼다', () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;')
+16 -5
View File
@@ -130,16 +130,27 @@ export interface MailBodySource {
textBody: string | null
}
/**
* 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다.
* 서버(정화 결과가 공백뿐이면 htmlBody 를 비운다) · 계약 2절과 같은 규칙이다.
* 읽기 화면(buildMailDocument)과 답장 · 전달 인용(mail-quote.ts 의 originalBodyHtml)이 함께 써서 둘이 같은 본문을 고른다
* — 예) 외부 이미지 하나뿐인 HTML 은 정화하면 줄바꿈만 남는다. 읽기 화면은 글자 본문을 보이므로 인용도 글자 본문을 쓴다.
*/
export function displayedHtmlBody(htmlBody: string | null | undefined): string | null {
return htmlBody?.trim() ? htmlBody : null
}
/**
* 본문 → iframe srcdoc 문서.
* HTML 이 있으면 정화해서, 없으면 텍스트를 이스케이프해서 넣는다.
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
*/
export function buildMailDocument(source: MailBodySource): string {
const html = source.htmlBody?.trim()
const content = html
? sanitizeMailHtml(html)
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
const html = displayedHtmlBody(source.htmlBody)
const content =
html !== null
? sanitizeMailHtml(html.trim())
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
return [
'<!doctype html>',
@@ -6,6 +6,7 @@ import {
QUOTE_SOURCE_MAX_LENGTH,
buildQuoteHtml,
forwardQuoteRows,
hasVisibleQuoteContent,
originalBodyHtml,
replyQuoteRows,
textToParagraphHtml,
@@ -52,6 +53,36 @@ describe('originalBodyHtml', () => {
expect(html).toBe(`${head}${OMITTED}`)
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 여기서 자르지 않았어도 끝에 생략 표시 — 서식 · 텍스트 모두', () => {
expect(originalBodyHtml({ htmlBody: '<p>앞부분</p>', textBody: '텍스트', isBodyTruncated: true })).toBe(
`<p>앞부분</p>${OMITTED}`,
)
expect(originalBodyHtml({ htmlBody: null, textBody: '앞부분', isBodyTruncated: true })).toBe(
`<p>앞부분</p>${OMITTED}`,
)
// 잘리지 않았다고 알려 왔거나 알리지 않았으면(이전 서버) 그대로
expect(originalBodyHtml({ htmlBody: '<p>전부</p>', textBody: null, isBodyTruncated: false })).toBe('<p>전부</p>')
})
it('공백뿐인 서식 본문은 없는 것으로 보고 텍스트를 인용한다 — 읽기 화면과 같은 본문 (외부 그림만 있던 뉴스레터 등)', () => {
const html = originalBodyHtml({ htmlBody: '\n\n', textBody: '원문', isBodyTruncated: true })
expect(html).toContain('<p>원문</p>')
expect(html).toBe(`<p>원문</p>${OMITTED}`)
expect(originalBodyHtml({ htmlBody: ' \n\t ', textBody: '한 줄\n두 줄' })).toBe('<p>한 줄</p><p>두 줄</p>')
})
it('서버도 잘랐고 여기서도 잘랐으면 생략 표시는 한 번만', () => {
const html = originalBodyHtml({
htmlBody: 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH + 10),
textBody: null,
isBodyTruncated: true,
})
expect(html).toBe(`${'x'.repeat(QUOTE_SOURCE_MAX_LENGTH)}${OMITTED}`)
expect(html.split(MAIL_QUOTE_OMITTED_MARK)).toHaveLength(2)
})
it('그림(서버가 넣은 data: 그림 포함)은 자르기 전에 걷어 낸다 — 큰 그림 뒤의 글이 생략 표시 없이 사라지지 않게', () => {
const picture = `<img src="data:image/png;base64,${'A'.repeat(QUOTE_SOURCE_MAX_LENGTH + 100_000)}" alt="서명">`
const html = originalBodyHtml({ htmlBody: `<p>앞 글</p><p>${picture}</p><p>뒤쪽 본문 내용</p>`, textBody: null })
@@ -61,6 +92,74 @@ describe('originalBodyHtml', () => {
})
})
describe('그림뿐인 원문 — 인용은 그림을 받지 않으므로 걷어 내면 보이는 것이 없다', () => {
const PICTURE = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="회사 로고">'
it('글자 본문이 있으면 그 문단을 인용한다 (읽기 화면은 그대로 그림을 보인다)', () => {
expect(originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: '회사 로고\n안녕하세요' })).toBe(
'<p>회사 로고</p><p>안녕하세요</p>',
)
// 링크로 감싼 그림 · 빈 문단 · 공백 문자 참조만 남아도 보이는 것이 없다
expect(
originalBodyHtml({ htmlBody: `<p><a href="https://x.kr">${PICTURE}</a></p><p>&nbsp;</p>`, textBody: '본문' }),
).toBe('<p>본문</p>')
})
it('글자 본문도 없으면 인용 머리만 남는다 — 지금과 같다', () => {
const body = originalBodyHtml({ htmlBody: `<div>${PICTURE}</div>`, textBody: null })
const html = buildQuoteHtml(replyQuoteRows(HEADER, 'Asia/Seoul'), body)
expect(body).toBe('<p></p>')
expect(html.endsWith('</tbody></table><p></p></blockquote>')).toBe(true)
expect(html).not.toContain('<img')
})
it('잘린 원문(isBodyTruncated)이면 글자 본문 끝에 생략 표시를 한 번 둔다', () => {
expect(originalBodyHtml({ htmlBody: PICTURE, textBody: '앞부분', isBodyTruncated: true })).toBe(`<p>앞부분</p>${OMITTED}`)
})
it('글자가 있는 원문은 그대로 — 그림만 걷어 낸다', () => {
expect(originalBodyHtml({ htmlBody: `<p>안녕하세요</p><p>${PICTURE}</p>`, textBody: '텍스트 본문' })).toBe(
'<p>안녕하세요</p><p></p>',
)
})
it('글자 없는 표 · 가로줄만 남아도 보이는 내용이 아니다 — 글자 본문을 인용한다', () => {
expect(
originalBodyHtml({ htmlBody: `<table><tbody><tr><td>${PICTURE}</td></tr></tbody></table>`, textBody: '텍스트' }),
).toBe('<p>텍스트</p>')
expect(originalBodyHtml({ htmlBody: `${PICTURE}<hr>`, textBody: '텍스트' })).toBe('<p>텍스트</p>')
})
it('그림만 표로 늘어놓은 뉴스레터 — 글자 본문의 문단을 인용한다', () => {
const layout =
'<table width="600" align="center"><tbody>' +
`<tr><td align="center">${PICTURE}</td></tr>` +
`<tr><td><table><tbody><tr><td>${PICTURE}</td><td>&nbsp;</td><td>${PICTURE}</td></tr></tbody></table></td></tr>` +
'</tbody></table>'
expect(originalBodyHtml({ htmlBody: layout, textBody: '10월 소식\n신제품을 소개합니다.' })).toBe(
'<p>10월 소식</p><p>신제품을 소개합니다.</p>',
)
})
it('글자가 든 표는 그대로 원문 HTML 을 인용한다 — 그림만 걷어 낸다', () => {
expect(
originalBodyHtml({
htmlBody: `<table><tbody><tr><td>${PICTURE}</td><td>견적 합계</td></tr></tbody></table>`,
textBody: '텍스트',
}),
).toBe('<table><tbody><tr><td></td><td>견적 합계</td></tr></tbody></table>')
})
it('hasVisibleQuoteContent — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 (&nbsp; · U+00A0 은 글자가 아니다)', () => {
expect(hasVisibleQuoteContent('<p>&nbsp;  &#160;</p><div><br></div>')).toBe(false)
expect(hasVisibleQuoteContent('<table><tbody><tr><td></td></tr></tbody></table><HR>')).toBe(false)
expect(hasVisibleQuoteContent('<p>&amp;</p>')).toBe(true)
expect(hasVisibleQuoteContent('<p><strong>글</strong></p>')).toBe(true)
})
})
describe('인용 머리', () => {
it('답장 — 보낸 사람 · 날짜 · 제목 (제목이 없으면 "(제목 없음)")', () => {
expect(replyQuoteRows(HEADER, 'Asia/Seoul')).toEqual([
+47 -11
View File
@@ -17,7 +17,7 @@ import type { MessageDetail } from '@/shared/types/mail'
import { formatMailDateTime } from '@/shared/utils/format'
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
import { truncateText } from '@/shared/utils/mail-draft'
import { escapeHtml } from '@/shared/utils/mail-html'
import { displayedHtmlBody, escapeHtml } from '@/shared/utils/mail-html'
export interface QuoteRow {
label: string
@@ -37,17 +37,43 @@ export const QUOTE_SOURCE_MAX_LENGTH = 2_000_000
*/
const IMAGE_TAG = /<img\b[^>]*>/gi
/** 태그 하나 — IMAGE_TAG 와 같은 가정(정화한 HTML 이라 속성 값 안의 `>` 는 &gt; 다)으로 태그를 걷어 내 글자만 본다 */
const ANY_TAG = /<[^>]*>/g
/** 공백 문자 참조(&nbsp;) — 글자로 세지 않는다 (U+00A0 등 공백 문자 그대로인 것은 \s 가 걸러 낸다) */
const SPACE_ENTITY = /&(?:nbsp|#160|#x0*a0);/gi
/**
* 그림을 걷어 낸 원문 HTML 에 보이는 내용이 남았는가 — 태그를 걷어 내고 공백이 아닌 글자가 남았는가 (&nbsp; · U+00A0 은 글자가 아니다).
* 글자 없는 표 · 가로줄도 보이는 내용으로 치지 않는다 — 그림만 표로 늘어놓은 뉴스레터는 걷어 내면 빈 칸만 남는다.
* 그림뿐이던 원문은 이렇게 아무것도 남지 않는다 — 그때 인용은 글자 본문을 쓴다 (originalBodyHtml).
*/
export function hasVisibleQuoteContent(html: string): boolean {
return /\S/.test(html.replace(ANY_TAG, '').replace(SPACE_ENTITY, ''))
}
/** 생략 표시 문단 — 편집기의 맞춤(fit-draft)과 같은 글 */
const OMITTED_PARAGRAPH = `<p>${escapeHtml(MAIL_QUOTE_OMITTED_MARK)}</p>`
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않고, 잘랐으면 끝에 생략 표시를 둔다 */
function truncateHtml(html: string, maxLength: number): string {
if (html.length <= maxLength) return html
/** 인용할 원문 HTML — cut: 길이 때문에 여기서 잘랐다 (끝에 생략 표시를 둔다) */
interface QuotedSource {
html: string
cut: boolean
}
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않는다 */
function truncateHtml(html: string, maxLength: number): QuotedSource {
if (html.length <= maxLength) return { html, cut: false }
let cut = truncateText(html, maxLength)
// 마지막 태그가 닫히지 않았으면 그 태그 앞에서 자른다
const lastOpen = cut.lastIndexOf('<')
if (lastOpen > cut.lastIndexOf('>')) cut = cut.slice(0, lastOpen)
return `${cut}${OMITTED_PARAGRAPH}`
return { html: cut, cut: true }
}
/** 텍스트를 길이에 맞춰 자른 뒤 문단 HTML 로 — 서로게이트 쌍 가운데는 자르지 않는다 */
function truncateTextToParagraphs(text: string, maxLength: number): QuotedSource {
if (text.length <= maxLength) return { html: textToParagraphHtml(text), cut: false }
return { html: textToParagraphHtml(truncateText(text, maxLength)), cut: true }
}
const NO_SUBJECT = '(제목 없음)'
@@ -61,14 +87,24 @@ export function textToParagraphHtml(text: string): string {
.join('')
}
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody'>
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody' | 'isBodyTruncated'>
/** 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), 없으면 textBody 를 문단으로. 아주 길면 자르고 생략 표시 */
/**
* 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), null 이거나 공백뿐이면 textBody 를 문단으로.
* 고르는 규칙은 읽기 화면과 같다 (mail-html.ts 의 displayedHtmlBody) — 화면에 보인 본문을 인용한다. 아주 길면 자른다.
* 다만 그림을 걷어 내니 보이는 것이 남지 않았으면(그림뿐이던 원문) 공백뿐인 HTML 과 같이 textBody 를 쓴다 —
* 인용은 그림을 받지 않으므로, 읽기 화면에 보인 그림 대신 글자 본문이라도 남긴다 (읽기 화면은 그대로 그림을 보인다).
* 여기서 잘랐거나 서버가 읽기 한도(2MB)에서 잘라 보냈으면(isBodyTruncated — 인용하는 본문이 그 본문이다)
* 끝에 생략 표시를 한 번 둔다 — 원문이 거기서 끝나는 것처럼 보이지 않게.
*/
export function originalBodyHtml(message: OriginalSource): string {
if (message.htmlBody) return truncateHtml(message.htmlBody.replace(IMAGE_TAG, ''), QUOTE_SOURCE_MAX_LENGTH)
const text = message.textBody ?? ''
if (text.length <= QUOTE_SOURCE_MAX_LENGTH) return textToParagraphHtml(text)
return `${textToParagraphHtml(truncateText(text, QUOTE_SOURCE_MAX_LENGTH))}${OMITTED_PARAGRAPH}`
const html = displayedHtmlBody(message.htmlBody)?.replace(IMAGE_TAG, '') ?? null
const source =
html !== null && hasVisibleQuoteContent(html)
? truncateHtml(html, QUOTE_SOURCE_MAX_LENGTH)
: truncateTextToParagraphs(message.textBody ?? '', QUOTE_SOURCE_MAX_LENGTH)
const omitted = source.cut || message.isBodyTruncated === true
return omitted ? `${source.html}${OMITTED_PARAGRAPH}` : source.html
}
type HeaderSource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'>
+74 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { buildReplyDraft, buildReplyRecipients, buildReplySubject } from './mail-reply'
@@ -75,6 +75,57 @@ describe('buildReplyRecipients', () => {
})
})
describe('buildReplyRecipients — 내가 보낸 메일(보낸 사람이 나)에 답장', () => {
const MYSELF = { name: '김아라', email: ME }
it('답장 — 원래 받는 사람(나는 뺀다). 참조는 넣지 않는다', () => {
const sent = message({ from: MYSELF, to: [JUNHO, MYSELF, DOYUN], cc: [INFRA] })
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO, DOYUN], cc: [] })
})
it('전체 답장 — 원래 받는 사람과 원래 참조 (둘 다 나는 뺀다)', () => {
const sent = message({ from: MYSELF, to: [JUNHO], cc: [INFRA, MYSELF] })
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [INFRA] })
})
it('회신 주소(Reply-To)가 있어도 원래 받는 사람에게 — 내 메일의 회신 주소는 남이 답할 때 쓰는 값이다', () => {
const sent = message({ from: MYSELF, replyTo: [INFRA], to: [JUNHO] })
expect(buildReplyRecipients(sent, ME, 'reply')).toEqual({ to: [JUNHO], cc: [] })
expect(buildReplyRecipients(sent, ME, 'replyAll')).toEqual({ to: [JUNHO], cc: [] })
})
it.each(['reply', 'replyAll'] as const)(
'나에게만 보낸 메일이면 받는 사람 칸을 비우지 않고 나에게 답장한다 (%s)',
(mode) => {
const toSelf = message({ from: MYSELF, to: [MYSELF], cc: [] })
expect(buildReplyRecipients(toSelf, ME, mode)).toEqual({ to: [MYSELF], cc: [] })
},
)
it('받는 사람 칸이 비고 참조만 있으면 — 받는 사람은 나, 전체 답장은 원래 참조를 그대로 둔다', () => {
const ccOnly = message({ from: MYSELF, to: [], cc: [INFRA] })
expect(buildReplyRecipients(ccOnly, ME, 'reply')).toEqual({ to: [MYSELF], cc: [] })
expect(buildReplyRecipients(ccOnly, ME, 'replyAll')).toEqual({ to: [MYSELF], cc: [INFRA] })
})
it('보낸 사람이 나인지는 앞뒤 공백 · 대소문자를 가리지 않는다', () => {
const sent = message({ from: { name: '김아라', email: ' ARA.Kim@AraSystem.it.kr ' }, to: [JUNHO] })
expect(buildReplyRecipients(sent, ` ${ME.toUpperCase()} `, 'reply').to).toEqual([JUNHO])
})
it('로그인한 주소를 모르면(빈 값) 지금처럼 보낸 사람에게', () => {
const sent = message({ from: MYSELF, to: [JUNHO] })
expect(buildReplyRecipients(sent, '', 'reply').to).toEqual([MYSELF])
})
})
describe('buildReplyDraft', () => {
it('받는 사람 · Re: 제목 · inReplyTo(메일 id) 를 채운다', () => {
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
@@ -118,6 +169,28 @@ describe('buildReplyDraft', () => {
expect(html).not.toContain('텍스트')
})
it('서버가 읽기 한도에서 자른 원문(isBodyTruncated)이면 인용 끝에 생략 표시 — 편집기가 줄이지 않아도', () => {
const { html } = buildReplyDraft(
message({ htmlBody: '<p>앞부분</p>', isBodyTruncated: true }),
ME,
'reply',
'Asia/Seoul',
)
expect(html.endsWith(`<p>앞부분</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
it('서식 본문이 공백뿐이면(외부 그림만 있던 메일) 읽기 화면처럼 글자 본문을 인용한다 — 잘린 원문이면 생략 표시도', () => {
const { html } = buildReplyDraft(
message({ htmlBody: '\n\n\n\n\n', textBody: '원문 글자 본문', isBodyTruncated: true }),
ME,
'reply',
'Asia/Seoul',
)
expect(html.endsWith(`<p>원문 글자 본문</p><p>${MAIL_QUOTE_OMITTED_MARK}</p></blockquote>`)).toBe(true)
})
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
+27 -3
View File
@@ -31,11 +31,32 @@ export function buildReplySubject(subject: string): string {
type ReplySource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'replyTo'>
/**
* 내가 보낸 메일(보낸 사람이 나)에 답장 — 그 메일을 받은 사람에게 보낸다 (Reply-To 는 보지 않는다).
* - 답장: 원래 받는 사람 · 전체 답장: 원래 받는 사람과 원래 참조 — 둘 다 나는 뺀다
* - 받는 사람이 비면(나에게만 보낸 메일) 나에게 답장한다 — 받는 사람 칸을 비워 두지 않는다
*/
function replyToOwnMessage(
message: ReplySource,
me: Address,
notMe: (address: Address) => boolean,
mode: ReplyMode,
): { to: Address[]; cc: Address[] } {
const recipients = uniqueAddresses(message.to).filter(notMe)
const to = recipients.length > 0 ? recipients : [me]
if (mode === 'reply') return { to, cc: [] }
const cc = uniqueAddresses(message.cc).filter(
(address) => notMe(address) && !to.some((target) => sameAddress(target, address)),
)
return { to, cc }
}
/**
* 답장 받는 사람.
* - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람
* - 전체 답장: 위 주소 + 원래 받는 사람(받는 사람 칸) + 원래 참조(참조 칸)
* 어느 쪽이든 나 자신은 뺀다. 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다.
* 어느 쪽이든 나 자신은 뺀다 (주소는 앞뒤 공백 · 대소문자를 가리지 않고 비교한다).
* 내가 보낸 메일에 답장하면 원래 받는 사람에게 보낸다 (replyToOwnMessage — 나에게만 보낸 메일이면 나에게).
*/
export function buildReplyRecipients(
message: ReplySource,
@@ -45,9 +66,12 @@ export function buildReplyRecipients(
const me = normalizeEmail(myEmail)
const notMe = (address: Address) => normalizeEmail(address.email) !== me
const primary = message.replyTo.length > 0 ? message.replyTo : message.from ? [message.from] : []
const { from } = message
if (from && me !== '' && !notMe(from)) return replyToOwnMessage(message, from, notMe, mode)
const primary = message.replyTo.length > 0 ? message.replyTo : from ? [from] : []
const others = primary.filter(notMe)
// 내가 보낸 메일이면(보낸 사람이 나) 원래 받는 사람에게 답장한다
// 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게 답장한다
const base = others.length > 0 ? others : message.to.filter(notMe)
if (mode === 'reply') return { to: uniqueAddresses(base), cc: [] }
@@ -77,6 +77,26 @@ describe('acceptStyleValue — 들어온 값(붙여 넣기 · 원문 인용)을
expect(acceptStyleValue('font-size', '80pt')).toBeNull()
})
it('보이지 않을 만큼 작은 글자는 받지 않는다 — 하한 8px 은 pt 를 px 로 바꾼 값으로 본다 (6pt = 8px · 5.25pt = 7px)', () => {
expect(acceptStyleValue('font-size', '8px')).toBe('8px')
expect(acceptStyleValue('font-size', '6pt')).toBe('8px')
expect(acceptStyleValue('font-size', '5.25pt')).toBeNull()
expect(acceptStyleValue('font-size', '7.99px')).toBeNull()
expect(acceptStyleValue('font-size', '1px')).toBeNull()
})
it('거의 투명한 글자 색(알파 0.3 미만)은 받지 않는다 — 배경 색 · 테두리 색은 알파 0~1 그대로', () => {
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.3)')).toBe('rgba(0, 0, 0, 0.3)')
expect(acceptStyleValue('color', 'rgba(0,0,0,.35)')).toBe('rgba(0,0,0,.35)')
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 1)')).toBe('rgba(0, 0, 0, 1)')
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0.1)')).toBeNull()
expect(acceptStyleValue('color', 'rgba(0, 0, 0, 0)')).toBeNull()
expect(acceptStyleValue('color', 'rgb(0, 0, 0)')).toBe('rgb(0, 0, 0)')
expect(acceptStyleValue('background-color', 'rgba(0, 0, 0, 0.1)')).toBe('rgba(0, 0, 0, 0.1)')
expect(acceptStyleValue('border-color', 'rgba(0, 0, 0, 0)')).toBe('rgba(0, 0, 0, 0)')
expect(isOutgoingStyleValue('border', '1px solid rgba(0, 0, 0, 0)')).toBe(true)
})
it('lengthToPx — px · pt 만 (기준 크기를 모르는 em · % 는 null)', () => {
expect(lengthToPx('40px')).toBe(40)
expect(lengthToPx('36pt')).toBe(48)
@@ -7,6 +7,9 @@
// 쓰는 곳: 서식 편집기가 붙여 넣은 글 · 원문 인용을 읽어 들일 때 이 형식에 맞는 값만 받는다 (editor/mail-format-extensions.ts).
// 서버만 지우면 편집기에서만 보이는(또는 숨는) 모양이 생긴다 — 예: font-size:0 으로 숨긴 글이 받는 사람에게는 보인다.
// 그래서 편집기에서도 처음부터 버린다 — 편집기에 보이는 모양 = 받는 사람이 보는 모양.
// 숨긴 글을 막는 하한은 두 가지만 막는다 — 8px 보다 작은 글자 크기, 알파가 0.3 보다 작은 글자 색(rgba).
// 그런 값은 그 서식만 버려 기본 크기 · 색으로 보인다 (계약 2절 "서식 본문", 디자인 README "붙여 넣은 글 · 원문 인용의 서식").
// 바탕과 같은 색의 글(흰 바탕에 흰 글 등)은 막지 않는다 — 받는 쪽에서 글 뒤에 깔릴 바탕 색을 알 수 없기 때문이다.
// ── style 값 형식 (서버 STYLE_RULES 와 같다 — 모두 처음부터 끝까지, 대소문자 무시) ──────────────
@@ -14,13 +17,18 @@
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`
/** 칸 폭 — px · % · auto */
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만, 알파 0~1) — 배경 색 · 테두리 색 */
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`
/**
* 글자 색 — COLOR 와 같되 rgb()/rgba() 에 알파가 있으면 0.3~1 (거의 투명한 글자로 숨긴 글을 보내지 않게).
* hex 는 알파가 없다 (8자리 hex 는 COLOR 에서도 받지 않는다).
*/
const TEXT_COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:1|0?\.[3-9]\d{0,2})\s*)?\))`
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`
/** 글자 크기 — px 만, 1~99.99px */
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`
/** 글자 크기 — px 만, 8~99.99px. 8px 아래는 보이지 않게 숨긴 글로 보고 받지 않는다 */
const FONT_SIZE = String.raw`(?:(?:[89]|[1-9]\d)(?:\.\d{1,2})?px)`
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 */
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`
@@ -67,6 +75,7 @@ const FONT_NAME = (() => {
const exactly = (pattern: string) => new RegExp(`^${pattern}$`, 'i')
const COLOR_VALUE = exactly(COLOR)
const TEXT_COLOR_VALUE = exactly(TEXT_COLOR)
const PX_VALUE = exactly(PX)
const WIDTH_VALUE = exactly(WIDTH)
/** margin · padding 줄임형 — px 길이 1~4개 */
@@ -78,7 +87,7 @@ const BORDER_VALUE = exactly(String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}
* 여기 없는 속성(display · position · visibility · opacity · text-decoration 등)은 서버가 지운다.
*/
export const OUTGOING_STYLE_RULES = {
color: COLOR_VALUE,
color: TEXT_COLOR_VALUE,
'background-color': COLOR_VALUE,
'font-family': exactly(String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`),
'font-size': exactly(FONT_SIZE),
@@ -148,7 +157,8 @@ export function lengthToPx(value: string): number | null {
/**
* 들어온 style 값 → 보낼 수 있는 값. 형식이 틀리면 null (그 서식은 받지 않는다).
* - `!important` 는 떼고 본다 (서버도 값만 본다)
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px)
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px).
* 하한은 바꾼 px 로 본다 — 6pt(8px)는 받고 5.25pt(7px)는 받지 않는다
*/
export function acceptStyleValue(property: OutgoingStyleProperty, raw: string | null | undefined): string | null {
if (typeof raw !== 'string') return null
@@ -13,6 +13,9 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['color', 'rgb(23, 92, 211)', true],
['color', 'RGB(1,2,3)', true],
['color', 'rgba(0, 0, 0, 0.5)', true],
['color', 'rgba(0, 0, 0, 0.3)', true],
['color', 'rgba(0, 0, 0, 0.29)', false],
['color', 'rgba(0, 0, 0, 0)', false],
['color', 'red', false],
['color', 'transparent', false],
['color', 'currentcolor', false],
@@ -26,11 +29,14 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['background-color', '#FFF1A8', true],
['background-color', 'yellow', false],
['background-color', 'transparent', false],
// 글자 크기 — px 만, 1~99.99px
['background-color', 'rgba(0, 0, 0, 0)', true],
// 글자 크기 — px 만, 8~99.99px (8px 미만은 보이지 않게 숨긴 글로 보고 지운다)
['font-size', '14px', true],
['font-size', '12.5px', true],
['font-size', '13.33px', true],
['font-size', '1px', true],
['font-size', '1px', false],
['font-size', '8px', true],
['font-size', '7.99px', false],
['font-size', '99.99px', true],
['font-size', '0', false],
['font-size', '0px', false],
+11 -2
View File
@@ -1,6 +1,15 @@
// 단위 테스트 전용 — 앱과 같은 이름의 라우트를 가진 메모리 라우터 (화면은 빈 컴포넌트)
import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { computed, h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, matchedRouteKey, type Router } from 'vue-router'
/**
* RouterView 없이 바로 띄운 화면에 "지금 그리는 경로"를 준다 — 화면의 onBeforeRouteLeave · onBeforeRouteUpdate 가 그 경로에 걸린다
* (RouterView 가 하위 화면에 주는 값과 같다). 띄우기 전에 router.push 로 그 경로에 가 있어야 한다.
* 사용: mount(Page, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
*/
export function routeViewProvide(router: Router): Record<symbol, unknown> {
return { [matchedRouteKey as symbol]: computed(() => router.currentRoute.value.matched.at(-1)) }
}
const Empty: FunctionalComponent = () => h('div')