chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
88 changed files with 8717 additions and 418 deletions
Showing only changes of commit 5710151c0b - Show all commits
@@ -0,0 +1,68 @@
<script setup lang="ts">
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
import ModalDialog from '@/components/feedback/ModalDialog.vue'
interface LeaveDialogCopy {
title: string
message: string
/** 머무르기 단추 */
stay: string
/** 나가기 단추 */
leave: string
}
interface Props {
open: boolean
/** 고정 id 의 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
copy: LeaveDialogCopy
}
interface Emits {
/** 머무르기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 바꾼 것을 버리고 이동한다 */
(e: 'leave'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
:id="`${idPrefix}-dialog`"
:open="open"
role="alertdialog"
:title="copy.title"
:message="copy.message"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('stay')"
>
<template #actions>
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 바꾼 것이 남는다 -->
<button
:id="`${idPrefix}-stay`"
type="button"
class="modal-dialog__action"
autofocus
@click="emit('stay')"
>
{{ copy.stay }}
</button>
<button
:id="`${idPrefix}-confirm`"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('leave')"
>
{{ copy.leave }}
</button>
</template>
</ModalDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
+33 -3
View File
@@ -1,11 +1,26 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
import AppIcon from '@/components/AppIcon.vue'
import { formatTraceCode } from '@/shared/utils/format'
import { useToastStore } from '@/stores/toast.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
const toastStore = useToastStore()
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
function onFocusOut(event: FocusEvent, id: number) {
const next = event.relatedTarget
if (next instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(next)) return
toastStore.release(id, 'focus')
}
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */
function runAction(toast: Toast) {
const action = toast.action
toastStore.dismiss(toast.id)
if (action && 'run' in action) action.run()
}
</script>
<template>
@@ -23,6 +38,10 @@ const toastStore = useToastStore()
:key="toast.id"
:class="['toast', `toast--${toast.kind}`]"
:role="toast.kind === 'error' ? 'alert' : 'status'"
@mouseenter="toastStore.hold(toast.id, 'pointer')"
@mouseleave="toastStore.release(toast.id, 'pointer')"
@focusin="toastStore.hold(toast.id, 'focus')"
@focusout="onFocusOut($event, toast.id)"
>
<AppIcon
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
@@ -38,13 +57,21 @@ const toastStore = useToastStore()
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
</div>
<RouterLink
v-if="toast.action"
v-if="toast.action && 'to' in toast.action"
:to="toast.action.to"
class="toast__action"
@click="toastStore.dismiss(toast.id)"
>
{{ toast.action.label }}
</RouterLink>
<button
v-else-if="toast.action"
type="button"
class="toast__action"
@click="runAction(toast)"
>
{{ toast.action.label }}
</button>
<button
v-if="toast.kind === 'error'"
type="button"
@@ -140,8 +167,11 @@ const toastStore = useToastStore()
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-on-brand-link);
font: inherit;
font-weight: 600;
text-decoration: none;
}
@@ -0,0 +1,42 @@
<script setup lang="ts">
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 문구는 쓰기 화면이 에러 코드 사전에서 가져와 준다. 그 칸의 입력이 id 를 aria-describedby 로 가리킨다
import AppIcon from '@/components/AppIcon.vue'
interface Props {
/** 칸이 가리키는 id — 화면에 하나뿐인 값 */
id: string
/** 사전 문구 */
message: string
}
defineProps<Props>()
</script>
<template>
<p
:id="id"
class="compose-alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ message }}</span>
</p>
</template>
<style scoped>
.compose-alert {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-danger-border);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
</style>
@@ -0,0 +1,135 @@
<script setup lang="ts">
// 쓰기 화면 아래 단추 줄의 임시 저장 상태 (디자인 원본 ComposeAssist · README "2단계 › 임시 저장") — 12px
// - 저장하는 중: "저장하는 중…" (#667085)
// - 저장됨: ✓ "오후 3:12에 임시 저장됨" (✓ #0E7C86)
// - 실패: "임시 저장하지 못했습니다" (#B42318) + [다시 시도]
// - 다른 곳에서 바뀐 저장본(MAIL_008): 정보 아이콘 "다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14" (#7A5200)
// 상태 글은 role="status" 라 바뀌면 스크린리더가 읽는다. 아직 저장한 적이 없으면 아무것도 보이지 않는다.
// 고정 id — #compose-draft-status · 다시 시도 #compose-draft-retry
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
import type { IconName } from '@/shared/constants/icons'
import { DRAFT_STATUS_TEXT } from '@/shared/constants/mail'
import { formatDraftSavedTime } from '@/shared/utils/format'
interface Props {
status: DraftSaveStatus
/** 마지막으로 저장한 시각 (ISO 8601) */
savedAt: string | null
/** 다시 시도할 수 없다 (보내는 중 등) */
retryDisabled?: boolean
}
interface Emits {
(e: 'retry'): void
}
const props = withDefaults(defineProps<Props>(), {
retryDisabled: false,
})
const emit = defineEmits<Emits>()
const time = computed(() => (props.savedAt ? formatDraftSavedTime(props.savedAt) : ''))
const view = computed<{ icon: IconName; text: string; tone: 'muted' | 'saved' | 'danger' | 'warning' } | null>(() => {
switch (props.status) {
case 'saving':
return { icon: 'loader', text: DRAFT_STATUS_TEXT.saving, tone: 'muted' }
case 'saved':
return time.value ? { icon: 'check', text: DRAFT_STATUS_TEXT.saved(time.value), tone: 'saved' } : null
case 'replaced':
return { icon: 'info', text: DRAFT_STATUS_TEXT.replaced(time.value), tone: 'warning' }
case 'failed':
return { icon: 'alert-circle', text: DRAFT_STATUS_TEXT.failed, tone: 'danger' }
default:
return null
}
})
</script>
<template>
<span class="draft-status">
<span
id="compose-draft-status"
:class="['draft-status__text', view ? `draft-status__text--${view.tone}` : null]"
role="status"
:data-status="status"
>
<template v-if="view">
<AppIcon
:name="view.icon"
:size="14"
:stroke-width="view.tone === 'saved' ? 2.2 : 1.9"
:class="['draft-status__icon', { 'draft-status__icon--saved': view.tone === 'saved' }]"
/>
<span>{{ view.text }}</span>
</template>
</span>
<button
v-if="status === 'failed'"
id="compose-draft-retry"
type="button"
class="draft-status__retry"
:disabled="retryDisabled"
@click="emit('retry')"
>
{{ DRAFT_STATUS_TEXT.retry }}
</button>
</span>
</template>
<style scoped>
.draft-status {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-4);
min-width: 0;
}
.draft-status__text {
display: inline-flex;
align-items: flex-start;
gap: var(--space-6);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.draft-status__icon {
margin-top: var(--space-2);
}
.draft-status__icon--saved {
color: var(--color-brand);
}
.draft-status__text--danger {
color: var(--color-danger);
}
.draft-status__text--warning {
color: var(--color-warning-text);
}
.draft-status__retry {
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.draft-status__retry:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.draft-status__retry:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
// 쓰기 화면의 아래 단추 줄 — [보내기] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist)
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
import AppIcon from '@/components/AppIcon.vue'
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
interface Props {
canSend: boolean
sending: boolean
canSaveDraft: boolean
draftStatus: DraftSaveStatus
/** 마지막으로 저장한 시각 (ISO 8601) */
draftSavedAt: string | null
/** 보내는 사람 — "이름 <주소>" (비면 숨긴다) */
sender: string
}
interface Emits {
/** 「임시 저장」 · 상태 글의 [다시 시도] */
(e: 'save-draft'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<div class="compose__footer">
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-busy="sending"
>
<AppIcon
name="send"
:size="15"
:stroke-width="2"
/>
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
</button>
<button
id="compose-save-draft"
type="button"
class="compose__save"
:disabled="!canSaveDraft"
@click="emit('save-draft')"
>
임시 저장
</button>
<ComposeDraftStatus
:status="draftStatus"
:saved-at="draftSavedAt"
:retry-disabled="!canSaveDraft"
@retry="emit('save-draft')"
/>
<span
v-if="sender"
class="compose__sender"
>
<span class="compose__sender-label">보내는 사람</span>
{{ sender }}
</span>
</div>
</template>
<style scoped>
.compose__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
}
.compose__send {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-20);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.compose__send:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.compose__send:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 「임시 저장」 — 보조 단추 (디자인 원본 ComposeAssist) */
.compose__save {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.compose__save:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.compose__sender {
margin-left: auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
.compose__sender-label {
margin-right: var(--space-6);
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
// 쓰기 화면의 머리 — 제목(쓰기 종류) · 휴지통 · 닫기 (디자인 원본 ComposeAssist: 단추 사이 2)
// 휴지통은 저장본을 휴지통으로 옮기고 닫는다(쓰기 화면이 한다), 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다.
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 제목 #compose-title · 휴지통 #compose-discard · 닫기 .compose__close
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { DRAFT_DISCARD_LABEL } from '@/shared/constants/mail'
interface Props {
title: string
/** 휴지통을 누를 수 없다 — 버리는 중 · 보내는 중 · 저장본을 불러오는 중 */
discardDisabled: boolean
/** 닫으면 갈 곳 — 보던 화면 */
closeTo: RouteLocationRaw
}
interface Emits {
(e: 'discard'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<div class="compose__header">
<h1
id="compose-title"
class="compose__title"
>
{{ title }}
</h1>
<!-- 저장본을 휴지통으로 옮기고 닫는다 (디자인 원본 ComposeAssist "임시 저장본 삭제") -->
<button
id="compose-discard"
type="button"
class="compose__icon-button"
:aria-label="DRAFT_DISCARD_LABEL"
title="삭제"
:disabled="discardDisabled"
@click="emit('discard')"
>
<AppIcon name="trash" />
</button>
<!-- 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다 -->
<RouterLink
:to="closeTo"
class="compose__icon-button compose__close"
aria-label="쓰기 닫기"
title="닫기"
>
<AppIcon name="x" />
</RouterLink>
</div>
</template>
<style scoped>
.compose__header {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-18) var(--space-4) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-border);
}
.compose__title {
flex: 1 1 auto;
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
/* 머리의 아이콘 단추 — 휴지통 · 닫기 (44) */
.compose__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
}
.compose__icon-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__icon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
// 쓰기 화면 나가기 전 확인 — 디자인 README "2단계 › 임시 저장 › 나가기 확인" (공통 틀 LeaveConfirmDialog · 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 LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
@@ -13,7 +14,7 @@ interface Props {
interface Emits {
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
/** 나가기 — 저장하지 못한 내용을 버리고 이동한다 */
(e: 'leave'): void
}
@@ -22,35 +23,11 @@ const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
id="compose-leave-dialog"
<LeaveConfirmDialog
: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>
id-prefix="compose-leave"
:copy="COMPOSE_LEAVE_DIALOG"
@stay="emit('stay')"
@leave="emit('leave')"
/>
</template>
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
const DRAFTS: Mailbox = {
id: 'drafts',
name: '임시보관함',
role: 'drafts',
parentId: null,
sortOrder: 3,
totalCount: 2,
unreadCount: 0,
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [DRAFTS.id],
from: { name: '김아라', email: 'ara.kim@arasystem.test' },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2025-10-24T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
describe('MessageList (분할 보기 목록)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
restoreApiAdapter()
})
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
const store = useMailStore()
store.mailboxes = [mailbox]
store.currentMailboxId = mailbox.id
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.isReady()
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
}
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const wrapper = await mountList(DRAFTS, [
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
summary('d2', { to: [], subject: '' }),
])
const rows = wrapper.findAll('.message-row')
expect(rows[0]?.get('.message-row__who').text()).toBe('박준호, 인프라팀')
expect(rows[0]?.classes()).not.toContain('is-unread')
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
expect(rows[1]?.get('.message-row__who').text()).toBe('(받는 사람 없음)')
expect(rows[1]?.get('.message-row__who').classes()).toContain('is-placeholder')
expect(rows[1]?.get('.message-row__subject').text()).toBe('(제목 없음)')
expect(rows[1]?.get('.message-row__subject').classes()).toContain('is-placeholder')
})
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
const row = wrapper.get('.message-row')
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
})
})
@@ -27,6 +27,8 @@ const showRecipients = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
@@ -70,6 +72,7 @@ function goToInbox() {
:mailbox-id="mailboxId"
:selected="message.id === mailStore.selectedId"
:show-recipients="showRecipients"
:draft="isDrafts"
@toggle-flag="mailStore.toggleFlag"
/>
</ul>
@@ -1,11 +1,15 @@
<script setup lang="ts">
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
interface Props {
message: MessageSummary
@@ -14,6 +18,8 @@ interface Props {
selected?: boolean
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
showRecipients?: boolean
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
draft?: boolean
}
interface Emits {
@@ -23,31 +29,42 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
selected: false,
showRecipients: false,
draft: false,
})
const emit = defineEmits<Emits>()
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
const who = computed(() => {
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
if (props.showRecipients) {
const recipients = summarizeRecipients(props.message.to)
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
}
return displayName(props.message.from) || '(보낸 사람 없음)'
})
const subject = computed(() => props.message.subject || '(제목 없음)')
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const time = computed(() => formatMailListTime(props.message.receivedAt))
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
const unread = computed(() => !props.draft && !props.message.isRead)
const location = computed<RouteLocationRaw>(() =>
props.draft
? { name: 'mail-compose', query: { draft: props.message.id } }
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
)
</script>
<template>
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
<RouterLink
class="message-row__main"
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
:to="location"
>
<span class="message-row__top">
<span class="message-row__who">
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
<span
v-if="!message.isRead"
v-if="unread"
class="visually-hidden"
>안 읽음, </span>{{ who }}
</span>
@@ -58,7 +75,7 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
</span>
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
<span class="message-row__bottom">
<span class="message-row__subject">{{ subject }}</span>
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
@@ -166,6 +183,12 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
color: var(--color-text);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.message-row.is-unread .message-row__who {
font-weight: 650;
@@ -161,6 +161,52 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
})
})
describe('MessageReader 첨부 목록 (계약 2절 "MessageAttachment.isInline")', () => {
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] } })
}
const REPORT = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048 }
const LOGO = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024 }
it('본문 안에 그림으로 보인 첨부(isInline)는 목록 · 개수에서 뺀다', async () => {
const wrapper = await mountWith({
...DETAIL,
hasAttachment: true,
attachments: [
{ ...REPORT, isInline: false },
{ ...LOGO, isInline: true },
],
})
expect(wrapper.get('#reader-attachments-title').text()).toBe('첨부 파일 1개')
expect(wrapper.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
})
it('본문 그림뿐이면 첨부 목록이 없다 — 표시 값이 없는(이전 서버) 첨부는 첨부로 본다', async () => {
const inlineOnly = await mountWith({ ...DETAIL, attachments: [{ ...LOGO, isInline: true }] })
expect(inlineOnly.find('#reader-attachments-title').exists()).toBe(false)
inlineOnly.unmount()
const legacy = await mountWith({ ...DETAIL, attachments: [REPORT] })
expect(legacy.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
})
})
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 { listedAttachments } from '@/shared/utils/mail-attachment'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { useMailStore } from '@/stores/mail.store'
@@ -45,6 +46,8 @@ const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
async function onDelete() {
const current = message.value
@@ -241,9 +244,9 @@ function leaveToList() {
/>
<MessageAttachments
v-if="message.attachments.length > 0"
v-if="attachments.length > 0"
:message-id="message.id"
:attachments="message.attachments"
:attachments="attachments"
/>
</div>
</article>
@@ -151,6 +151,37 @@ describe('MessageTable (목록 보기)', () => {
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
})
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const { wrapper, router } = await mountTable({
mailbox: drafts,
messages: [
summary('d1', {
isRead: false,
to: [
{ name: '박준호', email: 'junho.park@arasystem.test' },
{ name: null, email: 'infra@arasystem.test' },
],
}),
summary('d2', { to: [], subject: '' }),
],
})
const rows = wrapper.findAll('tbody tr')
expect(rows[0]?.get('.mail-table__who').text()).toBe('박준호, infra@arasystem.test')
// 임시 저장본은 읽음 여부를 보이지 않는다
expect(rows[0]?.classes()).not.toContain('is-unread')
expect(rows[1]?.get('.mail-table__who').text()).toBe('(받는 사람 없음)')
expect(rows[1]?.get('.mail-table__who').classes()).toContain('is-placeholder')
expect(rows[1]?.get('.mail-table__link').text()).toBe('(제목 없음)')
expect(rows[1]?.get('.mail-table__link').classes()).toContain('is-placeholder')
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=d1')
await rows[1]?.get('.mail-table__who').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d2')
})
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
const requests = installMockApi(() =>
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
+38 -10
View File
@@ -2,8 +2,10 @@
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
@@ -11,10 +13,10 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
@@ -38,6 +40,8 @@ const isOutgoing = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
const empty = computed(() =>
@@ -47,16 +51,34 @@ const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
function whoOf(message: MessageSummary): string {
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
return displayName(message.from) || '(보낸 사람 없음)'
}
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
function hasNoDraftRecipients(message: MessageSummary): boolean {
return isDrafts.value && draftRecipientNames(message.to) === ''
}
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
function isUnread(message: MessageSummary): boolean {
return !isDrafts.value && !message.isRead
}
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
function rowLocation(message: MessageSummary): RouteLocationRaw {
return isDrafts.value
? { name: 'mail-compose', query: { draft: message.id } }
: messageLocation(props.mailboxId, message.id)
}
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent, message: MessageSummary) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('a, button')) return
if (window.getSelection()?.toString()) return
void router.push(messageLocation(props.mailboxId, message.id))
void router.push(rowLocation(message))
}
function retry() {
@@ -167,7 +189,7 @@ onMounted(async () => {
<tr
v-for="message in mailStore.messages"
:key="message.id"
:class="['mail-table__row', { 'is-unread': !message.isRead }]"
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
@click="onRowClick($event, message)"
>
<td class="mail-table__star">
@@ -186,20 +208,20 @@ onMounted(async () => {
/>
</button>
</td>
<td class="mail-table__who">
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
<span
v-if="!message.isRead"
v-if="isUnread(message)"
class="visually-hidden"
>안 읽음, </span>{{ whoOf(message) }}
</td>
<td class="mail-table__subject">
<div class="mail-table__subject-line">
<RouterLink
class="mail-table__link"
:to="messageLocation(mailboxId, message.id)"
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
:to="rowLocation(message)"
:data-message-id="message.id"
>
{{ message.subject || '(제목 없음)' }}
{{ message.subject || MAIL_NO_SUBJECT }}
</RouterLink>
<AppIcon
v-if="message.hasAttachment"
@@ -404,6 +426,12 @@ onMounted(async () => {
font-variant-numeric: tabular-nums;
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.mail-table__who.is-placeholder,
.mail-table__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.mail-table__row.is-unread .mail-table__who,
.mail-table__row.is-unread .mail-table__link {
@@ -1,14 +1,51 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
import type { RecipientToken } from '@/shared/utils/mail-address'
import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
import RecipientField from './RecipientField.vue'
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
const models = new WeakMap<VueWrapper, RecipientToken[]>()
function mountField(initial: RecipientToken[] = []): VueWrapper {
/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
function suggestionsOf(pool: RecipientSuggestion[]) {
const items = shallowRef(pool)
const searches: string[] = []
const preloads: number[] = []
let current: () => RecipientToken[] = () => []
const handle: RecipientSuggestionsHandle = {
preload: () => {
preloads.push(preloads.length + 1)
},
matchesFor: (query) =>
filterRecipientSuggestions(
query,
items.value,
new Set(current().map((token) => normalizeEmail(token.address.email))),
),
search: (query) => {
searches.push(query)
},
}
return {
handle,
items,
searches,
preloads,
bind: (tokens: () => RecipientToken[]) => {
current = tokens
},
}
}
function mountField(initial: RecipientToken[] = [], suggestions?: ReturnType<typeof suggestionsOf>): VueWrapper {
const wrapper: VueWrapper = mount(RecipientField, {
attachTo: suggestions ? document.body : undefined,
global: suggestions ? { provide: { [RECIPIENT_SUGGESTIONS as symbol]: suggestions.handle } } : {},
props: {
modelValue: initial,
label: '받는 사람',
@@ -21,6 +58,7 @@ function mountField(initial: RecipientToken[] = []): VueWrapper {
},
})
models.set(wrapper, initial)
suggestions?.bind(() => models.get(wrapper) ?? [])
return wrapper
}
@@ -109,4 +147,246 @@ describe('RecipientField', () => {
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
})
describe('자동 완성 (디자인 원본 ComposeAssist)', () => {
const POOL: RecipientSuggestion[] = [
{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' },
{ name: '최도윤', email: 'choi@partner.example', source: 'recent' },
{ name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' },
{ name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' },
]
async function typed(wrapper: VueWrapper, text: string) {
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue(text)
return input
}
/** 추천 한 줄 — "이름 주소" */
function optionText(option: ReturnType<VueWrapper['find']>): string {
return option
.findAll('.recipient-suggestions__name, .recipient-suggestions__email')
.map((part) => part.text())
.join(' ')
}
function options(wrapper: VueWrapper): string[] {
return wrapper.findAll('[role="option"]').map(optionText)
}
function activeOption(wrapper: VueWrapper): string | null {
const active = wrapper.find('[role="option"][aria-selected="true"]')
return active.exists() ? optionText(active) : null
}
it('한 글자 이상 치면 칸 아래에 사내 · 최근 주고받은 주소 묶음으로 보이고, 맞는 글자는 굵게 · 첫 추천을 골라 둔다', async () => {
const suggestions = suggestionsOf(POOL)
const wrapper = mountField([], suggestions)
const input = await typed(wrapper, 'ㄷㅇ')
expect(input.attributes('role')).toBe('combobox')
expect(input.attributes('aria-expanded')).toBe('true')
expect(input.attributes('aria-controls')).toBe('compose-to-suggestions')
expect(wrapper.get('#compose-to-suggestions').attributes('role')).toBe('listbox')
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
'사내',
'최근 주고받은 주소',
])
expect(options(wrapper)).toEqual([
'이도윤 doyun.lee@arasystem.it.kr',
'김도윤 doyun.kim@arasystem.it.kr',
'최도윤 choi@partner.example',
])
expect(wrapper.findAll('#compose-to-option-0 strong').map((part) => part.text())).toEqual(['도윤'])
expect(input.attributes('aria-activedescendant')).toBe('compose-to-option-0')
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
expect(input.attributes('aria-describedby')).toBe('compose-to-suggestions-hint')
expect(suggestions.searches).toEqual(['ㄷㅇ'])
wrapper.unmount()
})
it('↑↓ 로 고르고 Enter 로 넣는다 — 폼을 제출하지 않고, 이름 <주소> 토큰이 되고 목록은 닫힌다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
await input.trigger('keydown', { key: 'ArrowDown' })
expect(activeOption(wrapper)).toBe('김도윤 doyun.kim@arasystem.it.kr')
await input.trigger('keydown', { key: 'ArrowUp' })
await input.trigger('keydown', { key: 'ArrowUp' })
expect(activeOption(wrapper)).toBe('최도윤 choi@partner.example')
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
input.element.dispatchEvent(enter)
await wrapper.vm.$nextTick()
expect(enter.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최도윤 <choi@partner.example>'])
expect((input.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
wrapper.unmount()
})
it('Tab 으로도 넣고 칸에 남아 이어 친다 — 이미 넣은 주소는 다음 추천에서 빠진다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })
input.element.dispatchEvent(tab)
await wrapper.vm.$nextTick()
expect(tab.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
await input.setValue('도윤')
expect(options(wrapper)).not.toContain('이도윤 doyun.lee@arasystem.it.kr')
wrapper.unmount()
})
it('Esc 는 목록만 닫고, 다시 치면 연다 — 누르면(마우스) 그 추천을 넣는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '준호')
await input.trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
expect(input.attributes('aria-expanded')).toBe('false')
await input.setValue('준')
const option = wrapper.get('[role="option"]')
const down = new MouseEvent('mousedown', { cancelable: true, bubbles: true })
option.element.dispatchEvent(down)
await option.trigger('click')
expect(down.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['박준호 <junho.park@arasystem.it.kr>'])
wrapper.unmount()
})
it('완전한 주소를 쳤으면 미리 고르지 않는다 — Enter 는 친 주소를 그대로, 화살표로 고른 뒤에만 추천이 들어간다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, 'doyun.lee@partner.example')
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
expect(activeOption(wrapper)).toBeNull()
expect(input.attributes('aria-activedescendant')).toBeUndefined()
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
await input.setValue('doyun.lee@partner.example')
await input.trigger('keydown', { key: 'ArrowDown' })
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
'doyun.lee@partner.example',
'doyun.lee@arasystem.it.kr',
])
wrapper.unmount()
})
it('한글 조합 중의 Enter 는 추천도 넣지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
await input.trigger('keydown', { key: 'Enter', isComposing: true })
expect(tokensOf(wrapper)).toEqual([])
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
wrapper.unmount()
})
it('Safari · macOS 처럼 조합을 끝내는 Enter 가 isComposing 없이 keyCode 229 로 와도 넣지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
input.element.dispatchEvent(enter)
await wrapper.vm.$nextTick()
expect(enter.defaultPrevented).toBe(false)
expect(tokensOf(wrapper)).toEqual([])
wrapper.unmount()
})
it('칸에 초점이 오면 미리 받기를 부른다 — 실패했던 미리 받기를 다시 해 본다 (받았으면 추천 쪽이 아무것도 하지 않는다)', async () => {
const suggestions = suggestionsOf(POOL)
const wrapper = mountField([], suggestions)
expect(suggestions.preloads).toHaveLength(0)
await wrapper.get('input').trigger('focus')
expect(suggestions.preloads).toHaveLength(1)
wrapper.unmount()
})
it('Shift+Tab 은 추천을 넣지 않는다 — 원래대로 앞 칸으로 (막지 않는다)', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const back = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })
input.element.dispatchEvent(back)
await wrapper.vm.$nextTick()
expect(back.defaultPrevented).toBe(false)
expect(tokensOf(wrapper)).toEqual([])
wrapper.unmount()
})
it('한글 조합 중인 글자로도 찾는다 — "김"(조합 중) · "ㄷㅇ"(마지막 자모가 조합 중). 토큰 확정은 조합이 끝난 글자로', async () => {
const suggestions = suggestionsOf([
...POOL,
{ name: '김아라', email: 'ara.kim@arasystem.it.kr', source: 'directory' },
])
const wrapper = mountField([], suggestions)
const input = wrapper.get('input')
const element = input.element as HTMLInputElement
element.focus()
await input.trigger('focus')
// "김" 을 조합하는 중 — v-model 은 아직 비어 있지만 추천은 보인다
await input.trigger('compositionstart')
element.value = '김'
await input.trigger('compositionupdate')
await input.trigger('input')
expect(options(wrapper)).toEqual(['김도윤 doyun.kim@arasystem.it.kr', '김아라 ara.kim@arasystem.it.kr'])
expect(suggestions.searches.at(-1)).toBe('김')
await input.trigger('compositionend')
// "ㄷ" 을 확정하고 "ㅇ" 을 조합하는 중 — 초성 "ㄷㅇ" 으로 찾는다
element.value = ''
await input.trigger('input')
await input.trigger('compositionstart')
element.value = 'ㄷ'
await input.trigger('input')
await input.trigger('compositionend')
await input.trigger('compositionstart')
element.value = 'ㄷㅇ'
await input.trigger('input')
expect(options(wrapper)).toEqual([
'이도윤 doyun.lee@arasystem.it.kr',
'김도윤 doyun.kim@arasystem.it.kr',
'최도윤 choi@partner.example',
])
// 조합 중의 Enter 는 넣지 않는다 — 조합이 끝난 뒤의 Enter 가 고른 추천을 넣는다
await input.trigger('keydown', { key: 'Enter', isComposing: true })
expect(tokensOf(wrapper)).toEqual([])
await input.trigger('compositionend')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
expect(element.value).toBe('')
wrapper.unmount()
})
it('칸을 벗어나면 목록을 닫고 친 글자를 확정한다 — 맞는 추천이 없으면 목록을 열지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, 'zzz')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
await input.setValue('준호')
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
await input.trigger('blur')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
wrapper.unmount()
})
})
})
+164 -9
View File
@@ -1,18 +1,39 @@
<script setup lang="ts">
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
// 친 글자가 완전한 메일 주소면 고르지 않는다: Enter 는 친 주소를 그대로 넣고, 화살표로 고른 뒤에만 추천이 들어간다.
// Shift+Tab 은 추천을 넣지 않는다 (원래대로 앞 칸으로 — 칸을 벗어나며 친 글자를 확정한다)
// - 추천은 입력칸에 보이는 글 그대로 찾는다 — 한글 조합 중인 글자도 ("김" · "ㄷㅇ"). v-model 은 조합이 끝나야 바뀌므로
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, ref } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import RecipientSuggestionList from '@/components/mail/RecipientSuggestionList.vue'
import {
suggestionHintId,
suggestionListId,
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { RECIPIENT_SUGGESTIONS } from '@/composables/useRecipientSuggestions'
import {
hasOpenRecipientGroup,
isValidEmail,
mergeRecipientTokens,
parseRecipient,
recipientTokenKey,
splitRecipientInput,
tokenFromAddress,
type RecipientToken,
} from '@/shared/utils/mail-address'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { RecipientMatch } from '@/shared/utils/recipient-match'
interface Props {
modelValue: RecipientToken[]
@@ -36,27 +57,103 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 아직 토큰이 되지 않은 입력 */
/** 추천 — 쓰기 화면이 주지 않았으면 자동 완성 없이 쓴다 */
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
const dismissed = ref<boolean>(false)
/** 고른 추천 — -1 이면 고르지 않았다 (Enter 는 친 글자를 넣는다) */
const activeIndex = ref<number>(-1)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '')
const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim() !== '')
const listId = computed(() => suggestionListId(props.inputId))
const hintId = computed(() => suggestionHintId(props.inputId))
const matches = computed<RecipientMatch[]>(() => (suggestions ? suggestions.matchesFor(query.value) : []))
const listOpen = computed(
() =>
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && matches.value.length > 0,
)
/** 친 글자가 이미 완전한 메일 주소다 — 추천을 미리 고르지 않는다 */
const typedFullAddress = computed(() => isValidEmail(query.value))
const activeOptionId = computed(() =>
listOpen.value && activeIndex.value >= 0 ? suggestionOptionId(props.inputId, activeIndex.value) : undefined,
)
const hint = computed(() => (typedFullAddress.value && activeIndex.value < 0 ? HINT_TYPED_ADDRESS : HINT_DEFAULT))
const describedBy = computed(() => {
const ids = [hasInvalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
return ids.filter((id) => id !== '').join(' ') || undefined
})
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
const tokens = splitRecipientInput(text)
.map(parseRecipient)
.filter((token): token is RecipientToken => token !== null)
draft.value = ''
clearDraft()
if (tokens.length === 0) return
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
}
/** 추천을 넣는다 — "이름 <주소>" 토큰으로. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
function pick(match: RecipientMatch) {
const { name, email } = match.suggestion
clearDraft()
emit('update:modelValue', mergeRecipientTokens(props.modelValue, [tokenFromAddress({ name, email })]))
}
/** 화살표로 고른다 — 끝에서 처음으로 돈다 */
function move(step: 1 | -1) {
const count = matches.value.length
if (count === 0) return
if (activeIndex.value < 0) {
activeIndex.value = step === 1 ? 0 : count - 1
return
}
activeIndex.value = (activeIndex.value + step + count) % count
}
/** 추천 목록의 키 — 처리했으면 true */
function onListKeydown(event: KeyboardEvent): boolean {
if (!listOpen.value) return false
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
move(event.key === 'ArrowDown' ? 1 : -1)
return true
}
if (event.key === 'Escape') {
event.preventDefault()
dismissed.value = true
return true
}
const active = matches.value[activeIndex.value]
// Shift+Tab 은 넣지 않는다 — 원래대로 앞 칸으로 간다
if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) && active) {
// Enter 가 폼을 제출(메일 발송)하지 않고, Tab 이 칸을 벗어나지 않게 — 넣은 뒤 이어 친다
event.preventDefault()
pick(active)
return true
}
return false
}
function onKeydown(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
if (event.isComposing) return
// 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다
if (isImeComposing(event)) return
if (onListKeydown(event)) return
const isSeparator =
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
@@ -79,6 +176,22 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
}
function onFocus() {
focused.value = true
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
function onBlur() {
focused.value = false
commit()
}
function remove(token: RecipientToken) {
const key = recipientTokenKey(token)
emit(
@@ -92,11 +205,33 @@ function focus() {
inputRef.value?.focus()
}
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤, 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
draft.value = ''
query.value = ''
}
// v-model 이 바뀌면(조합이 끝남 · 확정 · 비움) 검색어도 그 값으로
watch(draft, (text) => {
query.value = text
})
// 친 글자가 바뀌면 — 닫았던 목록을 다시 열고, 첫 추천을 골라 둔다(완전한 주소면 고르지 않는다). 모자라면 서버에 묻는다
watch(query, (text) => {
dismissed.value = false
activeIndex.value = isValidEmail(text) ? -1 : 0
suggestions?.search(text)
})
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => matches.value.length,
(count) => {
if (activeIndex.value >= count) activeIndex.value = count - 1
if (activeIndex.value < 0 && count > 0 && !typedFullAddress.value) activeIndex.value = 0
},
)
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
@@ -141,19 +276,39 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
:role="suggestions ? 'combobox' : undefined"
:aria-autocomplete="suggestions ? 'list' : undefined"
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="hasInvalid"
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@keydown="onKeydown"
@blur="commit()"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
>
</div>
<slot name="trailing" />
<RecipientSuggestionList
v-if="listOpen"
:input-id="inputId"
:label="label"
:matches="matches"
:active-index="activeIndex"
:hint="hint"
@activate="activeIndex = $event"
@pick="pick"
/>
</div>
</template>
<style scoped>
.recipient-field {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -0,0 +1,217 @@
<script setup lang="ts">
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — RecipientField 가 칸 바로 아래에 연다
// - "사내" · "최근 주고받은 주소" 두 묶음, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
// - 고르기 · 넣기 키는 입력칸이 받는다(초점은 늘 입력칸) — 이 목록은 그리기와 마우스(올리면 고르기 · 누르면 넣기)만 한다.
// 누르는 동안 입력칸의 초점을 지킨다(mousedown)
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
import { computed } from 'vue'
import {
suggestionHintId,
suggestionListId,
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { highlightParts, type HighlightPart, type RecipientMatch } from '@/shared/utils/recipient-match'
interface Props {
/** 입력칸 id — 목록 · 추천 · 안내 id 의 앞부분 */
inputId: string
/** 칸 이름 — 목록의 이름("받는 사람 추천") */
label: string
matches: RecipientMatch[]
/** 고른 추천 — -1 이면 고르지 않았다 */
activeIndex: number
/** 목록 아래 안내 */
hint: string
}
interface Emits {
/** 누른 추천을 넣는다 */
(e: 'pick', match: RecipientMatch): void
/** 마우스를 올린 줄을 고른다 */
(e: 'activate', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 추천 묶음 이름 (디자인 원본 ComposeAssist) */
const GROUP_LABELS = { directory: '사내', recent: '최근 주고받은 주소' } as const
interface OptionView {
index: number
id: string
match: RecipientMatch
/** 첫 줄 — 이름(없으면 주소) */
primary: HighlightPart[]
/** 둘째 줄 — 주소 (이름이 없으면 없다) */
secondary: HighlightPart[] | null
}
function optionView(match: RecipientMatch, index: number): OptionView {
const { name, email } = match.suggestion
return {
index,
id: suggestionOptionId(props.inputId, index),
match,
primary: name ? highlightParts(name, match.name) : highlightParts(email, match.email),
secondary: name ? highlightParts(email, match.email) : null,
}
}
/** 묶음별 추천 — 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
const groups = computed(() => {
const options = props.matches.map(optionView)
return (['directory', 'recent'] as const)
.map((source) => ({
source,
label: GROUP_LABELS[source],
options: options.filter((option) => option.match.suggestion.source === source),
}))
.filter((group) => group.options.length > 0)
})
</script>
<template>
<!-- 칸 바로 아래에 겹쳐 연다 (디자인 원본 ComposeAssist). 누르는 동안 입력칸의 초점을 지킨다(mousedown) -->
<div class="recipient-suggestions">
<div
:id="suggestionListId(inputId)"
role="listbox"
:aria-label="`${label} 추천`"
class="recipient-suggestions__list"
>
<div
v-for="group in groups"
:key="group.source"
role="group"
:aria-label="group.label"
class="recipient-suggestions__group"
>
<span
aria-hidden="true"
class="recipient-suggestions__group-label"
>{{ group.label }}</span>
<div
v-for="option in group.options"
:id="option.id"
:key="option.id"
role="option"
:aria-selected="option.index === activeIndex"
:class="['recipient-suggestions__option', { 'is-active': option.index === activeIndex }]"
@mousedown.prevent
@mousemove="emit('activate', option.index)"
@click="emit('pick', option.match)"
>
<span class="recipient-suggestions__name">
<template
v-for="(part, partIndex) in option.primary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
<span
v-if="option.secondary"
class="recipient-suggestions__email"
>
<template
v-for="(part, partIndex) in option.secondary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
</div>
</div>
</div>
<p
:id="suggestionHintId(inputId)"
class="recipient-suggestions__hint"
>
{{ hint }}
</p>
</div>
</template>
<style scoped>
/*
* 추천 목록 (디자인 원본 ComposeAssist) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
* 줄 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363) · 고른 줄 #E3F2F3 · 묶음 이름 12px #667085 굵게
*/
.recipient-suggestions {
position: absolute;
top: calc(100% + var(--space-4));
left: var(--content-inline);
right: var(--content-toolbar-start);
z-index: 4;
max-width: 36rem;
display: flex;
flex-direction: column;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-toast);
}
.recipient-suggestions__list,
.recipient-suggestions__group {
display: flex;
flex-direction: column;
}
.recipient-suggestions__group + .recipient-suggestions__group {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.recipient-suggestions__group-label {
padding: var(--space-4) var(--space-8) var(--space-2);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.recipient-suggestions__option {
display: flex;
align-items: baseline;
gap: var(--space-8);
min-height: var(--size-row-min);
padding: var(--space-9) var(--space-8);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.recipient-suggestions__option.is-active {
background: var(--color-brand-subtle);
}
.recipient-suggestions__name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-suggestions__email {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
.recipient-suggestions__hint {
margin: var(--space-4) var(--space-8) var(--space-2);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -9,6 +9,7 @@ import AppIcon from '@/components/AppIcon.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { getErrorMessage } from '@/composables/useApi'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { normalizeLinkHref } from '@/shared/utils/link-href'
interface Props {
@@ -58,8 +59,8 @@ function submit() {
}
function onEnter(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다
if (event.isComposing) return
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다 (Safari · macOS 의 keyCode 229 포함)
if (isImeComposing(event)) return
event.preventDefault()
submit()
}
@@ -160,6 +160,22 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.vm.getContentForSend()).toBeNull()
expect(editor.state.doc).toBe(before)
})
it('임시 저장도 같은 한도로 잰다 — 넘으면 인용을 줄인 모양을 싣되 편집기는 그대로, 내 글만으로 넘으면 null', () => {
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
const editor = editorOf(view.element)
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
editor.commands.insertContentAt(1, mine)
const before = editor.state.doc
const snapshot = view.vm.captureForSave()
expect(snapshot?.content.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(snapshot?.content.text.startsWith(mine.trimEnd())).toBe(true)
expect(editor.state.doc).toBe(before)
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
expect(view.vm.captureForSave()).toBeNull()
}, LONG_DRAFT_TIMEOUT_MS)
})
it('본문 오류 안내(describedBy)를 주면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다', async () => {
@@ -313,4 +329,54 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
})
describe('임시 저장 · 서명 그림 (2단계)', () => {
const SIGNATURE_IMAGE = '<img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">'
it('저장한 뒤에는 저장한 본문과 견준다 — 저장하는 동안 더 쓴 글은 바꿈으로 남는다', () => {
const view = mountEditor()
const editor = editorOf(view.element)
editor.commands.insertContent('첫 줄')
const snapshot = view.vm.captureForSave()
if (!snapshot) throw new Error('저장할 본문이 없다')
expect(snapshot.content.text).toBe('첫 줄')
// 저장하는 동안 더 썼다
editor.commands.insertContent(' 더')
view.vm.markSaved(snapshot.savePoint)
expect(view.vm.isChanged()).toBe(true)
// 더 쓴 글을 지워 저장한 모양으로 돌아오면 바꾸지 않은 것이다
const end = editor.state.doc.content.size - 1
editor.commands.deleteRange({ from: end - ' 더'.length, to: end })
expect(editor.getText()).toBe('첫 줄')
expect(view.vm.isChanged()).toBe(false)
})
it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
const view = mountEditor({ initialHtml: `<p>${SIGNATURE_IMAGE}</p>` })
const { text, html } = view.vm.getContent()
expect(text).toBe('')
expect(html).toContain(SIGNATURE_IMAGE)
})
it('붙여 넣은 HTML 의 다른 그림은 빠진다 — 서명 그림 주소만 받는다', () => {
const view = mountEditor()
const dom = view.get('.ProseMirror').element
dom.dispatchEvent(
pasteEvent({
text: '글',
html: `<p>글<img src="https://tracker.example/p.gif"><img src="data:image/png;base64,AAAA">${SIGNATURE_IMAGE}</p>`,
}),
)
const { html } = view.vm.getContent()
expect(html).toContain(SIGNATURE_IMAGE)
expect(html).not.toContain('tracker.example')
expect(html).not.toContain('data:image')
})
})
})
@@ -1,8 +1,11 @@
<script setup lang="ts">
// 메일 본문 서식 편집기 — 툴바 + 편집 영역 (디자인 원본 Compose · ComposeEditor, README "본문 서식 편집기")
// TipTap(ProseMirror)은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 쓰기 화면이 지연 로딩한다(ComposePage).
// - 처음 내용(initialHtml)은 편집기 스키마로 옮긴다. 스키마에 없는 요소(이미지·스크립트 등)는 빠지고,
// - 처음 내용(initialHtml)은 편집기 스키마로 옮긴다. 스키마에 없는 요소(서명 그림이 아닌 이미지·스크립트 등)는 빠지고,
// 서식 값은 서버가 남기는 형식만 받는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
// 그림은 서명 그림만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// - 임시 저장: captureForSave 로 저장할 본문과 그 순간의 기준점을 받고, 저장이 끝나면 markSaved 로 돌려준다 —
// 그 뒤의 isChanged 는 저장한 본문과 견준다 (쓰기 화면의 나가기 확인 · 자동 저장).
// - 답장·전달 초안은 한도(텍스트 100,000 · 서식 500,000)에서 내 글 몫(5%)을 남기고 원문 인용을 줄인다(fit-draft.ts).
// 맞춘 내용으로 편집기를 만들므로 실행 취소 기록은 맞춘 내용에서 시작한다.
// 보내기 직전에 다시 재어(getContentForSend) 넘으면 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않는다.
@@ -24,8 +27,13 @@ import AppIcon from '@/components/AppIcon.vue'
import EditorToolbar from '@/components/mail/editor/EditorToolbar.vue'
import { INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits } from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import type { MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
import type {
EditorSavePoint,
MailEditorContent,
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { hasSignatureImage } from '@/components/mail/editor/signature-image'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
@@ -69,6 +77,7 @@ let mounted = false
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 settled 도 같다).
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
@@ -139,8 +148,9 @@ const editor = markRaw(
/** 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트 */
function contentOf(doc: ProseMirrorNode): MailEditorContent {
const text = docToText(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다
if (text.trim() === '') return { text: '', html: null }
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
}
@@ -182,13 +192,35 @@ 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)
}
/**
* 임시 저장할 본문과 그 순간의 문서 — 저장하는 동안 더 쓴 글은 markSaved 뒤에도 "바꿈"으로 남는다.
* 보내기(getContentForSend)와 같은 한도로 잰다. 넘으면 인용을 줄인 모양을 싣되 편집기는 바꾸지 않는다 — 쓰는 중에 화면이 바뀌지 않게.
* 인용을 다 줄여도 넘으면 null
*/
function captureForSave(): MailEditorSnapshot | null {
const { doc } = editor.state
const savePoint = doc as unknown as EditorSavePoint
const current = contentOf(doc)
if (contentFits(current)) return { content: current, savePoint }
const fitted = fitDraftDoc(doc, editor.schema)
if (fitted === doc) return null
const content = contentOf(fitted)
return contentFits(content) ? { content, savePoint } : null
}
/** 임시 저장이 끝났다 — 그때의 문서를 기준으로 삼는다 */
function markSaved(savePoint: EditorSavePoint) {
const doc = savePoint as unknown as ProseMirrorNode
opened = { doc, settled: doc }
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -227,7 +259,7 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus, isChanged })
defineExpose({ getContent, getContentForSend, focus, isChanged, captureForSave, markSaved })
</script>
<template>
@@ -390,6 +422,18 @@ defineExpose({ getContent, getContentForSend, focus, isChanged })
color: var(--color-brand);
}
/* 서명 그림 — 받는 쪽과 같은 폭(width 속성)으로, 칸보다 넓으면 줄여 보인다. 고르면 브랜드색 고리 (디자인 원본 Settings) */
.mail-editor__body :deep(.ProseMirror img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
.mail-editor__body :deep(.ProseMirror img.ProseMirror-selectednode) {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
/* 빈 본문 안내 — 커서는 문구 앞에 선다 */
.mail-editor__body :deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
@@ -9,6 +9,20 @@ export interface MailEditorContent {
html: string | null
}
/**
* 저장한 순간의 본문 — 편집기 밖에서는 열어 보지 않는 값이다 (TipTap 문서를 쓰기 화면이 모르게 감싼다).
* 임시 저장이 끝나면 markSaved 로 돌려줘 "바꾸지 않은" 기준으로 삼는다.
*/
export interface EditorSavePoint {
readonly __editorSavePoint: true
}
/** 임시 저장할 본문 — 보낼 모양(한도에 맞추지 않은 지금 본문)과 그 순간의 기준점 */
export interface MailEditorSnapshot {
content: MailEditorContent
savePoint: EditorSavePoint
}
export interface MailEditorHandle {
/** 지금 본문 — 보낼 모양 그대로 */
getContent: () => MailEditorContent
@@ -21,9 +35,18 @@ export interface MailEditorHandle {
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false.
* 임시 저장한 뒤에는 저장한 본문(markSaved)과 견준다
*/
isChanged: () => boolean
/**
* 임시 저장할 본문 — 저장이 끝나면 그 기준점을 markSaved 로 돌려준다 (저장하는 동안 바뀐 글은 "바꿈"으로 남는다).
* 보내기와 같은 한도로 잰다: 넘으면 원문 인용을 줄인 모양을 싣는다(편집기는 그대로 — 보낼 때 맞춘다).
* 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 저장하지 않는다 (서버도 MAIL_007 로 거절한다)
*/
captureForSave: () => MailEditorSnapshot | null
/** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
markSaved: (savePoint: EditorSavePoint) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -1,7 +1,8 @@
// 서식 편집기(TipTap) 확장 구성 — 디자인 README "본문 서식 편집기 (TipTap)" · 계약 2절 "서식 본문"
// 편집기가 만드는 것 = 서버 정화 허용 목록(sanitize-outgoing-html.ts)에 있는 것만 둔다:
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크.
// 제목 스타일·코드·이미지·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -22,6 +23,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
EDITOR_MAX_INDENT,
@@ -228,6 +230,11 @@ const LINK_OPTIONS = {
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
}
/** 링크 확장 — 쓰기 본문과 서명 칸이 같은 규칙을 쓴다 (주소만 읽고, 늘 새 창으로) */
export function createMailLinkExtension(): AnyExtension {
return mailLink(starterKitPart(StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS }), 'link'))
}
/** 편집기 확장 목록 — 화면의 편집기와 초안 맞춤(fit-draft)·테스트가 같은 스키마를 쓴다 */
export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {}): AnyExtension[] {
const kitWithLink = StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS })
@@ -245,6 +252,7 @@ export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {})
TableKit.configure({ table: { resizable: false }, tableCell: false, tableHeader: false }),
MailTableCell,
MailTableHeader,
SignatureImage,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
@@ -158,8 +158,14 @@ function styleAll(root: ParentNode, selector: string, style: string) {
root.querySelectorAll(selector).forEach((element) => prependStyle(element, style))
}
/** 글자도 줄바꿈도 그림(서명 그림)도 없는 문단 — 받는 쪽에서 높이가 0 이 되기 쉽다 */
function isEmptyParagraph(element: Element): boolean {
return element.tagName === 'P' && element.textContent === '' && element.querySelector('br') === null
return (
element.tagName === 'P' &&
element.textContent === '' &&
element.querySelector('br') === null &&
element.querySelector('img') === null
)
}
/**
@@ -0,0 +1,84 @@
import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
hasSignatureImage,
signatureImageIdsOf,
signatureImagesOf,
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID } from '@/test-utils/outgoing-style-samples'
const schema = getSchema(createMailEditorExtensions())
const signatureSchema = getSchema(createSignatureEditorExtensions())
const SRC = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
function parse(html: string, target = schema): ProseMirrorNode {
return createDocument(normalizeIncomingHtml(html), target)
}
function editorHtml(doc: ProseMirrorNode): string {
return getHTMLFromFragment(doc.content, doc.type.schema)
}
describe('서명 그림 노드 — 서명 그림만 받는다 (계약 2절 "서식 본문"의 그림 예외)', () => {
it('서명 그림 주소의 img 만 남기고, 주소 · 폭 · 설명만 그린다', () => {
const doc = parse(`<p>서명<img src="${SRC}" width="144" alt="로고" class="x" style="width:9px" onerror="alert(1)"></p>`)
expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 144, alt: '로고' }])
expect(editorHtml(doc)).toBe(`<p>서명<img src="${SRC}" width="144" alt="로고"></p>`)
})
it.each([
'https://evil.example/t.png',
'data:image/png;base64,iVBORw0KGgo=',
`/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`,
`cid:${SAMPLE_IMAGE_ID}`,
'/api/mail-signature/images/not-a-uuid',
])('그 밖의 그림은 빠진다 — %s', (src) => {
const doc = parse(`<p>앞<img src="${src}">뒤</p>`)
expect(hasSignatureImage(doc)).toBe(false)
expect(editorHtml(doc)).toBe('<p>앞뒤</p>')
})
it('표본 표의 폭 · 설명 — 서버가 남기지 않는 값은 그 속성만 뺀다', () => {
for (const [attribute, value, kept] of IMAGE_SAMPLES) {
if (attribute === 'src') continue
const doc = parse(`<p><img src="${SRC}" ${attribute}="${value.replace(/"/g, '&quot;')}"></p>`)
const [image] = signatureImagesOf(doc)
const read = attribute === 'width' ? image?.width : image?.alt
expect(read !== null && read !== undefined, `${attribute}=${value}`).toBe(kept)
}
})
it('주소는 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로 읽는다', () => {
const doc = parse(`<p><img src=" /api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()} " width=" 96 "></p>`)
expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 96, alt: null }])
expect(signatureImageIdsOf(doc)).toEqual(new Set([SAMPLE_IMAGE_ID]))
})
it('서명 칸 스키마도 같은 규칙이다 — 목록 · 표 · 글꼴은 글자만 남는다', () => {
const doc = parse(
`<ul><li><p>항목</p></li></ul><p><span style="font-family: Arial; color: #B42318">빨강</span><img src="${SRC}" width="96"></p>`,
signatureSchema,
)
const html = editorHtml(doc)
expect(html).toMatch(/^<p>항목<\/p><p><span style="color: [^"]+">빨강<\/span><img src="[^"]+" width="96"><\/p>$/)
expect(html).not.toContain('font-family')
})
})
describe('보낼 모양 — 그림은 서식 본문에만', () => {
it('텍스트 대체 본문에는 그림이 없다', () => {
expect(docToText(parse(`<p>김아라</p><p><img src="${SRC}" width="144" alt=""></p><p>끝</p>`))).toBe('김아라\n\n끝')
})
it('그림만 있는 문단에 줄바꿈을 더하지 않는다 — 빈 문단만 높이를 지킨다', () => {
const html = toMailHtml(editorHtml(parse(`<p><img src="${SRC}" width="144" alt=""></p><p></p><p>끝</p>`)))
expect(html).toContain(`<p style="margin: 0"><img src="${SRC}" width="144" alt=""></p>`)
expect(html).toContain('<p style="margin: 0"><br></p>')
})
})
@@ -0,0 +1,104 @@
// 서명 그림 — 쓰기 본문 · 서명 칸이 받는 유일한 그림 (계약 2절 "서식 본문"의 그림 예외 · 디자인 README "2단계 › 서명")
// <img src="/api/mail-signature/images/{id}" width="144" alt=""> 만 읽고 그린다 — 주소 · 폭 · 설명은 서버 정화와 같은 형식만 받는다
// (shared/utils/outgoing-html-rules.ts 의 그림 규칙). 그 밖의 그림(외부 주소 · data: · 다른 경로)은 스키마가 받지 않아 빠진다.
// 그림 파일을 본문에 붙여 넣거나 끌어다 놓으면 여전히 첨부로 올린다 (MailEditor 의 attachPasted · attachDropped).
// 글 안의 한 글자처럼 다룬다(inline atom) — 지우기 · 옮기기(끌기) · 고르기가 글자와 같다. 고르면 서명 칸이 크기 줄을 보인다.
import { Node } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import {
imageAltOf,
imageWidthOf,
normalizeSignatureImageSrc,
signatureImageIdOf,
} from '@/shared/utils/outgoing-html-rules'
export const SIGNATURE_IMAGE_NODE = 'signatureImage'
/** 그림 노드의 속성 — 형식이 틀린 폭 · 설명은 null(그 속성을 그리지 않는다) */
export interface SignatureImageAttributes {
src: string
width: number | null
alt: string | null
}
export const SignatureImage = Node.create({
name: SIGNATURE_IMAGE_NODE,
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
src: {
default: null,
// 서버가 다시 쓰는 모양으로 — 앞뒤 공백 없이, id 는 소문자
parseHTML: (element: HTMLElement) => normalizeSignatureImageSrc(element.getAttribute('src')),
renderHTML: (attributes: Record<string, unknown>) => {
const src = normalizeSignatureImageSrc(attributes.src)
return src === null ? {} : { src }
},
},
width: {
default: null,
parseHTML: (element: HTMLElement) => imageWidthOf(element.getAttribute('width')),
renderHTML: (attributes: Record<string, unknown>) => {
const width = imageWidthOf(attributes.width)
return width === null ? {} : { width: String(width) }
},
},
alt: {
default: null,
parseHTML: (element: HTMLElement) => imageAltOf(element.getAttribute('alt')),
renderHTML: (attributes: Record<string, unknown>) => {
const alt = imageAltOf(attributes.alt)
return alt === null ? {} : { alt }
},
},
}
},
parseHTML() {
return [
{
tag: 'img[src]',
// 서명 그림 주소가 아닌 그림은 읽지 않는다 — 노드 없이 빠진다
getAttrs: (element: HTMLElement) =>
normalizeSignatureImageSrc(element.getAttribute('src')) === null ? false : null,
},
]
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})
/** 문서 안의 서명 그림 — 나온 순서대로 */
export function signatureImagesOf(doc: ProseMirrorNode): SignatureImageAttributes[] {
const images: SignatureImageAttributes[] = []
doc.descendants((node) => {
if (node.type.name !== SIGNATURE_IMAGE_NODE) return true
const src = normalizeSignatureImageSrc(node.attrs.src)
if (src !== null) images.push({ src, width: imageWidthOf(node.attrs.width), alt: imageAltOf(node.attrs.alt) })
return false
})
return images
}
/** 문서에 서명 그림이 있는가 — 글자 없이 그림만 있어도 보낼 내용이 있다 */
export function hasSignatureImage(doc: ProseMirrorNode): boolean {
return signatureImagesOf(doc).length > 0
}
/** 문서가 쓰는 서명 그림 id — 겹치지 않게 */
export function signatureImageIdsOf(doc: ProseMirrorNode): Set<string> {
const ids = new Set<string>()
for (const image of signatureImagesOf(doc)) {
const id = signatureImageIdOf(image.src)
if (id !== null) ids.add(id)
}
return ids
}
@@ -0,0 +1,398 @@
<script setup lang="ts">
// 서명 칸 — 툴바 + 편집 영역 + 그림 크기 줄 (디자인 원본 Settings · README "2단계 › 서명")
// - 글꼴은 쓰기 화면 본문과 같은 맑은 고딕 14px · 줄 간격 1.75 (보이는 모양 = 쓰기 화면에 들어가는 모양)
// - 그림: 붙여 넣거나 끌어다 놓은 파일은 넣지 않고 files 로 넘긴다 — 서명 화면이 검사해 올린 뒤 insertImage 로 넣는다.
// 고른 그림(노드 선택) 아래에 크기 줄: 작게 96 · 보통 144 · 원본(원래 폭, 600 이하) · 그림 빼기
// - 내용이 바뀌면 change 로 문서를 넘긴다 — 미리 보기가 그 문서를 그린다
// - 바꿨는가(isChanged)는 처음 불러온 서명, 저장한 뒤에는 저장한 서명(markSaved)과 견준다 — 서명 화면의 나가기 확인
// TipTap 은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 서명 화면이 지연 로딩한다.
import { Editor, getHTMLFromFragment } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import { computed, markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { EditorSavePoint, MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
SIGNATURE_IMAGE_NODE,
hasSignatureImage,
signatureImageIdsOf,
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import SignatureToolbar from '@/components/mail/editor/signature/SignatureToolbar.vue'
import { SIGNATURE_TEXT, type SignatureImageSize } from '@/shared/constants/mail-signature'
import type { SignatureImage } from '@/shared/types/mail'
import { pastedFiles } from '@/shared/utils/editor-files'
import { imageSizeOf, imageWidthFor, insertedImageWidth } from '@/shared/utils/mail-signature'
import { imageWidthOf, signatureImageIdOf, signatureImageSrc } from '@/shared/utils/outgoing-html-rules'
interface Props {
/** 처음 내용 — 저장한 서명 HTML */
initialHtml?: string
/** 서명 칸의 이름표 id (aria-labelledby) */
labelledBy: string
/** 아래 안내 · 오류 id (aria-describedby) */
describedBy?: string | null
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림) */
images: ReadonlyMap<string, SignatureImage>
/** 그림을 더 넣을 수 없다 (올리는 중) */
imageDisabled?: boolean
/** 파일을 끌고 있다 — 칸 위에 덮개를 보인다 */
dragging?: boolean
}
interface Emits {
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 */
(e: 'files', files: File[]): void
/** 그림 단추 — 파일 고르기를 연다 */
(e: 'pick-image'): void
}
const props = withDefaults(defineProps<Props>(), {
initialHtml: '',
describedBy: null,
imageDisabled: false,
dragging: false,
})
const emit = defineEmits<Emits>()
/** 화면에 하나뿐인 서명 칸 */
const SIGNATURE_BODY_ID = 'signature-body'
const IMAGE_SIZES: readonly SignatureImageSize[] = ['small', 'medium', 'original']
const bodyRef = ref<HTMLElement | null>(null)
const toolbarRef = ref<InstanceType<typeof SignatureToolbar> | null>(null)
const revision = ref<number>(0)
let mounted = false
/**
* "바꾸지 않은" 기준 — 처음 불러온(저장한 뒤에는 저장한) 문서. settled 는 편집기가 첫 거래에 스스로 붙이는
* 끝 빈 줄까지 붙인 모양이다(StarterKit 의 TrailingNode — 쓰기 본문 MailEditor 와 같다)
*/
let baseline: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
const extensions = createSignatureEditorExtensions({
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
})
const editor = markRaw(
new Editor({
element: null,
extensions,
content: props.initialHtml ? normalizeIncomingHtml(props.initialHtml) : '',
editorProps: {
attributes: () => ({
id: SIGNATURE_BODY_ID,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': props.labelledBy,
...(props.describedBy ? { 'aria-describedby': props.describedBy } : {}),
}),
handlePaste: (_view, event) => {
const files = pastedFiles(event.clipboardData)
if (files.length === 0) return false
emit('files', files)
return true
},
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
emit('files', files)
return true
},
transformPastedHTML: normalizeIncomingHtml,
},
onTransaction: () => {
revision.value += 1
},
onUpdate: ({ editor: current }) => {
emit('change', markRaw(current.state.doc))
},
}),
)
/** 고른 그림 — 노드 선택일 때만 (크기 줄을 보인다) */
const selectedImage = computed(() => {
void revision.value
const { selection } = editor.state
if (!(selection instanceof NodeSelection) || selection.node.type.name !== SIGNATURE_IMAGE_NODE) return null
const id = signatureImageIdOf(selection.node.attrs.src)
const known = id ? props.images.get(id) : undefined
return {
pos: selection.from,
width: imageWidthOf(selection.node.attrs.width),
naturalWidth: known?.width ?? null,
}
})
const selectedSize = computed(() =>
selectedImage.value ? imageSizeOf(selectedImage.value.width, selectedImage.value.naturalWidth) : null,
)
/** 그림 폭을 바꾼다 — 노드를 바꾸지 않고 속성만 (고른 상태가 그대로 남는다) */
function setImageSize(size: SignatureImageSize) {
const image = selectedImage.value
if (!image) return
// 원래 폭을 모르면(남이 만든 서명 등) 원본은 지금 그림의 화면 폭으로 본다
const natural = image.naturalWidth ?? naturalWidthOnScreen(image.pos)
const width = imageWidthFor(size, natural)
editor
.chain()
.command(({ tr }) => {
tr.setNodeAttribute(image.pos, 'width', width)
return true
})
.run()
}
function naturalWidthOnScreen(pos: number): number {
const dom = mounted ? editor.view.nodeDOM(pos) : null
return dom instanceof HTMLImageElement && dom.naturalWidth > 0 ? dom.naturalWidth : imageWidthFor('medium', 0)
}
function removeSelectedImage() {
if (!selectedImage.value) return
editor.chain().focus().deleteSelection().run()
}
/** 올린 그림을 커서 자리에 넣고 고른다 — 크기 줄이 바로 보인다 */
function insertImage(image: SignatureImage) {
const src = signatureImageSrc(image.id)
if (src === null) return
const { from } = editor.state.selection
editor
.chain()
.focus()
.insertContent({ type: SIGNATURE_IMAGE_NODE, attrs: { src, width: insertedImageWidth(image.width), alt: '' } })
.setNodeSelection(from)
.run()
}
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 */
function getContent(): MailEditorContent {
const { doc } = editor.state
const text = docToText(doc)
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
}
/** 서명이 쓰는 그림 id */
function imageIds(): Set<string> {
return signatureImageIdsOf(editor.state.doc)
}
/** 불러온(저장한) 뒤에 서명을 바꿨는가 — 지웠다 다시 써 같아졌으면 바꾸지 않은 것이다 */
function isChanged(): boolean {
if (!baseline) return false
const { doc } = editor.state
return !doc.eq(baseline.doc) && !doc.eq(baseline.settled)
}
/** 저장할 때의 문서 — 저장이 끝나면 markSaved 로 돌려줘 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다) */
function captureSavePoint(): EditorSavePoint {
return editor.state.doc as unknown as EditorSavePoint
}
function markSaved(savePoint: EditorSavePoint) {
const doc = savePoint as unknown as ProseMirrorNode
baseline = { doc, settled: doc }
}
function focus() {
if (mounted) editor.view.focus()
}
onMounted(() => {
if (bodyRef.value) {
editor.mount(bodyRef.value)
mounted = true
}
revision.value += 1
// 붙이면서 켜진 플러그인이 붙일 것(끝 빈 줄)까지 미리 본다 — 화면에 보내지 않는다
const { state } = editor
baseline = { doc: state.doc, settled: state.apply(state.tr).doc }
emit('ready', markRaw(editor.state.doc))
})
onBeforeUnmount(() => {
editor.destroy()
})
// 오류 안내가 생기거나 사라지면 서명 칸의 aria 속성을 다시 그린다
watch(
() => props.describedBy,
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
</script>
<template>
<div :class="['signature-editor', { 'is-dragging': dragging }]">
<SignatureToolbar
ref="toolbarRef"
:editor="editor"
:revision="revision"
:controls="SIGNATURE_BODY_ID"
:image-disabled="imageDisabled"
@pick-image="emit('pick-image')"
/>
<div class="signature-editor__frame">
<div
ref="bodyRef"
class="signature-editor__body"
/>
<div
v-if="dragging"
class="signature-editor__drop"
aria-hidden="true"
>
<AppIcon name="image" />
</div>
</div>
<div
v-if="selectedImage"
class="signature-editor__sizes"
>
<span
id="signature-image-size-label"
class="signature-editor__sizes-label"
>{{ SIGNATURE_TEXT.imageSizeLabel }}</span>
<div
role="group"
aria-labelledby="signature-image-size-label"
class="segmented"
>
<button
v-for="size in IMAGE_SIZES"
:key="size"
type="button"
class="segmented__item"
:data-size="size"
:aria-pressed="selectedSize === size"
@mousedown.prevent
@click="setImageSize(size)"
>
{{ SIGNATURE_TEXT.imageSizes[size] }}
</button>
</div>
<button
type="button"
class="signature-editor__remove"
@mousedown.prevent
@click="removeSelectedImage"
>
{{ SIGNATURE_TEXT.removeImage }}
</button>
</div>
</div>
</template>
<style scoped>
/* 서명 칸 — 테두리 상자 안에 툴바 · 편집 영역 · 그림 크기 줄 (디자인 원본 Settings) */
.signature-editor {
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 서명 칸에 초점 — 테두리 상자가 브랜드색 (글자 입력칸 포커스와 같은 색) */
.signature-editor:has(.ProseMirror:focus) {
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.signature-editor__frame {
position: relative;
}
.signature-editor__body :deep(.ProseMirror) {
min-height: 8.25rem;
padding: var(--space-14) var(--space-16);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
line-height: var(--line-height-mail-body);
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.signature-editor__body :deep(.ProseMirror:focus),
.signature-editor__body :deep(.ProseMirror:focus-visible) {
outline: none;
}
.signature-editor__body :deep(.ProseMirror p) {
margin: 0;
}
.signature-editor__body :deep(.ProseMirror a) {
color: var(--color-brand);
}
.signature-editor__body :deep(.ProseMirror img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
/* 고른 그림 — 브랜드색 고리 (디자인 원본 Settings) */
.signature-editor__body :deep(.ProseMirror img.ProseMirror-selectednode) {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
/* 끌어다 놓는 동안의 덮개 — 쓰기 본문과 같은 점선 */
.signature-editor__drop {
position: absolute;
inset: var(--space-8);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
border: var(--focus-ring-width) dashed var(--color-brand);
border-radius: var(--radius-md);
background: var(--color-drop-surface);
color: var(--color-brand-strong);
}
/* 그림 크기 줄 — 그림을 고르면 칸 아래에 */
.signature-editor__sizes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-10);
padding: var(--space-6) var(--space-12) var(--space-8);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.signature-editor__sizes-label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 그림 빼기 — 크기 단추와 같은 36 (캔버스 확정값, 44px 기준의 밀도 예외) */
.signature-editor__remove {
min-height: var(--size-segment-item-lg);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-strong);
font-size: var(--font-size-label);
font-weight: 500;
}
.signature-editor__remove:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -0,0 +1,465 @@
<script setup lang="ts">
// 메일 서명 설정 (디자인 원본 Settings · README "2단계 › 서명") — 설정 › 메일 › 서명 화면이 지연 로딩한다 (TipTap 이 이 청크에만 있다)
// 위에서부터 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → 서명을 넣을 메일(새 메일 · 답장 · 전달) → [저장] → 미리 보기.
// - 그림: 그림 단추 · 붙여 넣기 · 끌어다 놓기(화면 어디든) → 사전 검사(PNG · JPG · GIF, 300KB, 3장) → POST /mail-signature/images →
// 커서 자리에 보통(144) 폭으로 넣는다. 걸리면(또는 서버가 형식 · 크기로 거절하면) 서명 칸 아래에 사전 문구 (FILE_001 · FILE_002 · VAL_001).
// 3장은 서명 칸에 있는 서로 다른 그림으로 센다 — 뺀 그림은 세지 않는다 (서명 저장의 한도, 계약 2절 "서명").
// 올려 두기만 한 그림까지 한 사람 10장은 서버가 올리기에서 막는다(VAL_001) — 그 알림은 인터셉터가 띄운다.
// - 저장: PUT /mail-signature { bodyHtml(쓰기 본문과 같은 인라인 서식), body(텍스트 서명), isForNew, isForReply } —
// 비었으면 둘 다 null. 길이 한도(서식 10,000 · 텍스트 2,000)는 보내기 전에 먼저 막는다(VAL_001).
// 저장하면 "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다." — 무엇이든 다시 바꾸면 내린다.
// - 나가기 확인: 처음 불러온(저장한 뒤에는 저장한) 서명과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창
// (useLeaveConfirm). 올리는 중인 그림도 바꿈이다. 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다.
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 서명 칸 #signature-body · 그림 고르기 #signature-image-input ·
// 새 메일 #signature-for-new · 답장 · 전달 #signature-for-reply · 저장 #signature-save · 상태 #signature-save-status ·
// 오류 #signature-error · 크기 단추 [data-size] · 미리 보기 단추 [data-mode] ·
// 나가기 확인 #signature-leave-dialog · #signature-leave-stay(계속 고치기) · #signature-leave-confirm(나가기)
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import SignatureEditor from '@/components/mail/editor/signature/SignatureEditor.vue'
import SignaturePreview from '@/components/mail/editor/signature/SignaturePreview.vue'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGE_ACCEPT,
SIGNATURE_IMAGE_INLINE_CODES,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_SAVE_INLINE_CODES,
SIGNATURE_TEXT,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/shared/constants/mail-signature'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import { renameFile } from '@/shared/utils/editor-files'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { precheckSignatureImage, signatureImageUploadName } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
interface Props {
/** 서버의 서명 — 이 값으로 칸을 채운다 */
signature: MailSignature
}
const props = defineProps<Props>()
const { uploadSignatureImage } = useMail()
const authStore = useAuthStore()
const signatureStore = useMailSignatureStore()
const LABEL_ID = 'signature-label'
const HINT_ID = 'signature-hint'
const ERROR_ID = 'signature-error'
/** 처음 내용 — 서식 서명, 없으면 텍스트 서명을 문단으로 (다른 메일 프로그램이 텍스트만 남긴 경우) */
const initialHtml =
props.signature.bodyHtml ?? (props.signature.body ? textToParagraphHtml(props.signature.body) : '')
const editorRef = ref<InstanceType<typeof SignatureEditor> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const forNew = ref<boolean>(props.signature.isForNew)
const forReply = ref<boolean>(props.signature.isForReply)
/** 미리 보기가 그릴 문서 — 편집기가 바뀔 때마다 */
const previewDoc = shallowRef<ProseMirrorNode | null>(null)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
/** 서명 칸 아래 오류 — 사전 문구 */
const errorMessage = ref<string | null>(null)
/** 올리는 중인 그림 수 */
const uploading = ref<number>(0)
/** 알고 있는 그림 — 원본 폭을 찾는다 */
const images = shallowRef<ReadonlyMap<string, SignatureImage>>(
new Map(props.signature.images.map((image) => [image.id, image])),
)
/** 불러온(저장한 뒤에는 저장한) 뒤 서명 칸을 바꿨다 — 편집기가 잰다 */
const editorChanged = ref<boolean>(false)
/** 불러온(저장한) 때의 "넣을 메일" — 이것과 다르면 바꾼 것이다 */
const savedOptions = shallowRef({ forNew: props.signature.isForNew, forReply: props.signature.isForReply })
const describedBy = computed(() => (errorMessage.value ? `${HINT_ID} ${ERROR_ID}` : HINT_ID))
/** 떠나면 잃는 것이 있다 — 저장하지 않은 서명 칸 · 넣을 메일, 올리는 중인 그림 */
const hasUnsavedChanges = computed(
() =>
editorChanged.value ||
uploading.value > 0 ||
forNew.value !== savedOptions.value.forNew ||
forReply.value !== savedOptions.value.forReply,
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn,
})
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
function markChanged() {
if (saveState.value === 'saved') saveState.value = 'idle'
}
function onEditorReady(doc: ProseMirrorNode) {
previewDoc.value = doc
}
function onEditorChange(doc: ProseMirrorNode) {
previewDoc.value = doc
editorChanged.value = editorRef.value?.isChanged() ?? false
markChanged()
}
/** 그림 수 — 서명 칸에 있는 서로 다른 그림과 올리는 중인 그림 (뺀 그림은 세지 않는다) */
function occupiedImageCount(): number {
return (editorRef.value?.imageIds().size ?? 0) + uploading.value
}
/** 그림 한 장을 검사해 올리고 넣는다 — 실패는 칸 아래에 사전 문구 */
async function addImage(file: File) {
const rejected = precheckSignatureImage(file, occupiedImageCount())
if (rejected) {
errorMessage.value = getErrorMessage(rejected)
return
}
errorMessage.value = null
uploading.value += 1
try {
const named = signatureImageUploadName(file)
const image = await uploadSignatureImage(named === file.name ? file : renameFile(file, named), {
silent: SIGNATURE_IMAGE_INLINE_CODES,
})
images.value = new Map([...images.value, [image.id, image] as const])
editorRef.value?.insertImage(image)
markChanged()
} catch (error) {
const apiError = toApiError(error)
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 10장 초과 VAL_001 등)은 인터셉터가 알렸다
if (SIGNATURE_IMAGE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
} finally {
uploading.value -= 1
}
}
/** 여러 장이면 차례로 — 앞의 그림이 개수에 들어간 뒤 다음을 센다 */
async function addImages(files: File[]) {
for (const file of files) await addImage(file)
}
// 화면 어디에 그림을 놓아도 받는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
const { dragging } = useWindowFileDrop((files) => void addImages(files))
function pickImage() {
fileInputRef.value?.click()
}
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
// 같은 파일을 다시 고를 수 있게 비운다
input.value = ''
void addImages(files)
}
async function save() {
const editor = editorRef.value
if (!editor || saveState.value === 'saving') return
const content = editor.getContent()
// 길이 한도 — 서버도 같은 한도로 VAL_001 을 준다 (서식은 정화한 뒤 재지만 정화는 거의 줄이지 않는다)
if ((content.html?.length ?? 0) > SIGNATURE_HTML_MAX_LENGTH || content.text.length > SIGNATURE_TEXT_MAX_LENGTH) {
errorMessage.value = getErrorMessage('VAL_001')
editor.focus()
return
}
errorMessage.value = null
saveState.value = 'saving'
// 저장할 때의 모양 — 저장이 끝나면 이것을 "바꾸지 않은" 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다)
const savePoint = editor.captureSavePoint()
const options = { forNew: forNew.value, forReply: forReply.value }
try {
const saved = await signatureStore.save(
{
bodyHtml: content.html,
body: content.text === '' ? null : content.text,
isForNew: options.forNew,
isForReply: options.forReply,
},
{ silent: SIGNATURE_SAVE_INLINE_CODES },
)
images.value = new Map([...images.value, ...saved.images.map((image) => [image.id, image] as const)])
editorRef.value?.markSaved(savePoint)
editorChanged.value = editorRef.value?.isChanged() ?? false
savedOptions.value = options
saveState.value = 'saved'
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
}
}
watch([forNew, forReply], markChanged)
</script>
<template>
<div class="signature-form">
<div class="signature-form__field">
<p
:id="LABEL_ID"
class="signature-form__label"
>
{{ SIGNATURE_TEXT.fieldLabel }}
</p>
<SignatureEditor
ref="editorRef"
:initial-html="initialHtml"
:labelled-by="LABEL_ID"
:described-by="describedBy"
:images="images"
:image-disabled="uploading > 0"
:dragging="dragging"
@ready="onEditorReady"
@change="onEditorChange"
@files="addImages"
@pick-image="pickImage"
/>
<input
id="signature-image-input"
ref="fileInputRef"
type="file"
multiple
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="SIGNATURE_IMAGE_ACCEPT"
@change="onFilesChosen"
>
<p
v-if="errorMessage"
:id="ERROR_ID"
class="signature-form__alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ errorMessage }}</span>
</p>
<p
v-if="uploading > 0"
class="signature-form__uploading"
role="status"
>
<AppIcon
name="loader"
:size="14"
:stroke-width="2"
/>
<span>{{ SIGNATURE_TEXT.uploading }}</span>
</p>
<p
:id="HINT_ID"
class="signature-form__hint"
>
{{ SIGNATURE_TEXT.hint }}
</p>
</div>
<fieldset class="signature-form__usage">
<legend class="signature-form__legend">
{{ SIGNATURE_TEXT.usageLegend }}
</legend>
<label class="signature-form__option">
<input
id="signature-for-new"
v-model="forNew"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forNew }}</span>
</label>
<label class="signature-form__option">
<input
id="signature-for-reply"
v-model="forReply"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forReply }}</span>
<span class="signature-form__option-note">{{ SIGNATURE_TEXT.forReplyNote }}</span>
</label>
</fieldset>
<div class="signature-form__actions">
<button
id="signature-save"
type="button"
class="signature-form__save"
:disabled="saveState === 'saving' || uploading > 0"
:aria-busy="saveState === 'saving'"
@click="save"
>
{{ saveState === 'saving' ? SIGNATURE_TEXT.saving : SIGNATURE_TEXT.save }}
</button>
<span
id="signature-save-status"
class="signature-form__status"
role="status"
>
<template v-if="saveState === 'saved'">
<AppIcon
name="check"
:size="15"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_TEXT.saved }}</span>
</template>
</span>
</div>
<hr class="signature-form__rule">
<SignaturePreview
:doc="previewDoc"
:for-new="forNew"
:for-reply="forReply"
/>
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="signature-leave"
:copy="SIGNATURE_LEAVE_DIALOG"
@stay="stayOnForm"
@leave="leaveForm"
/>
</div>
</template>
<style scoped>
.signature-form {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.signature-form__field {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.signature-form__label {
margin: 0;
font-weight: 600;
}
.signature-form__alert {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
}
.signature-form__uploading {
display: flex;
align-items: center;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.signature-form__hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.signature-form__usage {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.signature-form__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-form__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.signature-form__checkbox {
width: 1.125rem;
height: 1.125rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.signature-form__option-note {
color: var(--color-text-muted);
}
.signature-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.signature-form__save {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-22);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.signature-form__save:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.signature-form__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.signature-form__status {
display: inline-flex;
align-items: center;
gap: var(--space-6);
color: var(--color-success-text);
font-weight: 500;
}
.signature-form__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
// 서명 미리 보기 (디자인 원본 Settings) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// - 새 메일: (여기에 쓴 내용) · 빈 줄 · 서명 / 답장: 같은 위에 원문 인용(자리만 — 실제 메일이 아니다)
// - 서명은 편집 중인 문서를 편집기 스키마로 그린다 (DOMSerializer — 스키마가 그리는 태그 · 속성뿐이라 남의 HTML 을 넣지 않는다).
// 그 쓰기 종류에 넣지 않기로 했으면 서명을 빼고 아래 안내가 바뀐다.
import { DOMSerializer, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, watch } from 'vue'
import {
SIGNATURE_PREVIEW_NOTES,
SIGNATURE_PREVIEW_QUOTE,
SIGNATURE_TEXT,
} from '@/shared/constants/mail-signature'
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
export type SignaturePreviewMode = 'new' | 'reply'
interface Props {
/** 편집 중인 서명 문서 (없으면 아직 불러오는 중) */
doc: ProseMirrorNode | null
/** 새 메일에 넣는다 */
forNew: boolean
/** 답장 · 전달에 넣는다 */
forReply: boolean
}
const props = defineProps<Props>()
const mode = ref<SignaturePreviewMode>('new')
const signatureRef = ref<HTMLElement | null>(null)
const MODES: readonly { mode: SignaturePreviewMode; label: string }[] = [
{ mode: 'new', label: SIGNATURE_TEXT.previewNew },
{ mode: 'reply', label: SIGNATURE_TEXT.previewReply },
]
/** 이 쓰기 종류에 서명을 넣는다 */
const shown = computed(() => (mode.value === 'reply' ? props.forReply : props.forNew))
const note = computed(() => {
if (mode.value === 'reply') return props.forReply ? SIGNATURE_PREVIEW_NOTES.replyOn : SIGNATURE_PREVIEW_NOTES.replyOff
return props.forNew ? SIGNATURE_PREVIEW_NOTES.newOn : SIGNATURE_PREVIEW_NOTES.newOff
})
/** 서명 문서를 그린다 — 스키마의 toDOM 결과만 붙인다 */
function render() {
const target = signatureRef.value
if (!target) return
target.replaceChildren()
const { doc } = props
if (!doc) return
const fragment = DOMSerializer.fromSchema(doc.type.schema).serializeFragment(doc.content, { document })
target.append(fragment)
}
watch([() => props.doc, signatureRef], render, { flush: 'post', immediate: true })
</script>
<template>
<section
class="signature-preview"
aria-labelledby="signature-preview-title"
>
<div class="signature-preview__head">
<h2
id="signature-preview-title"
class="signature-preview__title"
>
{{ SIGNATURE_TEXT.previewTitle }}
</h2>
<div
role="group"
:aria-label="SIGNATURE_TEXT.previewSwitchLabel"
class="segmented"
>
<button
v-for="option in MODES"
:key="option.mode"
type="button"
class="segmented__item signature-preview__mode"
:data-mode="option.mode"
:aria-pressed="mode === option.mode"
@click="mode = option.mode"
>
{{ option.label }}
</button>
</div>
</div>
<div class="signature-preview__mail">
<p class="signature-preview__placeholder">
{{ SIGNATURE_TEXT.previewBody }}
</p>
<p><br></p>
<div
v-show="shown"
ref="signatureRef"
class="signature-preview__signature"
data-signature-preview
/>
<blockquote
v-if="mode === 'reply'"
class="signature-preview__quote"
>
<div class="signature-preview__quote-head">
<p class="signature-preview__quote-heading">
{{ MAIL_QUOTE_HEADING }}
</p>
<table class="signature-preview__quote-table">
<tbody>
<tr
v-for="row in SIGNATURE_PREVIEW_QUOTE.rows"
:key="row.label"
>
<th scope="row">
{{ row.label }}
</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<p>{{ SIGNATURE_PREVIEW_QUOTE.body }}</p>
</blockquote>
</div>
<p
class="signature-preview__note"
data-signature-preview-note
>
{{ note }}
</p>
</section>
</template>
<style scoped>
.signature-preview {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.signature-preview__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-14);
}
.signature-preview__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.signature-preview__mode {
padding: 0 var(--space-14);
}
/* 받는 사람이 보는 본문과 같은 글꼴 · 크기 · 줄 간격 */
.signature-preview__mail {
padding: var(--space-16) var(--space-20) var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
line-height: var(--line-height-mail-body);
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.signature-preview__mail p,
.signature-preview__signature :deep(p) {
margin: 0;
}
.signature-preview__placeholder {
color: var(--color-text-muted);
}
.signature-preview__signature :deep(a) {
color: var(--color-brand);
}
.signature-preview__signature :deep(img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
/* 원문 인용 — 쓰기 화면의 인용 막대와 같은 값 */
.signature-preview__quote {
margin: var(--space-24) 0 0;
padding: var(--space-2) 0 var(--space-2) var(--space-14);
border-left: var(--focus-ring-width) solid var(--color-quote-bar);
color: var(--color-text-secondary);
}
.signature-preview__quote-head {
margin: 0 0 var(--space-12);
font-size: var(--font-size-label);
line-height: 1.6;
}
.signature-preview__quote-heading {
font-weight: 600;
}
.signature-preview__quote-table {
border-collapse: collapse;
font-size: var(--font-size-label);
line-height: 1.6;
}
.signature-preview__quote-table th {
padding: 0 var(--space-12) 0 0;
text-align: left;
vertical-align: top;
font-weight: 400;
color: var(--color-text-muted);
white-space: nowrap;
}
.signature-preview__quote-table td {
padding: 0;
}
.signature-preview__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,296 @@
<script setup lang="ts">
// 서명 칸 툴바 (디자인 원본 Settings) — 굵게 · 기울임 · 밑줄 | 글자 색 · 링크 · 그림
// 쓰기 본문 툴바(EditorToolbar)와 같은 단추 · 팝오버를 쓴다:
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). Esc → 서명 칸, 서명 칸에서 Alt+F10 → 툴바
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 팝오버에서 고른 값은 서명 칸으로 돌아간다
// 그림 단추는 파일 고르기를 연다 — 올리기는 서명 화면(SignatureForm)이 한다 (pick-image).
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorColorPicker from '@/components/mail/editor/EditorColorPicker.vue'
import EditorLinkForm from '@/components/mail/editor/EditorLinkForm.vue'
import EditorToolButton from '@/components/mail/editor/EditorToolButton.vue'
import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/components/mail/editor/editor-links'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { readToolbarStatus } from '@/components/mail/editor/editor-state'
import { EDITOR_SHORTCUTS, EDITOR_TEXT_COLORS, type EditorColor } from '@/shared/constants/mail-editor'
interface Props {
editor: Editor
/** 편집기 상태가 바뀔 때마다 늘어나는 값 — 툴바 표시를 다시 읽는다 */
revision: number
/** 서명 칸 id — aria-controls */
controls: string
/** 그림을 더 넣을 수 없다 (올리는 중 등) */
imageDisabled?: boolean
}
interface Emits {
/** 그림 넣기 — 파일 고르기를 연다 */
(e: 'pick-image'): void
}
const props = withDefaults(defineProps<Props>(), {
imageDisabled: false,
})
const emit = defineEmits<Emits>()
type ToolId = 'bold' | 'italic' | 'underline' | 'textColor' | 'link' | 'image'
const TOOL_ORDER: readonly ToolId[] = ['bold', 'italic', 'underline', 'textColor', 'link', 'image']
type PopupId = 'textColor' | 'link'
const rootRef = ref<HTMLElement | null>(null)
const activeTool = ref<ToolId>('bold')
/** 열린 팝오버 — 키보드로 열었으면 닫을 때 그 단추로, 마우스로 열었으면 서명 칸으로 */
const popup = ref<{ id: PopupId; returnFocus: 'toolbar' | 'editor'; focusItems: boolean } | null>(null)
const linkTarget = ref<LinkTarget | null>(null)
const status = computed(() => {
// 편집기 상태는 반응형이 아니다 — revision 이 바뀔 때마다 다시 읽는다
void props.revision
return readToolbarStatus(props.editor)
})
const marks = computed(() => [
{ id: 'bold' as const, label: '굵게', icon: 'bold' as const, pressed: status.value.bold, run: (chain: ChainedCommands) => chain.toggleBold() },
{ id: 'italic' as const, label: '기울임', icon: 'italic' as const, pressed: status.value.italic, run: (chain: ChainedCommands) => chain.toggleItalic() },
{ id: 'underline' as const, label: '밑줄', icon: 'underline' as const, pressed: status.value.underline, run: (chain: ChainedCommands) => chain.toggleUnderline() },
])
const focusableTools = computed(() => TOOL_ORDER.filter((id) => id !== 'image' || !props.imageDisabled))
const rovingTool = computed<ToolId>(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : 'bold',
)
function toolElement(id: ToolId): HTMLElement | null {
return rootRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`) ?? null
}
function isFocusOnToolbar(): boolean {
const active = document.activeElement
return Boolean(active && rootRef.value?.contains(active) && !active.closest('[data-editor-popup]'))
}
function focusTool(id: ToolId) {
activeTool.value = id
toolElement(id)?.focus()
}
function focusEditor() {
props.editor.view.focus()
}
function runTool(command: (chain: ChainedCommands) => ChainedCommands) {
const byKeyboard = isFocusOnToolbar()
command(props.editor.chain()).run()
if (!byKeyboard) focusEditor()
}
function openPopup(id: PopupId, returnFocus: 'toolbar' | 'editor', focusItems: boolean) {
linkTarget.value = id === 'link' ? readLinkTarget(props.editor) : null
popup.value = { id, returnFocus, focusItems }
}
function togglePopup(id: PopupId) {
if (popup.value?.id === id) {
closePopup(id, true)
return
}
const byKeyboard = isFocusOnToolbar()
openPopup(id, byKeyboard ? 'toolbar' : 'editor', byKeyboard)
}
function closePopup(id: PopupId, restoreFocus: boolean) {
const open = popup.value
if (open?.id !== id) return
popup.value = null
if (!restoreFocus) return
if (id === 'link' || open.returnFocus === 'editor') {
focusEditor()
return
}
void nextTick(() => focusTool(id))
}
function onTextColorPick(color: EditorColor) {
popup.value = null
const chain = props.editor.chain()
;(color.value === null ? chain.unsetColor() : chain.setColor(color.value)).run()
focusEditor()
}
function onLinkApply(value: LinkFormValue) {
const target = linkTarget.value
popup.value = null
if (target) applyLink(props.editor, target, value)
focusEditor()
}
function onLinkRemove() {
const target = linkTarget.value
popup.value = null
if (target) removeLink(props.editor, target)
focusEditor()
}
function onKeydown(event: KeyboardEvent) {
const target = event.target instanceof HTMLElement ? event.target : null
if (!target || target.closest('[data-editor-popup]')) return
const tools = focusableTools.value
const current = tools.indexOf(target.dataset.tool as ToolId)
if (current < 0) return
const last = tools.length - 1
if (event.key === 'Escape') {
event.preventDefault()
focusEditor()
return
}
const moves: Record<string, number> = {
ArrowRight: current === last ? 0 : current + 1,
ArrowLeft: current === 0 ? last : current - 1,
Home: 0,
End: last,
}
const next = moves[event.key]
if (next === undefined) return
event.preventDefault()
const tool = tools[next]
if (tool) focusTool(tool)
}
function onFocusIn(event: FocusEvent) {
const tool = event.target instanceof HTMLElement ? event.target.dataset.tool : undefined
if (tool && TOOL_ORDER.includes(tool as ToolId)) activeTool.value = tool as ToolId
}
defineExpose({
/** 서명 칸의 Alt+F10 — 툴바로 */
focusToolbar: () => focusTool(rovingTool.value),
/** 서명 칸의 Ctrl+K — 링크 팝오버 */
openLink: () => openPopup('link', 'editor', true),
})
</script>
<template>
<div
ref="rootRef"
class="signature-toolbar"
role="toolbar"
aria-label="서명 서식"
:aria-controls="controls"
@keydown="onKeydown"
@focusin="onFocusIn"
>
<EditorToolButton
v-for="item in marks"
:key="item.id"
:tool="item.id"
:label="item.label"
:shortcut="EDITOR_SHORTCUTS[item.id]"
:pressed="item.pressed"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
<AppIcon :name="item.icon" />
</EditorToolButton>
<span
class="signature-toolbar__separator"
aria-hidden="true"
/>
<div class="signature-toolbar__anchor">
<EditorToolButton
tool="textColor"
:label="`글자 색: ${status.textColor.name}`"
title="글자 색"
haspopup="dialog"
:expanded="popup?.id === 'textColor'"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
<EditorColorGlyph
kind="text"
:color="status.textColor.value"
/>
</EditorToolButton>
<EditorColorPicker
v-if="popup?.id === 'textColor'"
title="글자 색"
:palette="EDITOR_TEXT_COLORS"
:current="status.textColor.color"
:trigger="toolElement('textColor')"
:focus-content="!popup.focusItems"
@pick="onTextColorPick"
@close="(restore) => closePopup('textColor', restore)"
/>
</div>
<div class="signature-toolbar__anchor">
<EditorToolButton
tool="link"
label="링크"
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="popup?.id === 'link'"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
<AppIcon name="link" />
</EditorToolButton>
<EditorLinkForm
v-if="popup?.id === 'link'"
:editing="linkTarget?.editing ?? false"
:initial-href="linkTarget?.href ?? ''"
:initial-text="linkTarget?.text ?? ''"
:trigger="toolElement('link')"
align="start"
@apply="onLinkApply"
@remove="onLinkRemove"
@close="(restore) => closePopup('link', restore)"
/>
</div>
<EditorToolButton
tool="image"
label="그림 넣기"
title="그림 넣기 — PNG · JPG · GIF"
:disabled="imageDisabled"
:focusable="rovingTool === 'image'"
@press="emit('pick-image')"
>
<AppIcon name="image" />
</EditorToolButton>
</div>
</template>
<style scoped>
/* 높이 52 (위아래 4) · 아래 선 — 디자인 원본 Settings 의 서명 칸 머리 */
.signature-toolbar {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
min-height: var(--size-editor-toolbar);
padding: var(--space-4) var(--space-8);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
/* 팝오버가 이 단추 아래에 붙는다 */
.signature-toolbar__anchor {
position: relative;
display: flex;
}
.signature-toolbar__separator {
flex: 0 0 auto;
width: var(--border-width);
height: var(--space-20);
margin: 0 var(--space-4);
background: var(--color-border);
}
</style>
@@ -0,0 +1,68 @@
// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings)
// 쓸 수 있는 것: 문단 · 줄바꿈, 굵게 · 기울임 · 밑줄, 글자 색, 링크, 서명 그림. 그 밖(목록 · 표 · 글꼴 · 글자 크기 등)은
// 스키마에 없어 붙여 넣어도 글자만 남는다. 값 형식은 쓰기 본문과 같다 (서버 정화 허용 목록 — mail-format-extensions.ts).
// 서명은 쓰기 화면을 열 때 본문에 그대로 들어가므로, 이 스키마는 쓰기 본문 스키마(mail-extensions.ts)의 일부여야 한다.
import { Extension, type AnyExtension } from '@tiptap/core'
import { TextStyle } from '@tiptap/extension-text-style'
import { StarterKit } from '@tiptap/starter-kit'
import { createMailLinkExtension } from '@/components/mail/editor/mail-extensions'
import { MailColor } from '@/components/mail/editor/mail-format-extensions'
import { SignatureImage } from '@/components/mail/editor/signature-image'
export interface SignatureEditorHooks {
/** Ctrl+K — 링크 넣기 · 고치기 */
openLink: (() => void) | null
/** Alt+F10 — 툴바로 간다 */
focusToolbar: (() => void) | null
}
const SignatureBehavior = Extension.create<SignatureEditorHooks>({
name: 'signatureEditor',
addOptions() {
return { openLink: null, focusToolbar: null }
},
addKeyboardShortcuts() {
return {
'Mod-k': () => {
this.options.openLink?.()
return true
},
'Alt-F10': () => {
this.options.focusToolbar?.()
return true
},
}
},
})
/** StarterKit 에서 쓰지 않는 것 — 제목 · 코드 · 목록 · 인용 · 가로줄 · 취소선 (링크는 따로 넣는다) */
const STARTER_KIT_OPTIONS = {
heading: false,
code: false,
codeBlock: false,
blockquote: false,
bulletList: false,
orderedList: false,
listItem: false,
listKeymap: false,
horizontalRule: false,
strike: false,
link: false,
} as const
export function createSignatureEditorExtensions(hooks: Partial<SignatureEditorHooks> = {}): AnyExtension[] {
return [
StarterKit.configure(STARTER_KIT_OPTIONS),
createMailLinkExtension(),
TextStyle,
MailColor,
SignatureImage,
SignatureBehavior.configure({
openLink: hooks.openLink ?? null,
focusToolbar: hooks.focusToolbar ?? null,
}),
]
}
@@ -0,0 +1,14 @@
// 받는 사람 추천 목록의 고정 id — 입력칸(RecipientField)과 목록(RecipientSuggestionList)이 함께 쓴다 (화면 검증 · 테스트도 쓴다)
// 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint
export function suggestionListId(inputId: string): string {
return `${inputId}-suggestions`
}
export function suggestionHintId(inputId: string): string {
return `${inputId}-suggestions-hint`
}
export function suggestionOptionId(inputId: string, index: number): string {
return `${inputId}-option-${index}`
}
@@ -20,19 +20,25 @@ describe('AppRail', () => {
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
})
it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
it('메일 · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
const wrapper = await mountRail()
const current = wrapper.get('a[aria-current="page"]')
const links = wrapper.findAll('a.rail-item').map((link) => [link.text(), link.attributes('href')])
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
expect(links).toEqual([
['메일', '/mail'],
['설정', '/settings'],
])
})
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
const wrapper = await mountRail()
const disabled = wrapper.findAll('button[aria-disabled="true"]')
expect(disabled).toHaveLength(APP_MODULES.length - 1)
expect(disabled).toHaveLength(APP_MODULES.length - 2)
for (const item of disabled) {
expect(item.attributes('title')).toContain('준비 중')
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
+4
View File
@@ -60,6 +60,10 @@ describe('에러 코드 사전', () => {
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
// 웹메일 2단계
'MAIL_008', // 다른 곳에서 바뀐 임시 저장본 — 쓰기 화면이 새 저장본으로 남기고 정보 상태 글을 보인다
'MAIL_001', // 메일 서버 장애 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
@@ -411,4 +411,58 @@ describe('useAttachmentUploads', () => {
stop()
})
})
describe('임시 저장 뒤 첨부 id 바꾸기 (계약 2절 "임시 저장" — 저장할 때마다 첨부 id 가 바뀐다)', () => {
const SAVED = (id: string, name: string) => ({ id, name, type: 'application/pdf', size: 1 })
it('저장 본문에 실은 순서대로 응답의 id 로 바꾼다 — 화면의 이름 · 키는 그대로', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
uploads.addFiles([file('회의록.pdf')])
await flushPromises()
const [forward] = uploads.addExisting([SAVED('orig-1', '체크리스트.xlsx')])
const keys = uploads.items.value.map((item) => item.key)
uploads.applySavedAttachments(keys, [SAVED('up1-v1', '회의록.pdf'), SAVED('orig-1-v1', '체크리스트.xlsx')])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['up1-v1', 'orig-1-v1'])
expect(uploads.items.value.map((item) => item.key)).toEqual(keys)
expect(uploads.items.value[1]?.key).toBe(forward?.key)
stop()
})
it('이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이고 응답 순서가 달라도', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
const added = uploads.addExisting([
{ id: 'small', name: '보고서.pdf', type: 'application/pdf', size: 10 },
{ id: 'large', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
{ id: 'sheet', name: '표.xlsx', type: 'application/vnd.ms-excel', size: 5 },
])
const keys = added.map((item) => item.key)
uploads.applySavedAttachments(keys, [
{ id: 'large-v1', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
// 서버가 형식을 고쳐 돌려줬다 — 남은 것끼리 순서대로
{ id: 'sheet-v1', name: '표.xlsx', type: 'application/octet-stream', size: 5 },
{ id: 'small-v1', name: '보고서.pdf', type: 'application/pdf', size: 10 },
])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['small-v1', 'large-v1', 'sheet-v1'])
stop()
})
it('개수가 다르면 같은 자리 · 같은 이름끼리만 — 저장하는 동안 뺀 첨부는 건너뛴다', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
const added = uploads.addExisting([SAVED('a', '가.pdf'), SAVED('b', '나.pdf')])
const keys = added.map((item) => item.key)
uploads.remove(keys[1] ?? -1)
uploads.applySavedAttachments(keys, [SAVED('a-v1', '가.pdf')])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['a-v1'])
stop()
})
})
})
@@ -51,6 +51,11 @@ export interface AttachmentUploadOptions {
const COMPLETE = 100
/** 같은 파일로 보이는가 — 이름 · 크기 · 형식이 모두 같다 */
function isSameFile(a: Attachment, b: Attachment): boolean {
return a.name === b.name && a.size === b.size && a.type === b.type
}
/**
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
@@ -205,6 +210,43 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
return marked
}
/**
* 임시 저장이 끝났다 — 저장본의 첨부 id 로 바꿔 쓴다 (계약 2절 "임시 저장": 저장할 때마다 첨부 id 가 바뀐다).
* keys 는 저장 본문에 실은 첨부 항목의 키(실은 순서대로), saved 는 응답의 첨부다.
* 이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이어도 응답의 순서에만 기대지 않게.
* 남은 것은 순서대로 — 남은 수가 같으면 같은 자리끼리, 다르면 같은 자리 · 같은 이름끼리만 바꾼다.
* 저장하는 동안 뺀 항목은 이미 없고(그 자리는 건너뛴다), 새로 붙인 항목은 다음 저장에 실린다.
*/
function applySavedAttachments(keys: readonly number[], saved: readonly Attachment[]) {
const sent = keys.map((key) => items.value.find((candidate) => candidate.key === key))
const next = new Map<number, Attachment>()
const usedSaved = new Set<number>()
const matchedSent = new Set<number>()
sent.forEach((item, position) => {
const current = item?.attachment
if (!item || !current) return
const index = saved.findIndex((candidate, at) => !usedSaved.has(at) && isSameFile(current, candidate))
const attachment = saved[index]
if (!attachment) return
usedSaved.add(index)
matchedSent.add(position)
next.set(item.key, attachment)
})
const restSent = sent.filter((_, position) => !matchedSent.has(position))
const restSaved = saved.filter((_, at) => !usedSaved.has(at))
const sameCount = restSent.length === restSaved.length
restSent.forEach((item, index) => {
const attachment = restSaved[index]
if (!attachment || !item?.attachment) return
if (sameCount || item.attachment.name === attachment.name) next.set(item.key, attachment)
})
if (next.size === 0) return
items.value = items.value.map((item) => {
const attachment = next.get(item.key)
return attachment && item.status === 'done' ? { ...item, attachment } : item
})
}
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
function remove(key: number) {
controllers.get(key)?.abort()
@@ -233,6 +275,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachedCount,
addFiles,
addExisting,
applySavedAttachments,
markMissing,
remove,
reset,
+29
View File
@@ -0,0 +1,29 @@
import { inject, type InjectionKey } from 'vue'
// 시계 — 기다렸다 하는 일(임시 저장의 입력 멈춤 · 받는 사람 추천의 기다림)이 쓰는 시각과 타이머.
// 화면은 기본으로 브라우저 시계를 쓰고, 테스트는 손으로 돌리는 시계를 provide 해 시간을 정확히 넘긴다
// (test-utils/manual-clock.ts — 진짜 타이머를 기다리면 느리고, 부하에 따라 결과가 달라진다).
/** 타이머 손잡이 — 시계마다 모양이 다르므로 열어 보지 않는다 */
export type ClockTimer = ReturnType<typeof setTimeout> | number | symbol
export interface Clock {
/** 지금 시각 (밀리초) */
now: () => number
setTimeout: (callback: () => void, ms: number) => ClockTimer
clearTimeout: (timer: ClockTimer) => void
}
export const systemClock: Clock = {
now: () => Date.now(),
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
}
/** 화면 트리에 시계를 준다 — 주지 않으면 브라우저 시계 */
export const CLOCK: InjectionKey<Clock> = Symbol('clock')
/** 컴포넌트 setup 에서 부른다 */
export function useClock(): Clock {
return inject(CLOCK, systemClock)
}
@@ -0,0 +1,119 @@
import { onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { saveDraftDetached } from './useDraftSaving'
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
// - 주소 맞추기(sync): 저장할 때마다 /mail/compose?draft=<새 id> 로 바꿔 끼운다 — 새로고침 · 다시 열기가 마지막 저장본을 이어 쓴다.
// 새 메일 · 답장 · 전달 · 옛 draft 매개변수를 모두 이것으로 바꾼다. 스스로 바꾼 주소는 ownsKey 로 알아본다 —
// 쓰기 화면은 그 주소로 양식을 다시 채우지도, 떠나는 이동으로 보지도 않는다.
// 다른 이동이 진행 중이면 바꾸지 않는다 — 그 이동을 취소해 버린다: 쓰기 화면이 막는 때(canReplace — 나가기 확인 · 떠나기 전 저장 ·
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
export interface ComposeDraftLinkOptions {
/** 지금 주소를 바꿔도 되는가 — 쓰기 화면의 다른 흐름(나가기 확인 · 보내기 · 버리기 · 보낸 뒤 · 버린 뒤)이 아니다 */
canReplace: () => boolean
}
/** 이 화면이 주소에 쓰는 저장본 표시 — 쓰기 화면의 draftKeyOf 와 같은 모양 */
function draftKeyFor(id: string): string {
return `draft:${id}`
}
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
const router = useRouter()
const mailStore = useMailStore()
/**
* 이 양식의 주소 — 연 때의 쓰기 종류 · 원본, 저장한 뒤에는 바꿔 끼운 `draft:<id>`.
* 주소가 이것으로 바뀐 것은 이 화면이 스스로 바꾼 것이다
*/
let ownKey = ''
/** 이 화면이 바꿔 끼우는 중인 주소 — 다른 이동을 세는 데서 뺀다 */
let ownSyncPath: string | null = null
/**
* 라우터의 가드를 지난 다른 이동 수 — 화면이 바뀌기 전(지연 로딩한 화면을 받는 중 등)에 주소를 바꿔 끼우면 그 이동이 취소된다.
* 이 화면의 이동 가드(떠나기 전 저장 · 대화상자)를 기다리는 동안은 쓰기 화면의 canReplace 가 맡는다 (그 가드가 라우터 전체 가드보다 먼저 돈다)
*/
let navigationsPending = 0
const stopCountingStart = router.beforeEach((to) => {
if (to.fullPath !== ownSyncPath) navigationsPending += 1
})
const stopCountingEnd = router.afterEach((to, _from, failure) => {
const own = ownSyncPath !== null && to.fullPath === ownSyncPath
if (own) ownSyncPath = null
// 끝까지 간 이동이 있으면 그 앞의 이동은 남아 있지 않다(라우터가 앞 이동을 취소한다). 실패로 끝난 이동은 하나만 뺀다
if (!failure) navigationsPending = 0
else if (!own) navigationsPending = Math.max(0, navigationsPending - 1)
})
// 이동이 오류로 끝나면 afterEach 가 오지 않는다 — 세던 것을 비운다
const stopCountingError = router.onError(() => {
navigationsPending = 0
})
/** 이 화면이 스스로 바꾼(또는 연) 주소인가 */
function ownsKey(key: string): boolean {
return key === ownKey
}
/** 이 주소의 양식을 연다 — 이제 이 주소가 이 화면의 것이다 */
function adoptKey(key: string) {
ownKey = key
}
/** 주소를 /mail/compose?draft=<id> 로 바꿔 끼운다 — 이 화면이 스스로 바꾼 것으로 표시한다. 이미 그 주소면 그대로 */
function replace(id: string) {
ownKey = draftKeyFor(id)
const current = router.currentRoute.value
if (current.query.draft === id && Object.keys(current.query).length === 1) return
const target = router.resolve({ name: 'mail-compose', query: { draft: id } })
ownSyncPath = target.fullPath
void router.replace(target)
}
/** 저장한 저장본을 주소에 옮긴다 — 다른 이동이 진행 중이거나 쓰기 화면을 떠났으면 바꾸지 않는다 */
function sync(id: string | null) {
if (!id || !options.canReplace() || navigationsPending > 0) return
if (router.currentRoute.value.name !== 'mail-compose') return
replace(id)
}
/** 이어 쓸 저장본 — 이 탭에서 다시 저장해 바뀐 옛 id 면 마지막 저장본을 고르고 주소도 그것으로 바꾼다 */
function openDraft(requested: string): string {
const latest = mailStore.latestDraftId(requested)
if (latest !== requested) replace(latest)
return latest
}
/** 저장이 끝났다 — 옛 id → 새 id 를 적는다 (양식이 바뀐 뒤 끝난 저장도 — 탭 메모리 정보라 해가 없다) */
function recordSaved(previousId: string | null, saved: DraftSaved) {
if (previousId !== null) mailStore.recordDraftSuccessor(previousId, saved.id)
}
/**
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
*/
async function keepUnsent(payload: SendMessagePayload) {
const { draftId = null, ...draft } = payload
const saved = await saveDraftDetached(draft, draftId)
if (!saved) return
recordSaved(draftId, saved)
mailStore.syncCounts()
}
onBeforeUnmount(() => {
stopCountingStart()
stopCountingEnd()
stopCountingError()
})
return { ownsKey, adoptKey, sync, openDraft, recordSaved, keepUnsent }
}
@@ -0,0 +1,447 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { useDraftSaving, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
const PAYLOAD: DraftPayload = { to: [], cc: [], bcc: [], subject: '초안', body: '', attachments: [] }
function savedAs(id: string, at = '2026-10-27T06:12:00Z'): DraftSaved {
return { id, attachments: [], updatedAt: at }
}
/** 붙잡아 둔 응답 */
interface Held {
release: (result: MockResult) => void
}
describe('useDraftSaving (임시 저장 — 계약 2절 "임시 저장")', () => {
let scope: EffectScope
let clock: ManualClock
/** 저장할 바뀐 내용이 있다 — 저장이 끝나면(commit) 지운다 */
let changed: boolean
let commits: DraftSaved[]
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
scope = effectScope()
clock = createManualClock(1_000_000)
changed = false
commits = []
})
afterEach(() => {
scope.stop()
useToastStore().clear()
restoreApiAdapter()
})
function setup(handler: MockHandler, canSave = () => true) {
requests = installMockApi(handler)
const drafts = scope.run(() =>
useDraftSaving({
canSave,
hasChanges: () => changed,
prepare: (): DraftSaveRequest => ({
payload: PAYLOAD,
commit: (saved) => {
changed = false
commits.push(saved)
},
}),
clock,
}),
)
if (!drafts) throw new Error('scope')
return drafts
}
/** 바꿨다 — 화면이 하는 것처럼 touch */
function edit(drafts: ReturnType<typeof setup>) {
changed = true
drafts.touch()
}
const saves = () => requests.filter((request) => request.url.startsWith('/drafts'))
const calls = () => saves().map((request) => `${request.method} ${request.url}`)
/** 처음은 d1, 그다음 d2 … 를 돌려주는 서버 */
function counting(): MockHandler {
let next = 1
return (request) => {
if (!request.url.startsWith('/drafts')) return ok(null)
const id = `d${next}`
next += 1
return ok(savedAs(id), request.method === 'POST' ? 201 : 200)
}
}
it('입력이 3초 멈추면 저장한다 — 계속 쓰는 동안은 미룬다', async () => {
const drafts = setup(counting())
edit(drafts)
clock.advance(2_000)
edit(drafts)
clock.advance(2_999)
await flushPromises()
expect(calls()).toEqual([])
clock.advance(1)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
expect(drafts.status.value).toBe('saved')
expect(drafts.draftId.value).toBe('d1')
expect(drafts.savedAt.value).toBe('2026-10-27T06:12:00Z')
expect(commits).toHaveLength(1)
})
it('쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다 — 되돌려 바꾼 것이 없어지면 거둔다', async () => {
const drafts = setup(counting())
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
await flushPromises()
if (elapsed + 1_000 < 30_000) expect(calls()).toEqual([])
}
expect(calls()).toEqual(['POST /drafts'])
// 바꿨다가 되돌리면(바꾼 것이 없어짐) 30초 타이머도 거둔다
edit(drafts)
changed = false
drafts.touch()
clock.advance(60_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
})
it('저장하는 동안 바꾼 것은 그때(저장하는 동안 처음 바꾼 때)부터 30초를 센다', async () => {
let held: Held | null = null
let posts = 0
const drafts = setup((request) => {
if (request.method === 'POST' && posts === 0) {
posts += 1
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d2'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
// 저장이 끝나지 않은 채 25초 동안 쉬지 않고 쓴다 — 30초 타이머는 저장하는 동안 처음 바꾼 때(3초)부터
for (let elapsed = 0; elapsed < 25_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
}
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
changed = true
for (let elapsed = 0; elapsed < 5_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
await flushPromises()
}
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it('바꾼 내용이 없으면 저장하지 않는다', async () => {
const drafts = setup(counting())
drafts.touch()
clock.advance(60_000)
await flushPromises()
expect(calls()).toEqual([])
expect(drafts.status.value).toBe('idle')
})
it('10초에 한 번까지 — 그다음 저장은 직전 저장을 시작한 지 10초 뒤, 직전 id 로 PUT', async () => {
const drafts = setup(counting())
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
// 첫 저장을 시작한 때(3초)부터 10초 — 13초에
clock.advance(6_999)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
clock.advance(1)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
expect(drafts.draftId.value).toBe('d2')
})
it('한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시 저장한다', async () => {
let held: Held | null = null
const drafts = setup((request) => {
if (request.method === 'POST') {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d2'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(drafts.status.value).toBe('saving')
// 저장하는 동안 또 바꾸고 3초 · 10초가 지나도 두 번째 요청은 나가지 않는다
edit(drafts)
clock.advance(15_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
// 첫 저장의 commit 이 "바꿈"을 지웠지만, 그 뒤 또 바꿨다 — 화면은 다시 touch 한다
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it.each(['MAIL_008', 'RES_001'] as const)(
'PUT 이 %s 면 막지 않고 지금 내용을 POST 로 새로 남긴다 — 상태 replaced, 다음은 새 id 로',
async (code) => {
let posts = 0
const drafts = setup((request) => {
if (request.method === 'POST') {
posts += 1
return ok(savedAs(posts === 1 ? 'd1' : 'd9'), 201)
}
if (request.url === '/drafts/d1') return fail(code === 'MAIL_008' ? 409 : 404, code)
return ok(savedAs('d10'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1', 'POST /drafts'])
expect(drafts.status.value).toBe('replaced')
expect(drafts.draftId.value).toBe('d9')
expect(useToastStore().toasts).toEqual([])
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls().at(-1)).toBe('PUT /drafts/d9')
expect(drafts.status.value).toBe('saved')
},
)
it('자동 저장이 실패하면 알림 없이 failed — 다시 해 보면 나아질 실패는 15초 뒤 저절로 다시 한다', async () => {
let fails = 1
const drafts = setup(() => {
if (fails > 0) {
fails -= 1
return fail(503, 'MAIL_001')
}
return ok(savedAs('d1'), 201)
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(drafts.status.value).toBe('failed')
expect(drafts.lastError.value?.code).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
clock.advance(14_999)
await flushPromises()
expect(calls()).toHaveLength(1)
clock.advance(1)
await flushPromises()
expect(calls()).toHaveLength(2)
expect(drafts.status.value).toBe('saved')
})
it('내용을 고쳐야 하는 실패(MAIL_007 등)는 저절로 다시 하지 않는다 — 다시 바꾸면 저장한다', async () => {
const drafts = setup(() => fail(400, 'MAIL_007'))
edit(drafts)
clock.advance(3_000)
await flushPromises()
clock.advance(120_000)
await flushPromises()
expect(calls()).toHaveLength(1)
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toHaveLength(2)
})
it('「임시 저장」은 바로 저장하고, 실패하면 사전 문구 알림을 띄운다', async () => {
const drafts = setup(() => fail(507, 'MAIL_003'))
edit(drafts)
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
expect(drafts.status.value).toBe('failed')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
})
it('「임시 저장」 — 아직 저장한 적 없으면 바꾼 것이 없어도 저장하고, 저장한 그대로면 요청하지 않는다', async () => {
const drafts = setup(counting())
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
})
it('보내기 · 버리기 전 — suspend 로 멈추고 settle 로 진행 중인 저장이 끝나기를 기다린다', async () => {
let held: Held | null = null
const drafts = setup(() => {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
drafts.suspend()
let settled = false
void drafts.settle().then(() => {
settled = true
})
edit(drafts)
clock.advance(30_000)
await flushPromises()
expect(settled).toBe(false)
expect(calls()).toHaveLength(1)
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
expect(settled).toBe(true)
expect(drafts.draftId.value).toBe('d1')
// 멈춘 동안은 자동 저장하지 않는다
clock.advance(30_000)
await flushPromises()
expect(calls()).toHaveLength(1)
})
it('떠나기 전 — 남은 변경을 저장하고 성공 여부를 알려 준다 (저장한 그대로면 요청 없이 true)', async () => {
const drafts = setup(counting())
expect(await drafts.saveBeforeLeave()).toBe(true)
expect(calls()).toEqual([])
edit(drafts)
expect(await drafts.saveBeforeLeave()).toBe(true)
expect(calls()).toEqual(['POST /drafts'])
})
it('저장할 수 없는 때(canSave false)는 요청하지 않는다', async () => {
const drafts = setup(counting(), () => false)
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(await drafts.saveNow()).toBe(false)
expect(calls()).toEqual([])
})
it('reset — 이어 쓰는 저장본이면 그 id · 시각으로 시작하고, 늦게 끝난 앞 양식의 저장은 무시한다', async () => {
let held: Held | null = null
const drafts = setup((request) => {
if (request.method === 'POST') {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d8'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
drafts.reset({ id: 'd7', savedAt: '2026-10-26T01:00:00Z' })
expect(drafts.status.value).toBe('saved')
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
expect(drafts.draftId.value).toBe('d7')
expect(commits).toEqual([])
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls().at(-1)).toBe('PUT /drafts/d7')
})
it('reset(keepOriginal) — 원본을 지우지 않는다: 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT. 그동안 draftId 는 없다', async () => {
const drafts = setup(counting())
drafts.reset({ id: 'outlook-1', savedAt: '2026-10-26T01:00:00Z', keepOriginal: true })
expect(drafts.status.value).toBe('saved')
expect(drafts.draftId.value).toBeNull()
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it('onSaved — 저장이 끝날 때마다 바꾼 id 와 새 저장본을 알린다 (양식이 바뀐 뒤 끝난 저장도)', async () => {
let held: Held | null = null
const seen: [string | null, string][] = []
requests = installMockApi((request) =>
request.method === 'PUT'
? new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
: ok(savedAs('d1'), 201),
)
const drafts = scope.run(() =>
useDraftSaving({
canSave: () => true,
hasChanges: () => changed,
prepare: (): DraftSaveRequest => ({ payload: PAYLOAD, commit: () => (changed = false) }),
onSaved: (previousId, saved) => seen.push([previousId, saved.id]),
clock,
}),
)
if (!drafts) throw new Error('scope')
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
// PUT 이 나가 있는 동안 양식을 새로 채운다 — 늦게 끝난 저장도 알린다
drafts.reset()
;(held as Held | null)?.release(ok(savedAs('d2')))
await flushPromises()
expect(seen).toEqual([
[null, 'd1'],
['d1', 'd2'],
])
expect(drafts.draftId.value).toBeNull()
})
})
+352
View File
@@ -0,0 +1,352 @@
import { onScopeDispose, readonly, ref } from 'vue'
import { toApiError, type ApiError } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail, type MailRequestOptions } from './useMail'
import {
DRAFT_IDLE_SAVE_MS,
DRAFT_MAX_WAIT_MS,
DRAFT_MIN_SAVE_INTERVAL_MS,
DRAFT_RETRY_CODES,
DRAFT_RETRY_DELAYS_MS,
} from '@/shared/constants/mail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
// 쓰기 화면의 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장")
// - 자동 저장: 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 저장은 한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시.
// 쉬지 않고 쓰는 동안에도 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 30초가 지나면 저장한다.
// - 처음은 POST /drafts, 그다음은 PUT /drafts/{직전 id} — 응답의 id · 첨부로 바꿔 쓴다(prepare 가 준 commit).
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌으면(409 MAIL_008 · 임시보관함 밖으로 옮겨져 404 RES_001) 막지 않고 POST 로 새로 남긴다
// — 상태 'replaced' (임시보관함에 두 통이 남는다).
// - 자동 저장 실패는 알림 없이 상태(failed)로만 보이고, 다시 해 보면 나아질 실패(DRAFT_RETRY_CODES)는 조금 뒤 저절로 다시 한다.
// 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다 (인터셉터 — MAIL_008 · RES_001 은 위처럼 처리하므로 끈다).
// - 보내기 · 버리기 전에는 suspend 로 자동 저장을 멈추고 settle 로 진행 중인 저장을 기다린다 (draftId 가 최신이어야 한다).
export type DraftSaveStatus = 'idle' | 'saving' | 'saved' | 'failed' | 'replaced'
/** 저장을 시작한 까닭 — 알림을 띄울지(manual) 정한다 */
export type DraftSaveTrigger = 'auto' | 'manual' | 'leave'
/**
* 저장할 수 없는 내용이다 — 보내기 전 검사에 걸렸다 (예: 본문이 한도를 넘음 MAIL_007).
* 요청 없이 실패로 두고 저절로 다시 하지 않는다 — 내용이 바뀌면(touch) 다시 잰다
*/
export interface DraftSaveRejected {
rejected: ApiError
}
/** 저장 한 번 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수 */
export interface DraftSaveRequest {
payload: DraftPayload
/** 저장이 끝났다 — 응답의 첨부 id 로 바꿔 쓰고, 이 요청을 만든 때의 양식을 "바꾸지 않은" 기준으로 삼는다 */
commit: (saved: DraftSaved) => void
}
export interface DraftSavingOptions {
/** 지금 저장해도 되는가 — 편집기 준비 · 로그인 · 보내는 중 아님 등 */
canSave: () => boolean
/** 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다 */
hasChanges: () => boolean
/** 지금 내용으로 저장 요청을 만든다 — 만들 수 없으면 null, 저장할 수 없는 내용이면(사전 검사) rejected */
prepare: () => DraftSaveRequest | DraftSaveRejected | null
/** 저장이 실패했다 — 예: 찾지 못한 첨부(MAIL_004)를 표시한다 */
onFailed?: (error: ApiError, trigger: DraftSaveTrigger) => void
/**
* 저장이 끝났다 — previousId 는 이 저장이 바꾼(PUT 한) 저장본 id, 새로 만들었으면 null.
* 양식이 바뀐 뒤 끝난 저장도 부른다 (commit 은 부르지 않는다) — 예: 옛 id → 새 id 를 적어 둔다
*/
onSaved?: (previousId: string | null, saved: DraftSaved) => void
clock?: Clock
}
/** 이어 쓰는 저장본 — 쓰기 화면이 임시 저장본을 불러와 채웠을 때 */
export interface ExistingDraft {
id: string
/** 마지막으로 저장한 시각 (ISO 8601) */
savedAt: string
/**
* 원본을 지우지 않는다 — 첫 저장을 PUT 대신 새 저장본(POST)으로 하고, 그 뒤는 새 id 로 PUT 한다.
* 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(사전 검사 실패)가 있을 때 — PUT 하면 서버가 그 첨부째 원본을 지운다.
* 그동안 draftId 는 null 이다 (보내기도 원본을 가리키지 않는다)
*/
keepOriginal?: boolean
}
/** 「임시 저장」을 눌렀을 때 끄는 알림 — 이 둘은 새로 저장해 처리한다 */
const REPLACED_CODES: readonly ErrorCode[] = ['MAIL_008', 'RES_001']
function isRetryable(error: ApiError): boolean {
return !error.canceled && DRAFT_RETRY_CODES.includes(error.code)
}
type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
/**
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
*/
async function putOrCreate(
mail: DraftApi,
payload: DraftPayload,
id: string | null,
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
): Promise<{ saved: DraftSaved; replaced: boolean }> {
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
try {
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
} catch (error) {
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
}
}
/**
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
*/
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
try {
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
} catch {
return null
}
}
export function useDraftSaving(options: DraftSavingOptions) {
const mail = useMail()
const clock = options.clock ?? systemClock
/** 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로 */
const draftId = ref<string | null>(null)
const status = ref<DraftSaveStatus>('idle')
/** 마지막으로 저장한 시각 — 상태 글에 보인다 */
const savedAt = ref<string | null>(null)
/** 마지막 저장의 실패 (성공하면 비운다) */
const lastError = ref<ApiError | null>(null)
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게 */
let generation = 0
let inFlight: Promise<boolean> | null = null
let idleTimer: ClockTimer | null = null
/** 쉬지 않고 쓰는 동안의 저장 — 마지막 저장을 시작한 뒤 처음 바꾼 때 건다 (그 뒤로는 다시 걸지 않는다) */
let maxWaitTimer: ClockTimer | null = null
let retryTimer: ClockTimer | null = null
/** 마지막 저장을 시작한 시각 — 자동 저장은 10초에 한 번까지 */
let lastStartedAt = Number.NEGATIVE_INFINITY
let retryCount = 0
/** 저장하는 동안 자동 저장할 차례가 왔다 — 끝나면 다시 저장한다 */
let queued = false
let suspended = false
let stopped = false
function clearIdle() {
if (idleTimer !== null) clock.clearTimeout(idleTimer)
idleTimer = null
}
function clearMaxWait() {
if (maxWaitTimer !== null) clock.clearTimeout(maxWaitTimer)
maxWaitTimer = null
}
function clearRetry() {
if (retryTimer !== null) clock.clearTimeout(retryTimer)
retryTimer = null
}
/** 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 그때까지 미룬다 (입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다) */
function autosave() {
clearIdle()
if (suspended || stopped) return
if (inFlight) {
queued = true
return
}
if (!options.hasChanges()) return
const wait = lastStartedAt + DRAFT_MIN_SAVE_INTERVAL_MS - clock.now()
if (wait > 0) {
idleTimer = clock.setTimeout(autosave, wait)
return
}
void run('auto')
}
function onMaxWait() {
maxWaitTimer = null
autosave()
}
/**
* 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다 (계속 쓰는 동안은 미룬다).
* 다만 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다 — 그 타이머는 처음 바꾼 때 한 번만 건다.
* 바꾼 것이 없어졌으면(되돌림) 둘 다 거둔다.
*/
function touch() {
if (suspended || stopped) return
clearIdle()
if (!options.hasChanges()) {
clearMaxWait()
return
}
idleTimer = clock.setTimeout(autosave, DRAFT_IDLE_SAVE_MS)
if (maxWaitTimer === null) maxWaitTimer = clock.setTimeout(onMaxWait, DRAFT_MAX_WAIT_MS)
}
function scheduleRetry() {
clearRetry()
const delay = DRAFT_RETRY_DELAYS_MS[Math.min(retryCount, DRAFT_RETRY_DELAYS_MS.length - 1)] ?? 0
retryCount += 1
retryTimer = clock.setTimeout(() => {
retryTimer = null
autosave()
}, delay)
}
/** 저장 요청 — PUT 이 MAIL_008 · RES_001 이면 새 저장본으로. 「임시 저장」만 사전 문구 알림을 띄운다 */
function send(payload: DraftPayload, trigger: DraftSaveTrigger): Promise<{ saved: DraftSaved; replaced: boolean }> {
const manual = trigger === 'manual'
return putOrCreate(mail, payload, draftId.value, {
first: manual ? REPLACED_CODES : true,
fallback: manual ? undefined : true,
})
}
/** 저장 한 번 — 끝나면 성공 여부 */
async function run(trigger: DraftSaveTrigger): Promise<boolean> {
if (stopped || !options.canSave()) return false
const request = options.prepare()
if (!request) return false
clearIdle()
clearMaxWait()
clearRetry()
queued = false
if ('rejected' in request) {
// 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다. 저절로 다시 하지 않는다 (내용이 바뀌면 touch 가 다시 잡는다)
status.value = 'failed'
lastError.value = request.rejected
options.onFailed?.(request.rejected, trigger)
return false
}
const started = generation
lastStartedAt = clock.now()
status.value = 'saving'
// 이 저장이 바꿀 저장본 — send 가 이 id 로 PUT 한다(없으면 POST)
const previousId = draftId.value
const attempt = (async (): Promise<boolean> => {
try {
const { saved, replaced } = await send(request.payload, trigger)
options.onSaved?.(previousId, saved)
if (started !== generation) return false
draftId.value = saved.id
savedAt.value = saved.updatedAt
status.value = replaced ? 'replaced' : 'saved'
lastError.value = null
retryCount = 0
request.commit(saved)
return true
} catch (error) {
if (started !== generation) return false
const apiError = toApiError(error)
status.value = 'failed'
lastError.value = apiError
options.onFailed?.(apiError, trigger)
if (isRetryable(apiError) && !suspended && !stopped) scheduleRetry()
return false
}
})()
inFlight = attempt
const ok = await attempt
if (inFlight === attempt) inFlight = null
// 저장하는 동안 자동 저장 차례가 왔다 — 남은 변경을 다시 (10초 간격은 autosave 가 지킨다)
if (started === generation && queued) {
queued = false
autosave()
}
return ok
}
/** 진행 중인 저장이 끝나기를 기다린다 (보내기 · 버리기 전) */
async function settle(): Promise<void> {
while (inFlight) await inFlight
}
/**
* 「임시 저장」 — 진행 중인 저장을 기다린 뒤, 바꾼 것이 있거나 아직 한 번도 저장하지 않았으면 저장한다.
* 이미 저장한 그대로면 요청하지 않는다. 실패하면 사전 문구 알림(인터셉터)이 함께 뜬다.
*/
async function saveNow(): Promise<boolean> {
clearIdle()
await settle()
if (draftId.value !== null && !options.hasChanges()) return true
return run('manual')
}
/** 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) true */
async function saveBeforeLeave(): Promise<boolean> {
clearIdle()
await settle()
if (!options.hasChanges()) return true
return run('leave')
}
/** 자동 저장을 멈춘다 — 보내기 · 버리기 동안 */
function suspend() {
suspended = true
clearIdle()
clearMaxWait()
clearRetry()
}
/** 다시 자동 저장한다 — 보내기가 실패했을 때 등 */
function resume() {
if (stopped) return
suspended = false
touch()
}
/** 더 저장하지 않는다 — 보냈거나 버렸다 */
function stop() {
stopped = true
suspend()
}
/** 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 와 저장 시각으로 시작한다 */
function reset(existing: ExistingDraft | null = null) {
generation += 1
clearIdle()
clearMaxWait()
clearRetry()
inFlight = null
queued = false
suspended = false
stopped = false
lastStartedAt = Number.NEGATIVE_INFINITY
retryCount = 0
draftId.value = existing && !existing.keepOriginal ? existing.id : null
savedAt.value = existing?.savedAt ?? null
status.value = existing ? 'saved' : 'idle'
lastError.value = null
}
// 화면이 사라지면 더 저장하지 않는다 — 진행 중인 저장은 끝까지 받아 draftId 를 맞춘다
// (보내기를 누르고 바로 떠난 경우 — 보내기가 그 저장을 기다렸다가 새 draftId 로 보낸다)
onScopeDispose(stop)
return {
draftId: readonly(draftId),
status: readonly(status),
savedAt: readonly(savedAt),
lastError: readonly(lastError),
touch,
saveNow,
saveBeforeLeave,
settle,
suspend,
resume,
stop,
reset,
}
}
+65
View File
@@ -0,0 +1,65 @@
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
import { useMail } from './useMail'
import { useToast } from './useToast'
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장" · 계약 2절 "임시 저장")
// - 저장본을 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 } 이다. DELETE 는 이미 휴지통에 있는 메일을 영구 삭제한다 —
// 다른 창에서 먼저 휴지통으로 옮긴 저장본이 되돌릴 수 없게 지워지지 않게.
// - 휴지통 id 를 모르면 메일함을 먼저 불러온다. 그래도 모르면 지우지 않고 실패다 — 메일함 목록을 못 받았으면 그 오류,
// 받았는데 휴지통이 없으면 BIZ_001 을 알린다.
// - 옮겼으면 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + [되돌리기](임시보관함으로 — "임시보관함으로 되돌렸습니다.").
// 이미 없는 저장본(RES_001 — 다른 곳에서 보냈거나 지웠다)이면 버린 것과 같다 — 알리지 않는다.
/** moved — 옮겼다 · gone — 이미 없다(RES_001) · failed — 옮기지 못했다(알렸다) */
export type DraftTrashResult = 'moved' | 'gone' | 'failed'
export function useDraftTrash() {
const mailStore = useMailStore()
const { updateMessage } = useMail()
const toast = useToast()
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
let trash = mailStore.mailboxByRole('trash')
if (!trash) {
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
const loadError = await mailStore.loadMailboxes({ background: true })
trash = mailStore.mailboxByRole('trash')
if (!trash) {
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
return 'failed'
}
}
try {
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
return 'moved'
} catch (error) {
// 그 밖의 실패는 인터셉터가 알렸다
return toApiError(error).code === 'RES_001' ? 'gone' : 'failed'
}
}
/** 되돌리기 — 버린 저장본을 임시보관함으로 (PATCH { mailboxId }) */
async function restore(draftId: string, draftsMailboxId: string) {
if (await mailStore.moveMessage(draftId, draftsMailboxId)) toast.success(DRAFT_RESTORED_MESSAGE)
}
/** 저장본을 휴지통으로 옮기고 알린다 — 옮겼으면 [되돌리기] 알림. 옮겼거나 이미 없으면 개수를 서버 기준으로 다시 맞춘다 */
async function discard(draftId: string): Promise<DraftTrashResult> {
const result = await moveToTrash(draftId)
if (result === 'failed') return result
// 임시보관함 · 휴지통 개수를 서버 기준으로
mailStore.syncCounts()
if (result === 'moved') {
const draftsMailbox = mailStore.mailboxByRole('drafts')
toast.success(
DRAFT_DISCARDED_MESSAGE,
draftsMailbox ? { label: DRAFT_UNDO_LABEL, run: () => void restore(draftId, draftsMailbox.id) } : undefined,
)
}
return result
}
return { discard }
}
+45 -5
View File
@@ -1,11 +1,13 @@
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
import { computed, onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
// 묻기 전에 prepareLeave(임시 저장 등)를 기다린다 — 그 뒤에도 잃을 것이 남았을 때만 묻는다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
// guarding: 이동 가드가 준비 · 답을 기다리는 동안 true — 그동안 화면이 스스로 주소를 바꾸면(router.replace) 그 이동을 취소해 버린다.
export interface LeaveConfirmOptions {
/**
@@ -24,6 +26,16 @@ export interface LeaveConfirmOptions {
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
/**
* 묻기 전에 할 일 — 예: 남은 변경을 임시 저장한다. 끝난 뒤 active 를 다시 읽어 잃을 것이 남았을 때만 묻는다.
* 이동은 이것이 끝날 때까지 기다린다 (그사이 다른 이동이 오면 라우터가 앞 이동을 취소한다)
*/
prepareLeave?: () => Promise<unknown>
/**
* 묻는 중에 잃을 것이 없어졌을 때(active 가 false) 그대로 떠날지 — 예: 늦게 끝난 떠나기 전 저장(이제 잃을 것이 없다).
* 주지 않거나 false 면 그 이동을 취소한다 — 세션이 끝난 때처럼 다른 흐름(로그인 화면으로)이 이어받을 때
*/
leaveWhenSettled?: () => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
@@ -52,8 +64,34 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
resolve?.(leave)
}
function confirmLeave(): true | Promise<boolean> {
return options.active() ? ask() : true
/** 떠나기 전 준비 — 이동이 겹쳐도 한 번만 한다 */
let preparing: Promise<unknown> | null = null
function prepare(): Promise<unknown> {
if (!options.prepareLeave) return Promise.resolve()
if (!preparing) {
// 준비가 실패해도 이동 가드를 깨뜨리지 않는다 — 그다음 active 로 물을지 정한다
preparing = options
.prepareLeave()
.catch(() => undefined)
.finally(() => {
preparing = null
})
}
return preparing
}
/** 답을 기다리는 이동 가드 수 — 이동이 겹치면 여럿이다 */
const guards = ref<number>(0)
async function confirmLeave(): Promise<boolean> {
if (!options.active()) return true
guards.value += 1
try {
await prepare()
return options.active() ? await ask() : true
} finally {
guards.value -= 1
}
}
onBeforeRouteLeave(() => confirmLeave())
@@ -78,7 +116,7 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
if (!active && pending) answer(false)
if (!active && pending) answer(options.leaveWhenSettled?.() ?? false)
})
onBeforeUnmount(() => {
@@ -89,6 +127,8 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
return {
confirming: readonly(confirming),
/** 이동 가드가 준비(prepareLeave) · 답을 기다리는 중 */
guarding: computed(() => guards.value > 0),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
+77 -4
View File
@@ -8,13 +8,21 @@ import {
} from '@/shared/constants/mail'
import type {
Attachment,
Draft,
DraftPayload,
DraftSaved,
MailQuota,
MailSignature,
Mailbox,
MessageDetail,
MessageListQuery,
MessageSummary,
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
SendMessagePayload,
SendMessageResult,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -86,13 +94,71 @@ export function useMail() {
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
}
function updateMessage(id: string, changes: UpdateMessagePayload): Promise<MessageSummary> {
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes)
function updateMessage(
id: string,
changes: UpdateMessagePayload,
options?: MailRequestOptions,
): Promise<MessageSummary> {
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
}
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
async function deleteMessage(id: string): Promise<void> {
await api.delete(`/messages/${segment(id)}`)
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
await api.delete(`/messages/${segment(id)}`, options)
}
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
/** 이어 쓸 임시 저장본 — 없거나 초안이 아니면 404 RES_001 */
function getDraft(id: string, options?: MailRequestOptions): Promise<Draft> {
return api.get<Draft, Draft>(`/drafts/${segment(id)}`, options)
}
/** 처음 저장 — 201 DraftSaved. 본문 · 첨부는 발송과 같은 한도라 시간 한도도 발송과 같다 */
function createDraft(payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
return api.post<DraftSaved, DraftSaved>('/drafts', payload, { timeout: MAIL_SEND_TIMEOUT_MS, ...options })
}
/**
* 다시 저장 — 서버가 새 초안을 만들고 옛것을 지운다(응답의 id · attachments 로 바꿔 쓴다).
* 옛 초안이 다른 곳에서 먼저 바뀌었거나 지워졌으면 409 MAIL_008 — 부르는 쪽이 createDraft 로 새로 남긴다.
*/
function updateDraft(id: string, payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
return api.put<DraftSaved, DraftSaved>(`/drafts/${segment(id)}`, payload, {
timeout: MAIL_SEND_TIMEOUT_MS,
...options,
})
}
// ── 서명 (계약 2절 "서명") ─────────────────────────────
/** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
function getSignature(options?: MailRequestOptions): Promise<MailSignature> {
return api.get<MailSignature, MailSignature>('/mail-signature', options)
}
function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
return api.put<MailSignature, MailSignature>('/mail-signature', payload, options)
}
/** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise<SignatureImage> {
const form = new FormData()
form.append('file', file)
return api.post<SignatureImage, SignatureImage>('/mail-signature/images', form, options)
}
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
function listRecipientSuggestions(
query: RecipientSuggestionQuery,
options?: MailRequestOptions,
): Promise<PaginatedResult<RecipientSuggestion>> {
return api.get<PaginatedResult<RecipientSuggestion>, PaginatedResult<RecipientSuggestion>>(
'/recipient-suggestions',
{ ...options, params: query },
)
}
/**
@@ -160,5 +226,12 @@ export function useMail() {
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
getDraft,
createDraft,
updateDraft,
getSignature,
saveSignature,
uploadSignatureImage,
listRecipientSuggestions,
}
}
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import {
RECIPIENT_SEARCH_DELAY_MS,
useRecipientSuggestions,
} from '@/composables/useRecipientSuggestions'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
} from '@/test-utils/mock-api'
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
const VENDOR: RecipientSuggestion = { name: '협력사 담당', email: 'ops@vendor.example', source: 'recent' }
function page(items: RecipientSuggestion[], hasNext: boolean) {
return ok({ items, pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: null } })
}
describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () => {
let scope: EffectScope
let clock: ManualClock
let requests: MockRequest[]
let excluded: Set<string>
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
scope = effectScope()
clock = createManualClock()
excluded = new Set()
})
afterEach(() => {
scope.stop()
restoreApiAdapter()
})
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
requests = installMockApi((request) =>
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
)
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
const handle = setup(true)
handle.preload()
handle.preload()
await flushPromises()
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
excluded = new Set([DOYUN.email])
expect(handle.matchesFor('ㄷㅇ')).toEqual([])
})
it('받아 둔 목록이 전부면 서버에 다시 묻지 않는다', async () => {
const handle = setup(true)
handle.preload()
await flushPromises()
handle.search('vendor')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
})
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
const handle = setup(false, [VENDOR])
handle.preload()
await flushPromises()
handle.search('ven')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
await flushPromises()
expect(searches()).toEqual([])
clock.advance(1)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
handle.search('ㄷㅇ')
handle.search('vendor')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual(['vendor'])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
let failPreload = true
requests = installMockApi((request) => {
if (request.params.q !== undefined) return page([VENDOR], false)
if (failPreload) return fail(503, 'MAIL_001')
return page([DOYUN, JUNHO], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
handle.preload()
await flushPromises()
handle.search('ㄷㅇ')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
failPreload = false
handle.preload()
await flushPromises()
handle.preload()
await flushPromises()
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
})
it('추천을 못 받아도 조용히 넘어간다 (주소를 그대로 칠 수 있다)', async () => {
installMockApi(() => fail(503, 'MAIL_001'))
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
handle?.preload()
await flushPromises()
expect(handle?.matchesFor('도윤')).toEqual([])
})
})
@@ -0,0 +1,127 @@
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail } from './useMail'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail } from '@/shared/utils/mail-address'
import {
RECIPIENT_SUGGESTION_LIMIT,
filterRecipientSuggestions,
hasHangulJamo,
type RecipientMatch,
} from '@/shared/utils/recipient-match'
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
/** 처음 받아 두는 개수 — 계약의 최대 limit */
export const RECIPIENT_SUGGESTION_PRELOAD = 100
/** 서버에 다시 묻기 전에 기다리는 시간 */
export const RECIPIENT_SEARCH_DELAY_MS = 250
/** 서버가 받는 검색 글자 길이 (계약: q 1~100자) */
const SEARCH_QUERY_MAX_LENGTH = 100
export interface RecipientSuggestionsOptions {
/** 이미 넣은 주소 — 받는 사람 · 참조 · 숨은참조 모두 (정규화한 주소) */
excluded: () => ReadonlySet<string>
clock?: Clock
}
export interface RecipientSuggestionsHandle {
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
preload: () => void
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
matchesFor: (query: string) => RecipientMatch[]
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
search: (query: string) => void
}
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
const { listRecipientSuggestions } = useMail()
const clock = options.clock ?? systemClock
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
const pool = shallowRef<readonly RecipientSuggestion[]>([])
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
const complete = ref<boolean>(false)
/** 미리 받기 — 실패했으면 다음 preload 가 다시 받는다 */
let preloadState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
let searchTimer: ClockTimer | null = null
let controller: AbortController | null = null
/** 이미 물어본 글자 — 같은 글자를 두 번 묻지 않는다 */
const asked = new Set<string>()
function merge(items: readonly RecipientSuggestion[]) {
const known = new Set(pool.value.map((item) => normalizeEmail(item.email)))
const added = items.filter((item) => {
const key = normalizeEmail(item.email)
if (known.has(key)) return false
known.add(key)
return true
})
if (added.length > 0) pool.value = [...pool.value, ...added]
}
function preload() {
if (preloadState === 'loading' || preloadState === 'done') return
preloadState = 'loading'
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
.then((page) => {
preloadState = 'done'
merge(page.items)
complete.value = !page.pageInfo.hasNext
})
.catch(() => {
// 치는 대로 서버에 묻는다(search) — 다음에 칸에 초점이 오면 다시 받아 본다
preloadState = 'failed'
})
}
function matchesFor(query: string): RecipientMatch[] {
return filterRecipientSuggestions(query, pool.value, options.excluded())
}
function cancelSearch() {
if (searchTimer !== null) clock.clearTimeout(searchTimer)
searchTimer = null
controller?.abort()
controller = null
}
async function ask(query: string) {
searchTimer = null
asked.add(query)
controller = new AbortController()
const { signal } = controller
try {
const page = await listRecipientSuggestions(
{ q: query, limit: RECIPIENT_SUGGESTION_LIMIT * 2 },
{ silent: true, signal },
)
if (!signal.aborted) merge(page.items)
} catch {
// 보조 정보 — 못 받으면 받아 둔 목록만 보인다
asked.delete(query)
}
}
function search(query: string) {
cancelSearch()
const text = query.trim()
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
if (hasHangulJamo(text) || asked.has(text)) return
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
}
onScopeDispose(cancelSearch)
return { preload, matchesFor, search }
}
File diff suppressed because it is too large Load Diff
+73 -20
View File
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h, type FunctionalComponent } from 'vue'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import { CLOCK } from '@/composables/useClock'
import {
AUTH_CHANNEL_NAME,
closeLogoutChannel,
@@ -21,6 +22,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { createManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
@@ -100,6 +102,14 @@ describe('ComposePage', () => {
vi.useRealTimers()
})
/**
* 화면에 주는 것 — 지금 경로(이동 가드) · 손으로 돌리는 시계.
* 시계를 넘기지 않으면 자동 저장(입력이 3초 멈추면)이 돌지 않는다 — 이 파일은 자동 저장을 보지 않는다 (ComposePage.drafts.spec.ts 가 본다)
*/
function screenProvide(router: ReturnType<typeof createTestRouter>): Record<symbol, unknown> {
return { ...routeViewProvide(router), [CLOCK as symbol]: createManualClock() }
}
/**
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
@@ -113,7 +123,7 @@ describe('ComposePage', () => {
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
@@ -906,8 +916,24 @@ describe('ComposePage', () => {
})
})
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
describe('나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인")', () => {
const DIALOG = '#compose-leave-dialog'
/**
* 임시 저장이 늘 실패하는 서버 — 떠나기 전에 저장하지 못해 묻는다.
* (저장이 되면 묻지 않고 떠난다 — 그 흐름은 ComposePage.drafts.spec.ts 가 본다)
*/
const unsavable =
(base: MockHandler): MockHandler =>
(request) =>
request.url.startsWith('/drafts') ? fail(503, 'MAIL_001') : base(request)
/** 임시 저장을 받는 서버 — 처음은 POST 로 d1 */
const savable =
(base: MockHandler): MockHandler =>
(request) =>
request.method === 'POST' && request.url === '/drafts'
? ok({ id: 'd1', attachments: [], updatedAt: '2026-10-27T06:12:00Z' }, 201)
: base(request)
const draftSaves = (requests: MockRequest[]) => requests.filter((request) => request.url.startsWith('/drafts'))
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const forwardApi: MockHandler = (request) =>
request.method === 'GET' && request.url === '/messages/m1'
@@ -969,20 +995,28 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
it('손대지 않은 전달 초안(원본 첨부 포함)은 저장하지도 묻지도 않는다 — 원본 첨부를 빼면 바꾼 것이라 떠나기 전에 저장한다', async () => {
const first = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
expect(blocksUnload()).toBe(false)
await first.router.push('/mail/inbox')
expect(draftSaves(first.requests)).toEqual([])
first.wrapper.unmount()
const { wrapper, router, requests } = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
expect(blocksUnload()).toBe(true)
void router.push('/mail/inbox')
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(draftSaves(requests).map((request) => [request.method, request.url])).toEqual([['POST', '/drafts']])
expect(draftSaves(requests)[0]?.data).toMatchObject({ subject: 'Fwd: 메일 서버 이전 일정 공유', attachments: [] })
})
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
it('저장하지 못하면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), true)
await writeSubject(wrapper, '일정 공유')
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
subjectInput.focus()
@@ -995,8 +1029,8 @@ describe('ComposePage', () => {
expect(dialog.attributes('role')).toBe('alertdialog')
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
expect(wrapper.get('#compose-leave-title').text()).toBe('저장하지 못한 내용이 있습니다')
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.')
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('compose-leave-stay')
@@ -1013,13 +1047,32 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
it('「취소」는 없다 — 닫기 ✕ 는 저장한 채 닫는다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router, requests } = await mountCompose('/mail/compose', savable(replyApi))
expect(wrapper.find('.compose__cancel').exists()).toBe(false)
expect(wrapper.findAll('.compose__footer a, .compose__footer > button').map((item) => item.text())).toEqual([
'보내기',
'임시 저장',
])
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__cancel').trigger('click')
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
expect(draftSaves(requests)[0]?.data).toMatchObject({ body: '쓰던 본문' })
})
it('저장하지 못하면 닫기 ✕ 도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
await flushPromises()
await wrapper.get('.compose__close').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(true)
await wrapper.get('#compose-leave-stay').trigger('click')
@@ -1037,7 +1090,7 @@ describe('ComposePage', () => {
})
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
await writeSubject(wrapper, '일정 공유')
const navigation = router.push('/mail/inbox')
await flushPromises()
@@ -1053,7 +1106,7 @@ describe('ComposePage', () => {
})
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), false, ['/mail/inbox'])
await writeSubject(wrapper, '초안')
router.back()
@@ -1076,7 +1129,7 @@ describe('ComposePage', () => {
})
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
await writeSubject(wrapper, '일정 공유')
const first = router.push('/mail/inbox')
@@ -1093,8 +1146,8 @@ describe('ComposePage', () => {
expect(dialogOpen(wrapper)).toBe(false)
})
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 저장부터 하고, 못 하면 묻는다 — 계속 쓰면 답장 그대로', async () => {
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', unsavable(replyApi))
await writeSubject(wrapper, 'Re: 고친 제목')
const navigation = router.push('/mail/compose')
@@ -1348,7 +1401,7 @@ describe('ComposePage', () => {
})
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
mountedWrappers.push(sessionDialog)
await writeSubject(wrapper, '쓰던 메일')
@@ -1388,7 +1441,7 @@ describe('ComposePage', () => {
const router = createTestRouter()
await router.push('/mail/compose?forward=m1')
await router.isReady()
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: screenProvide(router) } })
mountedWrappers.push(wrapper)
await flushPromises()
File diff suppressed because it is too large Load Diff
+3
View File
@@ -3,6 +3,7 @@
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
import AppShell from '@/components/shell/AppShell.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
@@ -11,6 +12,8 @@ const mailStore = useMailStore()
void mailStore.ensureMailboxes()
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
</script>
<template>
@@ -0,0 +1,460 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailSignature, SaveSignaturePayload, SignatureImage } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import MailSignaturePage from './MailSignaturePage.vue'
// 설정 › 메일 › 서명 (디자인 원본 Settings · 계약 2절 "서명")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
const NEW_IMAGE_ID = '00000000-0000-4000-8000-000000000001'
const SAVED_AT = '2026-10-27T06:00:00Z'
const SIGNATURE: MailSignature = {
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p></div>',
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
function imageOf(id: string, width: number): SignatureImage {
return { id, type: 'image/png', size: 1000, width, height: 60 }
}
interface Server {
signature?: MailSignature | MockResult
/** 그림 올리기 응답 — 비면 NEW_IMAGE_ID · 폭 480 */
uploads?: MockResult[]
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
function server(options: Server = {}): MockHandler {
const uploads = [...(options.uploads ?? [])]
return (request) => {
const { method, url } = request
if (method === 'GET' && url === '/mail-signature') {
const signature = options.signature ?? SIGNATURE
return typeof signature === 'string' || 'status' in signature ? signature : ok(signature)
}
if (method === 'POST' && url === '/mail-signature/images') {
return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
}
if (method === 'PUT' && url === '/mail-signature') {
if (options.save) return options.save(request)
const body = request.data as SaveSignaturePayload
return ok({ ...body, images: [], updatedAt: SAVED_AT })
}
return ok(null)
}
}
describe('MailSignaturePage', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
})
async function mountPage(handler: MockHandler, waitForEditor = true) {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push('/settings/mail/signature')
await router.isReady()
// 서명 칸의 나가기 확인(onBeforeRouteLeave)이 지금 경로에 걸리게 — RouterView 가 주는 값과 같다
const wrapper = mount(MailSignaturePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
if (waitForEditor) {
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
}
return { wrapper, requests, router }
}
function choose(wrapper: VueWrapper, files: File[]) {
const picker = wrapper.get('#signature-image-input')
Object.defineProperty(picker.element, 'files', { value: files, configurable: true })
return picker.trigger('change')
}
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
function saveRequest(requests: MockRequest[]) {
return requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature')
}
it('저장한 서명으로 칸을 채우고, 넣을 메일 · 미리 보기를 보인다', async () => {
const { wrapper } = await mountPage(server())
expect(wrapper.get('h1').text()).toBe('서명')
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀')
expect(wrapper.get('#signature-body').attributes('aria-labelledby')).toBe('signature-label')
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('서명 서식')
expect(wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))).toEqual([
'굵게',
'기울임',
'밑줄',
'글자 색: 기본',
'링크',
'그림 넣기',
])
expect((wrapper.get('#signature-for-new').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('#signature-for-reply').element as HTMLInputElement).checked).toBe(true)
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀')
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('새 메일은 본문 끝에 서명을 넣습니다.')
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
let fails = 1
const { wrapper, requests } = await mountPage(
(request) => {
if (request.url === '/mail-signature' && fails > 0) {
fails -= 1
return fail(503, 'MAIL_001')
}
return server()(request)
},
false,
)
expect(wrapper.get('[role="alert"]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
await wrapper.get('[role="alert"] button').trigger('click')
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
expect(requests.filter((request) => request.url === '/mail-signature')).toHaveLength(2)
})
it('[저장] — 쓰기 본문과 같은 서식 본문 · 텍스트 서명 · 넣을 메일을 보내고 "저장했습니다"를 보인다 (바꾸면 내린다)', async () => {
const { wrapper, requests } = await mountPage(server())
const editor = editorOf(wrapper.element)
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>ARA SYSTEM</p>')
await wrapper.get('#signature-for-reply').setValue(false)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequest(requests)?.data).toEqual({
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
body: '김아라 · 인프라팀\nARA SYSTEM',
isForNew: true,
isForReply: false,
})
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.')
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
await wrapper.get('#signature-for-new').setValue(false)
expect(wrapper.get('#signature-save-status').text()).toBe('')
})
it('비운 서명은 bodyHtml · body 모두 null 로 저장한다', async () => {
const { wrapper, requests } = await mountPage(server())
editorOf(wrapper.element).commands.clearContent(true)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequest(requests)?.data).toEqual({ bodyHtml: null, body: null, isForNew: true, isForReply: true })
})
it('서버가 서명을 거절하면(VAL_001) 알림 없이 서명 칸 아래에 사전 문구', async () => {
const { wrapper } = await mountPage(server({ save: () => fail(400, 'VAL_001') }))
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#signature-body').attributes('aria-describedby')).toBe('signature-hint signature-error')
expect(useToastStore().toasts).toEqual([])
})
describe('서명 그림', () => {
it('그림을 올려 보통(144) 폭으로 넣고 고른다 — 크기 줄: 작게 96 · 보통 144 · 원본(600 이하) · 그림 빼기', async () => {
const { wrapper, requests } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 800), 201)] }))
await choose(wrapper, [png()])
await flushPromises()
const upload = requests.find((request) => request.url === '/mail-signature/images')
expect(upload?.data).toBeInstanceOf(FormData)
const image = () => wrapper.get('#signature-body img[src]')
expect(image().attributes()).toMatchObject({ src: `/api/mail-signature/images/${NEW_IMAGE_ID}`, width: '144' })
expect(wrapper.get('[aria-labelledby="signature-image-size-label"]').attributes('role')).toBe('group')
const pressed = () => wrapper.get('[data-size][aria-pressed="true"]').text()
expect(pressed()).toBe('보통')
await wrapper.get('[data-size="small"]').trigger('click')
expect(image().attributes('width')).toBe('96')
expect(pressed()).toBe('작게')
await wrapper.get('[data-size="original"]').trigger('click')
expect(image().attributes('width')).toBe('600')
expect(pressed()).toBe('원본')
await wrapper.get('.signature-editor__remove').trigger('click')
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
expect(wrapper.find('[data-size]').exists()).toBe(false)
})
it('작은 그림은 늘리지 않는다 — 원래 폭으로 넣는다', async () => {
const { wrapper } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 80), 201)] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.get('#signature-body img[src]').attributes('width')).toBe('80')
})
it.each([
['PNG · JPG · GIF 가 아니면 FILE_001', new File(['x'], '로고.webp', { type: 'image/webp' }), 'FILE_001'],
['확장자와 형식이 다르면 FILE_001', new File(['x'], '로고.gif', { type: 'image/png' }), 'FILE_001'],
['300KB 를 넘으면 FILE_002', png('큰 로고.png', 300 * 1024 + 1), 'FILE_002'],
] as const)('올리기 전에 막는다 — %s (서명 칸 아래에 사전 문구)', async (_label, file, code) => {
const { wrapper, requests } = await mountPage(server())
await choose(wrapper, [file])
await flushPromises()
expect(requests.some((request) => request.url === '/mail-signature/images')).toBe(false)
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES[code])
})
it('3장까지 — 서명 칸에 있는 서로 다른 그림을 센다 (같은 그림 두 번은 한 장, 뺀 그림은 세지 않는다)', async () => {
const SECOND_ID = '00000000-0000-4000-8000-000000000002'
const imageTag = (id: string) => `<img src="/api/mail-signature/images/${id}" width="96" alt="">`
const withTwo: MailSignature = {
...SIGNATURE,
bodyHtml: `<p>${imageTag(IMAGE_ID)}${imageTag(IMAGE_ID)}${imageTag(SECOND_ID)}</p>`,
images: [imageOf(IMAGE_ID, 200), imageOf(SECOND_ID, 200)],
}
const { wrapper, requests } = await mountPage(
server({
signature: withTwo,
uploads: [
ok(imageOf('00000000-0000-4000-8000-000000000003', 200), 201),
ok(imageOf('00000000-0000-4000-8000-000000000004', 200), 201),
],
}),
)
const uploads = () => requests.filter((request) => request.url === '/mail-signature/images')
// 두 장이 있다 — 한 장만 더 올리고 넷째 장은 올리지 않고 막는다
await choose(wrapper, [png('a.png'), png('b.png')])
await flushPromises()
expect(uploads()).toHaveLength(1)
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
// 서명 칸에서 그림을 빼면 다시 넣을 수 있다 — 올려 두기만 한 그림은 세지 않는다 (그것은 서버의 10장 한도)
const editor = editorOf(wrapper.element)
let secondAt = -1
editor.state.doc.descendants((node, pos) => {
if (node.attrs.src === `/api/mail-signature/images/${SECOND_ID}`) secondAt = pos
})
editor.commands.setNodeSelection(secondAt)
await flushPromises()
await wrapper.get('.signature-editor__remove').trigger('click')
await choose(wrapper, [png('c.png')])
await flushPromises()
expect(uploads()).toHaveLength(2)
expect(wrapper.find('#signature-error').exists()).toBe(false)
expect(wrapper.findAll('#signature-body img[src]')).toHaveLength(4)
})
it('서버가 그림을 거절하면(FILE_001 등) 알림 없이 서명 칸 아래에 사전 문구', async () => {
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'FILE_001')] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
expect(useToastStore().toasts).toEqual([])
})
it('올려 둔 그림이 10장을 넘었다는 거절(VAL_001)은 인터셉터의 알림으로 — 서명 칸 아래 문구는 형식 · 크기 거절만', async () => {
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
await choose(wrapper, [png()])
await flushPromises()
expect(wrapper.find('#signature-error').exists()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
})
})
describe('나가기 확인 (디자인 README "2단계 › 서명")', () => {
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
function dialogOpen(wrapper: VueWrapper): boolean {
return wrapper.get('#signature-leave-dialog').attributes('open') !== undefined
}
/** 서명 끝에 한 줄을 더 쓴다 */
function typeLine(wrapper: VueWrapper, text: string) {
const editor = editorOf(wrapper.element)
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
return flushPromises()
}
it('바꾸고 떠나면 묻는다 — alertdialog · 처음 초점은 계속 고치기, [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
const stay = router.push('/mail/inbox')
await flushPromises()
const dialog = wrapper.get('#signature-leave-dialog')
expect(dialogOpen(wrapper)).toBe(true)
expect(dialog.attributes('role')).toBe('alertdialog')
expect(dialog.attributes('aria-labelledby')).toBe('signature-leave-title')
expect(dialog.attributes('aria-describedby')).toBe('signature-leave-message')
expect(wrapper.get('#signature-leave-title').text()).toBe('저장하지 않은 서명이 있습니다')
expect(wrapper.get('#signature-leave-message').text()).toBe('이 화면을 나가면 바꾼 서명이 사라집니다.')
expect(wrapper.get('#signature-leave-stay').text()).toBe('계속 고치기')
expect(wrapper.get('#signature-leave-confirm').text()).toBe('나가기')
expect(document.activeElement?.id).toBe('signature-leave-stay')
await wrapper.get('#signature-leave-stay').trigger('click')
await stay
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/signature')
expect(wrapper.get('#signature-body').text()).toContain('ARA SYSTEM')
const leave = router.push('/mail/inbox')
await flushPromises()
await wrapper.get('#signature-leave-confirm').trigger('click')
await leave
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('불러온(저장한 뒤에는 저장한) 서명과 같으면 묻지 않는다 — 바꿨다 되돌린 것 · 저장한 뒤, 창 닫기 확인도 그동안만', async () => {
const { wrapper, router } = await mountPage(server())
expect(blocksUnload()).toBe(false)
// 넣을 메일을 바꿨다 되돌리면 바꾼 것이 아니다
await wrapper.get('#signature-for-reply').setValue(false)
expect(blocksUnload()).toBe(true)
await wrapper.get('#signature-for-reply').setValue(true)
expect(blocksUnload()).toBe(false)
// 쓰고 저장하면 저장한 서명이 새 기준이다
await typeLine(wrapper, 'ARA SYSTEM')
await wrapper.get('#signature-for-new').setValue(false)
expect(blocksUnload()).toBe(true)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('저장하는 중에는 앱 안 이동을 묻지 않는다 — 창 닫기는 저장이 끝날 때까지 확인한다', async () => {
let finishSave: (() => void) | null = null
const { wrapper, router } = await mountPage(
server({
save: (request) =>
new Promise<MockResult>((resolve) => {
finishSave = () => resolve(ok({ ...(request.data as SaveSignaturePayload), images: [], updatedAt: SAVED_AT }))
}),
}),
)
await typeLine(wrapper, 'ARA SYSTEM')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(blocksUnload()).toBe(true)
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
;(finishSave as (() => void) | null)?.()
await flushPromises()
expect(blocksUnload()).toBe(false)
expect(useMailSignatureStore().signature?.body).toBe('김아라 · 인프라팀\nARA SYSTEM')
})
it('세션이 끝나면 묻지 않는다 — 서명 칸을 내리고 창 닫기 확인도 뗀다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
useAuthStore().expireSession()
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/login')
await flushPromises()
expect(wrapper.find('#signature-leave-dialog').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/login')
})
})
it('미리 보기 — [답장]은 인용 위에 서명, 넣지 않기로 했으면 빼고 안내가 바뀐다', async () => {
const { wrapper } = await mountPage(server())
const replyMode = wrapper.get('[data-mode="reply"]')
await replyMode.trigger('click')
expect(replyMode.attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.')
await wrapper.get('#signature-for-reply').setValue(false)
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.')
})
})
@@ -0,0 +1,122 @@
<script setup lang="ts">
// 설정 › 메일 › 서명 (디자인 원본 Settings · README "2단계 › 서명") — /settings/mail/signature
// 들어올 때마다 서명을 새로 받는다(다른 창에서 고쳤을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
// 서명 칸(TipTap)은 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도" (ComposeEditorLoadError · MAIL_EDITOR_RETRY).
import { defineAsyncComponent, provide, ref } from 'vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
/** 서명 칸 — TipTap 을 이 화면에서만 내려받는다 */
const SignatureForm = defineAsyncComponent({
loader: () => import('@/components/mail/editor/signature/SignatureForm.vue'),
errorComponent: ComposeEditorLoadError,
})
const signatureStore = useMailSignatureStore()
/** 서명 칸을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
const formKey = ref<number>(0)
provide(MAIL_EDITOR_RETRY, () => {
formKey.value += 1
})
function reload() {
void signatureStore.load()
}
// 처음 그리기 전에 — 지난번에 받아 둔 서명으로 칸을 잠깐 그렸다 지우지 않게
reload()
</script>
<template>
<main
class="signature-page"
aria-labelledby="signature-page-title"
>
<div class="signature-page__column">
<div class="signature-page__intro">
<h1
id="signature-page-title"
class="signature-page__title"
>
{{ SIGNATURE_TEXT.title }}
</h1>
<p class="signature-page__description">
{{ SIGNATURE_TEXT.description }}
</p>
</div>
<MailLoadError
v-if="signatureStore.status === 'error' && signatureStore.error"
:error="signatureStore.error"
leave-label=""
@retry="reload"
/>
<SignatureForm
v-else-if="signatureStore.status === 'ready' && signatureStore.signature"
:key="formKey"
:signature="signatureStore.signature"
/>
<div
v-else
class="signature-page__placeholder"
aria-busy="true"
/>
</div>
</main>
</template>
<style scoped>
.signature-page {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
}
/* 콘텐츠 열 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (읽기 · 쓰기와 같은 값) */
.signature-page__column {
max-width: var(--content-max-width);
display: flex;
flex-direction: column;
gap: var(--space-22);
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
.signature-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.signature-page__title {
margin: 0;
font-size: var(--font-size-title-lg);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.signature-page__description {
margin: 0;
color: var(--color-text-secondary);
}
/* 서명을 받는 동안의 자리 — 서명 칸(툴바 52 + 편집 132)만큼 */
.signature-page__placeholder {
min-height: calc(var(--size-editor-toolbar) + 8.25rem);
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
@media (min-width: 70rem) {
.signature-page {
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,93 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
</script>
<template>
<AppShell module-id="settings">
<nav
class="settings-menu"
aria-label="설정 메뉴"
>
<p
id="settings-menu-mail"
class="settings-menu__group"
>
메일
</p>
<ul
class="settings-menu__list"
aria-labelledby="settings-menu-mail"
>
<li>
<RouterLink
:to="{ name: 'settings-mail-signature' }"
class="settings-menu__link"
active-class="is-active"
>
<AppIcon
name="edit"
:size="16"
/>
<span>서명</span>
</RouterLink>
</li>
</ul>
</nav>
<RouterView />
</AppShell>
</template>
<style scoped>
/* 왼쪽 패널 — 메일함 패널과 같은 바탕 · 간격 */
.settings-menu {
flex: 1 1 13rem;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-14) var(--space-10);
border-right: var(--border-width) solid var(--color-border);
background: var(--color-surface-panel);
}
.settings-menu__group {
margin: var(--space-4) var(--space-10);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.settings-menu__list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.settings-menu__link {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-row-min);
padding: 0 var(--space-10);
border-radius: var(--radius-item);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.settings-menu__link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.settings-menu__link.is-active {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
}
</style>
+15
View File
@@ -48,6 +48,21 @@ const routes: RouteRecordRaw[] = [
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
},
{
// 설정 — 왼쪽 메뉴(메일 › 서명) + 하위 화면. /settings 는 첫 메뉴로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
meta: { requiresAuth: true, title: '설정' },
children: [
{ path: '', name: 'settings', redirect: { name: 'settings-mail-signature' } },
{
path: 'mail/signature',
name: 'settings-mail-signature',
component: () => import('@/pages/settings/MailSignaturePage.vue'),
meta: { title: '메일 서명' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
+1 -1
View File
@@ -26,7 +26,7 @@ export const APP_MODULES: readonly AppModule[] = [
{ id: 'files', label: '파일', icon: 'folder', routeName: null, placement: 'main' },
{ id: 'erp', label: 'ERP', icon: 'briefcase', routeName: null, placement: 'main' },
{ id: 'contacts', label: '주소록', icon: 'users', routeName: null, placement: 'main' },
{ id: 'settings', label: '설정', icon: 'settings', routeName: null, placement: 'end' },
{ id: 'settings', label: '설정', icon: 'settings', routeName: 'settings', placement: 'end' },
]
/** 비활성 모듈에 붙이는 안내 (툴팁·스크린리더) */
+6
View File
@@ -117,6 +117,12 @@ export const ICONS = {
forward: [polyline('15 14 20 9 15 4'), path('M4 20v-7a4 4 0 0 1 4-4h12')],
shield: [path('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z')],
'alert-circle': [circle(12, 12, 10), ...ALERT_DOT],
/** 안내 — 임시 저장 "다른 곳에서 바뀐 저장본" (디자인 원본 ComposeAssist) */
info: [circle(12, 12, 10), line(12, 16, 12, 12), line(12, 8, 12.01, 8)],
/** 진행 중 — 임시 저장 "저장하는 중…" (원의 3/4) */
loader: [path('M21 12a9 9 0 1 1-6.22-8.56')],
/** 그림 넣기 — 서명 칸 툴바 (디자인 원본 Settings) */
image: [rect(3, 3, 18, 18, 2), circle(8.5, 8.5, 1.5), polyline('21 15 16 10 5 21')],
x: [line(18, 6, 6, 18), line(6, 6, 18, 18)],
download: [
path('M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'),
@@ -0,0 +1,101 @@
// 메일 서명 상수 — 계약 2절 "서명" · 디자인 README "2단계 › 서명" (디자인 원본 Settings)
// 한도는 서버(backend/src/modules/mail-signature/mail-signature.constants.ts)와 같은 값이다 — 서버가 최종 검증한다.
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { SignatureImageType } from '@/shared/types/mail'
/** 서명 그림 한 장 최대 크기 — 300KB (서버 SIGNATURE_IMAGE_MAX_BYTES = 300 × 1024, 넘으면 413 FILE_002) */
export const SIGNATURE_IMAGE_MAX_BYTES = 300 * 1024
/**
* 서명에 넣을 수 있는 그림 수 — 서명 칸에 있는 서로 다른 그림으로 센다(뺀 그림은 세지 않는다).
* 넘으면 서명 저장이 400 VAL_001 — 클라이언트가 그림을 넣기 전에 먼저 막는다.
* (올려 두기만 한 그림까지 한 사람 10장은 서버가 따로 막는다 — 올리기가 400 VAL_001, 알림은 인터셉터)
*/
export const SIGNATURE_IMAGE_MAX_COUNT = 3
/** 서명 서식(HTML) 최대 길이 — 서버는 정화한 뒤 잰다(넘으면 400 VAL_001). 보내기 전에 같은 길이로 먼저 막는다 */
export const SIGNATURE_HTML_MAX_LENGTH = 10_000
/** 텍스트 서명 최대 길이 (JS 문자열 길이) */
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000
/** 받는 그림 형식 — 확장자와 MIME 이 이 짝이어야 한다 (서버는 파일 앞부분까지 본다. WebP · SVG 는 받지 않는다) */
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: Readonly<Record<string, SignatureImageType>> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
}
/** <input type="file" accept> 값 */
export const SIGNATURE_IMAGE_ACCEPT = Object.keys(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
.map((extension) => `.${extension}`)
.join(',')
/**
* 그림 크기 줄 (디자인 원본 Settings) — 작게 96 · 보통 144 · 원본(원래 폭, 600 이하). 새로 넣는 그림은 보통(원래 폭이 더 작으면 원래 폭).
* 원본 상한은 서버가 남기는 width(1~600)와 같다 (outgoing-html-rules.ts 의 IMAGE_MAX_WIDTH).
*/
export const SIGNATURE_IMAGE_SIZES = {
small: 96,
medium: 144,
} as const
export type SignatureImageSize = keyof typeof SIGNATURE_IMAGE_SIZES | 'original'
/** 그림 올리기의 실패 중 서명 칸 아래에 직접 보이는 코드 (형식 · 크기) — 전역 알림은 끈다. 10장 초과(VAL_001)는 알림으로 */
export const SIGNATURE_IMAGE_INLINE_CODES: readonly ErrorCode[] = ['FILE_001', 'FILE_002']
/** 서명 저장의 실패 중 서명 칸 아래에 직접 보이는 코드 (길이 · 그림 3장 초과) — 전역 알림은 끈다 */
export const SIGNATURE_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
/** 서명을 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) */
export const SIGNATURE_LEAVE_DIALOG = {
title: '저장하지 않은 서명이 있습니다',
message: '이 화면을 나가면 바꾼 서명이 사라집니다.',
stay: '계속 고치기',
leave: '나가기',
} as const
/** 설정 화면 문구 (디자인 원본 Settings) */
export const SIGNATURE_TEXT = {
title: '서명',
description:
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
fieldLabel: '서명 내용',
hint:
'굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
usageLegend: '서명을 넣을 메일',
forNew: '새 메일',
forReply: '답장 · 전달',
forReplyNote: '— 인용한 원문 위에 넣습니다',
save: '저장',
saving: '저장하는 중…',
saved: '저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.',
uploading: '그림을 올리는 중…',
imageSizeLabel: '그림 크기',
imageSizes: { small: '작게', medium: '보통', original: '원본' },
removeImage: '그림 빼기',
previewTitle: '미리 보기',
previewSwitchLabel: '미리 볼 메일',
previewNew: '새 메일',
previewReply: '답장',
previewBody: '(여기에 쓴 내용)',
} as const
/** 미리 보기 아래 안내 (디자인 원본 Settings) */
export const SIGNATURE_PREVIEW_NOTES = {
newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
newOff: '새 메일에는 서명을 넣지 않습니다.',
replyOn: '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.',
replyOff: '답장 · 전달에는 서명을 넣지 않습니다.',
} as const
/**
* 답장 미리 보기의 원문 인용 — 실제 메일이 아니라 자리만 보인다 (캔버스의 예시 이름 대신 괄호 글 — 2026-10-27 구현하며 정함).
* 머리 줄 이름은 쓰기 화면의 원문 인용과 같다 (mail-quote.ts).
*/
export const SIGNATURE_PREVIEW_QUOTE = {
rows: [
{ label: '보낸 사람', value: '(보낸 사람)' },
{ label: '날짜', value: '(보낸 날짜)' },
{ label: '제목', value: '(원래 제목)' },
],
body: '(원래 메일 내용)',
} as const
+51 -4
View File
@@ -1,5 +1,6 @@
// 메일 화면·검증 상수 — 한도 값은 docs/mail/api-contract.md 와 같다 (서버가 최종 검증한다)
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { IconName } from '@/shared/constants/icons'
import type { MailboxRole } from '@/shared/types/mail'
@@ -132,17 +133,63 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
/** 임시 저장본을 불러오는 동안의 쓰기 화면 제목 — 불러오면 답장 · 전달 · 새 메일로 바뀐다 */
export const COMPOSE_LOADING_TITLE = '메일 쓰기'
/**
* 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
* [계속 쓰기]가 처음 초점이고 Esc 와 같다.
* 쓰기 화면 나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인") — 떠나기 전에 남은 변경을 먼저 저장하고,
* 저장하지 못한 내용이 있을 때만 묻는다. [계속 쓰기]가 처음 초점이고 Esc 와 같다.
*/
export const COMPOSE_LEAVE_DIALOG = {
title: '보내지 않은 메일이 있습니다',
message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
title: '저장하지 못한 내용이 있습니다',
message: '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.',
stay: '계속 쓰기',
leave: '나가기',
} as const
// ── 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장") ─────────────
/** 입력이 이만큼 멈추면 자동 저장한다 */
export const DRAFT_IDLE_SAVE_MS = 3_000
/** 자동 저장 사이의 최소 간격 — 10초에 한 번까지 */
export const DRAFT_MIN_SAVE_INTERVAL_MS = 10_000
/**
* 쉬지 않고 쓰는 동안에도 저장한다 — 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 이만큼 지나면
* 입력이 멈추기를 기다리지 않는다 (몇 분을 쉬지 않고 쓰다 브라우저가 죽어도 잃는 것이 이만큼이다). 10초 최소 간격은 그대로다
*/
export const DRAFT_MAX_WAIT_MS = 30_000
/**
* 떠나기 전 저장(진행 중인 저장 기다리기 + 남은 변경 저장)을 기다리는 시간의 상한 — 넘으면 "저장하지 못한 내용이 있습니다"를 묻는다.
* 큰 첨부가 든 저장은 몇십 초씩 걸릴 수 있어 그동안 이동이 멈춘 것처럼 보이지 않게 한다. 저장은 뒤에서 이어진다
*/
export const DRAFT_LEAVE_SAVE_WAIT_MS = 5_000
/**
* 자동 저장이 실패했을 때 다시 해 보기까지 — 실패할수록 길게, 마지막 값에서 멈춘다.
* 다시 해 보면 나아질 수 있는 실패(DRAFT_RETRY_CODES)에만 쓴다 — 그 밖은 내용을 바꾸면 다시 저장한다
*/
export const DRAFT_RETRY_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000]
/** 저절로 다시 해 볼 실패 — 메일 서버 장애 · 메일함 용량 · 요청 한도 · 네트워크 */
export const DRAFT_RETRY_CODES: readonly ErrorCode[] = ['MAIL_001', 'MAIL_003', 'REQ_001', 'SYS_001']
/** 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist) — 시각은 formatDraftSavedTime */
export const DRAFT_STATUS_TEXT = {
saving: '저장하는 중…',
saved: (time: string) => `${time}에 임시 저장됨`,
failed: '임시 저장하지 못했습니다',
retry: '다시 시도',
replaced: (time: string) => `다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · ${time}`,
} as const
/** 쓰기 화면 머리의 휴지통 단추 · 버린 뒤 알림 (디자인 원본 ComposeAssist "임시 저장본 삭제") */
export const DRAFT_DISCARD_LABEL = '이 메일 삭제'
export const DRAFT_DISCARDED_MESSAGE = '임시 저장한 메일을 휴지통으로 옮겼습니다.'
export const DRAFT_UNDO_LABEL = '되돌리기'
/** 되돌리기가 끝났을 때 — 캔버스에는 없다 (2026-10-27 구현하며 정함 — 되돌린 것을 알 수 있게) */
export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.'
/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
+102 -1
View File
@@ -45,6 +45,15 @@ export interface Attachment {
size: number
}
/** 메일 상세의 첨부 — 본문 안에 그림으로 들어간 것인지 함께 온다 (계약 2절 "MessageAttachment") */
export interface MessageAttachment extends Attachment {
/**
* 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다.
* 전달할 때는 원문 그림을 잃지 않게 함께 붙인다 (인용 본문에서는 그림이 빠진다). 필드가 없으면(이전 서버) false 로 본다
*/
isInline?: boolean
}
export interface MessageDetail extends MessageSummary {
cc: Address[]
bcc: Address[]
@@ -68,7 +77,8 @@ export interface MessageDetail extends MessageSummary {
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
*/
isBodyTruncated?: boolean
attachments: Attachment[]
/** 본문 그림(isInline)도 함께 온다 — 읽기 화면은 listedAttachments 로 거른다 */
attachments: MessageAttachment[]
}
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
@@ -117,9 +127,100 @@ export interface SendMessagePayload {
attachments: AttachmentRef[]
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
inReplyTo?: string
/** 이어 쓴 임시 저장본 id — 보낸 뒤 서버가 그 초안을 지운다 (계약 2절 "임시 저장") */
draftId?: string
}
/** POST /messages 응답 — 보낸편지함에 저장된 메일 id */
export interface SendMessageResult {
id: string
}
// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
/** GET /drafts/{id} — 이어 쓸 임시 저장본 */
export interface Draft {
/** 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다 */
id: string
to: Address[]
cc: Address[]
bcc: Address[]
subject: string
/** 텍스트 대체 본문 */
body: string
/** 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null) */
bodyHtml: string | null
/** 이 판의 첨부 — id 가 저장할 때마다 바뀐다 */
attachments: Attachment[]
/** 답장할 메일 id (없거나 원 메일이 사라졌으면 null) */
inReplyTo: string | null
/** 마지막으로 저장한 시각 */
updatedAt: string
}
/** POST /drafts · PUT /drafts/{id} 본문 — 발송 본문과 같은 필드 · 한도 (받는 사람 0명 가능, draftId 없음) */
export type DraftPayload = Omit<SendMessagePayload, 'draftId'>
/** POST /drafts · PUT /drafts/{id} 응답 — 새 id 와 그 판의 첨부 */
export interface DraftSaved {
id: string
attachments: Attachment[]
updatedAt: string
}
// ── 서명 (계약 2절 "서명") ─────────────────────────────
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
export interface SignatureImage {
id: string
type: SignatureImageType
/** 바이트 */
size: number
/** 원본 픽셀 */
width: number
height: number
}
/** GET · PUT /mail-signature — 사용자마다 하나 */
export interface MailSignature {
/** 서명 서식 — 서식 본문 규칙 + 서명 그림 */
bodyHtml: string | null
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) */
body: string | null
/** 새 메일에 넣는다 */
isForNew: boolean
/** 답장 · 전달에 넣는다 — 인용 위 */
isForReply: boolean
/** 서명이 쓰는 그림 */
images: SignatureImage[]
/** 저장한 적 없으면 null */
updatedAt: string | null
}
/** PUT /mail-signature 본문 */
export interface SaveSignaturePayload {
bodyHtml: string | null
body: string | null
isForNew: boolean
isForReply: boolean
}
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ────────────────
/** directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소 */
export type RecipientSuggestionSource = 'directory' | 'recent'
export interface RecipientSuggestion {
name: string | null
email: string
source: RecipientSuggestionSource
}
/** GET /recipient-suggestions 쿼리 */
export interface RecipientSuggestionQuery {
/** 1~100자 — 이름 · 주소에 이 글자가 든 것만 */
q?: string
limit?: number
cursor?: string
}
+4 -3
View File
@@ -1,5 +1,6 @@
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "2단계 › 임시 저장")
// 마지막으로 임시 저장한 양식(저장한 적 없으면 쓰기 화면을 다 연 때 — 답장 · 전달 초안을 채운 뒤)을 찍어 두고,
// 지금 양식과 다르면 자동 저장하고 떠나기 전에 저장한다 (저장하지 못하면 묻는다).
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
import type { RecipientToken } from '@/shared/utils/mail-address'
@@ -10,7 +11,7 @@ export interface ComposeFormSnapshot {
cc: readonly string[]
bcc: readonly string[]
subject: string
/** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
/** 첨부 목록 항목의 키 — 쓰기 화면은 저장본에 싣는 첨부(올리기를 마친 것 · 원본 첨부)만 넘긴다 */
attachments: readonly number[]
}
+14
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatDraftSavedTime,
formatFileSize,
formatMailDateTime,
formatMailListTime,
@@ -19,6 +20,19 @@ const SEOUL = 'Asia/Seoul'
/** 2026-10-26(월) 오후 3시 (서울) */
const NOW = new Date('2026-10-26T06:00:00Z')
describe('formatDraftSavedTime (임시 저장 상태 글)', () => {
it('오늘은 시각만 — "오후 3:12"', () => {
expect(formatDraftSavedTime('2026-10-26T06:12:00Z', NOW, SEOUL)).toBe('오후 3:12')
})
it('어제 · 올해 · 그 이전은 날짜를 붙인다 (이어 쓰는 저장본은 다른 날 저장했을 수 있다)', () => {
expect(formatDraftSavedTime('2026-10-25T01:00:00Z', NOW, SEOUL)).toBe('어제 오전 10:00')
expect(formatDraftSavedTime('2026-10-24T05:30:00Z', NOW, SEOUL)).toBe('10월 24일 오후 2:30')
expect(formatDraftSavedTime('2025-10-24T05:30:00Z', NOW, SEOUL)).toBe('2025. 10. 24. 오후 2:30')
expect(formatDraftSavedTime('not-a-date', NOW, SEOUL)).toBe('')
})
})
describe('formatMailListTime', () => {
it('오늘 받은 메일은 시각만 보인다', () => {
expect(formatMailListTime('2026-10-26T01:42:00Z', NOW, SEOUL)).toBe('오전 10:42')
+24
View File
@@ -186,6 +186,30 @@ export function formatMailTableDate(value: string, now: Date = new Date(), timeZ
return target.year === today.year ? monthDay : `${target.year}.${monthDay}`
}
/**
* 임시 저장 시각 — 쓰기 화면 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist: "오후 3:12에 임시 저장됨")
* - 오늘: "오후 3:12"
* - 어제: "어제 오후 3:12"
* - 올해: "10월 24일 오후 3:12"
* - 그 이전: "2025. 10. 24. 오후 3:12"
* 이어 쓰는 저장본은 다른 날 저장했을 수 있어 날짜를 붙인다.
*/
export function formatDraftSavedTime(value: string, now: Date = new Date(), timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
const target = calendarDayOf(date, timeZone)
const today = calendarDayOf(now, timeZone)
if (isSameDay(target, today)) return time
if (isSameDay(target, previousDay(today))) return `어제 ${time}`
const dayOptions: Intl.DateTimeFormatOptions =
target.year === today.year
? { timeZone, month: 'long', day: 'numeric' }
: { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }
return `${new Intl.DateTimeFormat('ko-KR', dayOptions).format(date)} ${time}`
}
/** 메일 본문 머리의 날짜 (디자인 원본: "2026. 10. 26. (월) 오전 10:42") */
export function formatMailDateTime(value: string, timeZone?: string): string {
const date = parseDate(value)
@@ -45,3 +45,14 @@ export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText
const rest = keys.filter((key) => !APPLE_MODIFIERS.some(([modifier]) => modifier === key))
return { label: [...modifiers, ...rest].join(''), aria }
}
/** 입력기가 처리 중인 키의 keyCode — Safari · macOS 한글 입력은 조합을 끝내는 Enter 를 isComposing 없이 이 값으로 보낸다 */
const IME_PROCESS_KEY_CODE = 229
/**
* 한글 등 입력기가 글자를 조합하는 중의 키인가 — 이때의 Enter · Tab 은 글자 확정이다 (토큰 · 추천 · 제출로 쓰지 않는다).
* isComposing 만 보면 Safari · macOS 에서 조합을 끝내는 Enter 가 새어 든다 (keyCode 229 로 온다)
*/
export function isImeComposing(event: KeyboardEvent): boolean {
return event.isComposing || event.keyCode === IME_PROCESS_KEY_CODE
}
@@ -58,6 +58,11 @@ export function summarizeRecipients(list: readonly Address[]): string {
return rest.length > 0 ? `${displayName(first)} 외 ${rest.length}명` : displayName(first)
}
/** 임시보관함 목록의 받는 사람 — 이름(없으면 주소)을 쉼표로 잇는다 ("박준호, 인프라팀"). 없으면 빈 문자열 */
export function draftRecipientNames(list: readonly Address[]): string {
return list.map(displayName).filter((name) => name !== '').join(', ')
}
/** 받는 사람 칸의 토큰 하나 */
export interface RecipientToken {
/** 화면에 보이는 글자 — 입력한 그대로 */
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import { BYTES_PER_MB, MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
import type { MessageAttachment } from '@/shared/types/mail'
import {
attachmentHint,
extensionOf,
isAllowedAttachmentName,
listedAttachments,
precheckAttachment,
type AttachedSummary,
type AttachmentLimits,
@@ -18,6 +20,18 @@ const FORWARDED: AttachmentLimits = { maxBytes: null, totalMaxBytes: TOTAL, maxC
const NONE: AttachedSummary = { count: 0, bytes: 0 }
const bytes = (value: number): AttachedSummary => ({ count: 1, bytes: value })
describe('listedAttachments — 읽기 화면의 첨부 목록', () => {
it('본문 안에 그림으로 보인 첨부(isInline)만 뺀다 — 표시 값이 없으면 첨부로 본다', () => {
const attachments: MessageAttachment[] = [
{ id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false },
{ id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true },
// 표시 값을 주지 않는(이전) 서버
{ id: 'a3', name: '자료.zip', type: 'application/zip', size: 4096 },
]
expect(listedAttachments(attachments).map((attachment) => attachment.id)).toEqual(['a1', 'a3'])
})
})
describe('precheckAttachment', () => {
it('허용하지 않는 확장자는 FILE_001', () => {
expect(precheckAttachment({ name: '설치.exe', size: 10 }, UPLOAD, NONE)).toBe('FILE_001')
@@ -6,6 +6,15 @@ import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_ATTACHMENT_EXTENSIONS, MAIL_ATTACHMENT_KINDS_LABEL } from '@/shared/constants/mail'
import { formatMegabytes } from '@/shared/utils/format'
/**
* 읽기 화면의 첨부 목록에 보일 첨부 — 본문 안에 그림으로 들어간 것(isInline)은 뺀다 (계약 2절 "MessageAttachment").
* 서명 그림 · 받은 메일의 본문 그림은 본문에 보이므로 첨부가 아니다. 필드가 없으면(이전 서버) 첨부로 본다.
* 전달은 이것을 쓰지 않는다 — 인용에서 그림이 빠지므로 원문 그림까지 모두 붙인다 (mail-forward.ts).
*/
export function listedAttachments<T extends { isInline?: boolean }>(attachments: readonly T[]): T[] {
return attachments.filter((attachment) => attachment.isInline !== true)
}
export function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
+18 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
import { buildForwardDraft, buildForwardSubject, isForwardSubject } from './mail-forward'
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
@@ -46,6 +46,23 @@ describe('buildForwardSubject', () => {
it('제목이 없어도 접두어만 남는다', () => {
expect(buildForwardSubject('')).toBe('Fwd:')
})
it('isForwardSubject — 이어 쓰는 저장본의 쓰기 화면 제목(전달)을 정한다', () => {
expect(isForwardSubject('Fwd: 일정')).toBe(true)
expect(isForwardSubject('전달: 일정')).toBe(true)
expect(isForwardSubject('Re: 일정')).toBe(false)
expect(isForwardSubject('일정 Fwd:')).toBe(false)
})
})
describe('buildForwardDraft — 본문 그림(isInline)', () => {
it('본문에 보인 그림도 원본 첨부로 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게', () => {
const report = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false }
const logo = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true }
const original = message({ attachments: [report, logo] })
expect(buildForwardDraft(original).attachments.map((attachment) => attachment.id)).toEqual(['a1', 'a2'])
})
})
describe('buildForwardDraft', () => {
+17 -5
View File
@@ -9,26 +9,38 @@ import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/uti
export interface ForwardDraft {
subject: string
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + (서명) + 원문 인용 */
html: string
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
/**
* 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다.
* 본문 그림(isInline)도 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게 (계약 2절 "MessageAttachment")
*/
attachments: Attachment[]
}
/** 이미 전달 접두어가 있으면 그대로 둔다 ("Fwd:" · "FW:" · "전달:") */
const FORWARD_PREFIX_PATTERN = /^\s*(?:fwd?|전달)\s*:/i
/** 전달 제목인가 — "Fwd:" · "FW:" · "전달:" 로 시작한다 (이어 쓰는 임시 저장본의 쓰기 화면 제목을 정할 때) */
export function isForwardSubject(subject: string): boolean {
return FORWARD_PREFIX_PATTERN.test(subject)
}
export function buildForwardSubject(subject: string): string {
const trimmed = subject.trim()
if (FORWARD_PREFIX_PATTERN.test(trimmed)) return trimmed
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
}
/** 전달 초안 — 제목은 255자에 맞춘다 */
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
/** 전달 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
export function buildForwardDraft(
message: MessageDetail,
timeZone?: string,
signatureHtml: string | null = null,
): ForwardDraft {
return {
subject: fitDraftSubject(buildForwardSubject(message.subject)),
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
attachments: [...message.attachments],
}
}
@@ -190,4 +190,10 @@ describe('buildQuoteHtml', () => {
'<p>원문</p></blockquote>',
)
})
it('서명은 인용 위 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명")', () => {
expect(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', '<p>김아라</p>')).toMatch(
/^<p><\/p><p><\/p><p>김아라<\/p><blockquote data-mail-quote="">/,
)
})
})
+7 -2
View File
@@ -135,13 +135,18 @@ export function forwardQuoteRows(message: HeaderSource, timeZone?: string): Quot
return rows
}
/** 내가 쓸 빈 문단 + "원본 메일" 머리 + 원문을 담은 인용 */
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string): string {
/**
* 내가 쓸 빈 문단 + (서명) + "원본 메일" 머리 + 원문을 담은 인용.
* 서명이 있으면 인용 위에 둔다 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명").
* signatureHtml 은 서버가 정화해 준 서명 HTML 이다 (편집기가 자기 스키마로 다시 읽는다).
*/
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string, signatureHtml: string | null = null): string {
const header = rows
.map((row) => `<tr><td>${escapeHtml(row.label)}</td><td>${escapeHtml(row.value)}</td></tr>`)
.join('')
return (
'<p></p>' +
(signatureHtml ? `<p></p>${signatureHtml}` : '') +
'<blockquote data-mail-quote="">' +
`<p data-quote-header=""><strong>${escapeHtml(MAIL_QUOTE_HEADING)}</strong></p>` +
`<table data-quote-header=""><tbody>${header}</tbody></table>` +
@@ -151,6 +151,12 @@ describe('buildReplyDraft', () => {
)
})
it('서명을 주면 인용 위에 넣는다', () => {
const { html } = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul', '<p>김아라 · 인프라팀</p>')
expect(html.startsWith('<p></p><p></p><p>김아라 · 인프라팀</p><blockquote data-mail-quote="">')).toBe(true)
})
it('답장 머리에는 받는 사람 · 참조가 없다 (전달에만)', () => {
const { html } = buildReplyDraft(message({ cc: [INFRA] }), ME, 'reply', 'Asia/Seoul')
+4 -3
View File
@@ -14,7 +14,7 @@ export interface ReplyDraft {
to: Address[]
cc: Address[]
subject: string
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + 원문 인용 */
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + (서명) + 원문 인용 */
html: string
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
inReplyTo: string
@@ -83,19 +83,20 @@ export function buildReplyRecipients(
return { to, cc }
}
/** 답장 초안 — 제목은 255자에 맞춘다 */
/** 답장 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
export function buildReplyDraft(
message: MessageDetail,
myEmail: string,
mode: ReplyMode,
timeZone?: string,
signatureHtml: string | null = null,
): ReplyDraft {
const { to, cc } = buildReplyRecipients(message, myEmail, mode)
return {
to,
cc,
subject: fitDraftSubject(buildReplySubject(message.subject)),
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message)),
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
inReplyTo: message.id,
}
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import type { MailSignature } from '@/shared/types/mail'
import {
imageSizeOf,
imageWidthFor,
insertedImageWidth,
newMailHtmlWithSignature,
originalImageWidth,
precheckSignatureImage,
signatureHtmlFor,
signatureImageUploadName,
} from './mail-signature'
const BASE: MailSignature = {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
}
describe('signatureHtmlFor — 쓰기 화면에 넣을 서명', () => {
it('넣기로 한 쓰기 종류에만, 서식 서명을 먼저', () => {
expect(signatureHtmlFor(BASE, 'new')).toBe('<p>김아라</p>')
expect(signatureHtmlFor({ ...BASE, isForNew: false }, 'new')).toBeNull()
expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'reply')).toBeNull()
expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'new')).toBe('<p>김아라</p>')
})
it('서식 서명이 없으면 텍스트 서명을 문단으로, 둘 다 비었으면 넣지 않는다', () => {
expect(signatureHtmlFor({ ...BASE, bodyHtml: null, body: '김아라\n인프라팀\n' }, 'new')).toBe('<p>김아라</p><p>인프라팀</p>')
expect(signatureHtmlFor({ ...BASE, bodyHtml: ' ', body: ' ' }, 'new')).toBeNull()
expect(signatureHtmlFor(null, 'new')).toBeNull()
})
it('새 메일은 쓸 줄 · 빈 줄 하나 뒤에', () => {
expect(newMailHtmlWithSignature('<p>김아라</p>')).toBe('<p></p><p></p><p>김아라</p>')
})
})
describe('precheckSignatureImage — PNG · JPG · GIF, 300KB, 3장', () => {
it.each([
[{ name: '로고.png', type: 'image/png', size: 1000 }, 0, null],
[{ name: '로고.JPG', type: 'image/jpeg', size: 1000 }, 2, null],
[{ name: '로고.jpeg', type: 'image/jpeg', size: 300 * 1024 }, 0, null],
[{ name: '로고.gif', type: 'image/gif', size: 1 }, 0, null],
// 붙여 넣은 그림은 이름이 없을 수 있다 — 형식만 본다
[{ name: '', type: 'image/png', size: 1 }, 0, null],
[{ name: '로고.webp', type: 'image/webp', size: 1 }, 0, 'FILE_001'],
[{ name: '로고.svg', type: 'image/svg+xml', size: 1 }, 0, 'FILE_001'],
[{ name: '로고.png', type: 'image/gif', size: 1 }, 0, 'FILE_001'],
[{ name: '로고.png', type: '', size: 1 }, 0, 'FILE_001'],
[{ name: '로고.png', type: 'image/png', size: 300 * 1024 + 1 }, 0, 'FILE_002'],
[{ name: '로고.png', type: 'image/png', size: 1 }, 3, 'VAL_001'],
] as const)('%o · 이미 %i장 → %s', (file, occupied, expected) => {
expect(precheckSignatureImage(file, occupied)).toBe(expected)
})
it('올릴 이름 — 확장자가 없으면 형식에 맞는 확장자를 붙인다', () => {
expect(signatureImageUploadName({ name: '로고.png', type: 'image/png', size: 1 })).toBe('로고.png')
expect(signatureImageUploadName({ name: '', type: 'image/jpeg', size: 1 })).toBe('서명 그림.jpg')
expect(signatureImageUploadName({ name: 'logo', type: 'image/gif', size: 1 })).toBe('logo.gif')
})
})
describe('그림 크기 — 작게 96 · 보통 144 · 원본(600 이하)', () => {
it('넣을 때는 보통, 원래 폭이 더 작으면 원래 폭', () => {
expect(insertedImageWidth(480)).toBe(144)
expect(insertedImageWidth(80)).toBe(80)
})
it('원본은 원래 폭, 600 을 넘으면 600', () => {
expect(originalImageWidth(1200)).toBe(600)
expect(originalImageWidth(320)).toBe(320)
expect(imageWidthFor('original', 800)).toBe(600)
expect(imageWidthFor('small', 800)).toBe(96)
})
it('지금 폭이 어느 칸인가 — 작게 · 보통 · 원본 순, 맞는 칸이 없으면 null', () => {
expect(imageSizeOf(96, 480)).toBe('small')
expect(imageSizeOf(144, 144)).toBe('medium')
expect(imageSizeOf(480, 480)).toBe('original')
expect(imageSizeOf(600, 1200)).toBe('original')
expect(imageSizeOf(200, 480)).toBeNull()
expect(imageSizeOf(null, 480)).toBe('original')
})
})
+102
View File
@@ -0,0 +1,102 @@
// 메일 서명 — 순수 함수 (계약 2절 "서명" · 디자인 README "2단계 › 서명")
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import {
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_MAX_COUNT,
SIGNATURE_IMAGE_SIZES,
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
type SignatureImageSize,
} from '@/shared/constants/mail-signature'
import type { MailSignature } from '@/shared/types/mail'
import { extensionOf } from '@/shared/utils/mail-attachment'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { IMAGE_MAX_WIDTH } from '@/shared/utils/outgoing-html-rules'
export type SignatureUse = 'new' | 'reply'
/** 받는 그림 MIME 형식 */
const SIGNATURE_IMAGE_TYPES: readonly string[] = Object.values(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
/**
* 쓰기 화면에 넣을 서명 HTML — 그 쓰기 종류에 넣기로 했고(isForNew · isForReply) 내용이 있을 때만.
* 서식 서명(bodyHtml)이 있으면 그것, 없으면 텍스트 서명(body)을 문단으로. 둘 다 비었으면 null.
*/
export function signatureHtmlFor(signature: MailSignature | null, use: SignatureUse): string | null {
if (!signature) return null
const enabled = use === 'new' ? signature.isForNew : signature.isForReply
if (!enabled) return null
const html = signature.bodyHtml?.trim() ?? ''
if (html !== '') return html
const text = signature.body?.replace(/\s+$/, '') ?? ''
return text.trim() === '' ? null : textToParagraphHtml(text)
}
/** 새 메일 본문 — 쓸 줄 · 빈 줄 하나 · 서명 (서명은 본문 끝, 빈 줄 하나 뒤) */
export function newMailHtmlWithSignature(signatureHtml: string): string {
return `<p></p><p></p>${signatureHtml}`
}
/** 그림 파일 사전 검사에 쓰는 모양 — File 과 같은 이름 · 형식 · 크기 */
export interface SignatureImageCandidate {
name: string
type: string
size: number
}
/**
* 서명 그림 사전 검사 — 걸리면 그 에러 코드 (문구는 사전에서), 통과하면 null. 서버도 같은 규칙으로 거절한다.
* - 형식: 확장자와 MIME 이 PNG · JPG · GIF 짝 (FILE_001)
* - 크기: 300KB 이하 (FILE_002)
* - 개수: 서명 칸에 이미 있는 서로 다른 그림 + 올리는 중인 그림(occupied) + 이 그림이 3장 이하 (VAL_001 — 서명 저장의 한도)
* 붙여 넣은 그림은 이름이 없을 수 있다 — 그때는 MIME 만 본다 (올릴 때 이름을 붙인다)
*/
export function precheckSignatureImage(file: SignatureImageCandidate, occupied: number): ErrorCode | null {
const extension = extensionOf(file.name)
const byExtension = extension === '' ? null : (SIGNATURE_IMAGE_TYPES_BY_EXTENSION[extension] ?? null)
if (!SIGNATURE_IMAGE_TYPES.includes(file.type) || (extension !== '' && byExtension !== file.type)) {
return 'FILE_001'
}
if (file.size > SIGNATURE_IMAGE_MAX_BYTES) return 'FILE_002'
if (occupied >= SIGNATURE_IMAGE_MAX_COUNT) return 'VAL_001'
return null
}
/** 이름 없이 붙여 넣은 그림의 이름 */
const SIGNATURE_IMAGE_DEFAULT_NAME = '서명 그림'
/**
* 올릴 때의 파일 이름 — 서버는 확장자와 MIME 이 맞아야 받는다. 확장자가 없으면(붙여 넣은 그림 등) 형식에 맞는 확장자를 붙인다.
* 사전 검사(precheckSignatureImage)를 통과한 파일에만 쓴다 — 확장자가 있으면 이미 형식과 맞다.
*/
export function signatureImageUploadName(file: SignatureImageCandidate): string {
if (extensionOf(file.name) !== '') return file.name
const extension =
Object.entries(SIGNATURE_IMAGE_TYPES_BY_EXTENSION).find(([, type]) => type === file.type)?.[0] ?? 'png'
return `${file.name.trim() || SIGNATURE_IMAGE_DEFAULT_NAME}.${extension}`
}
/** 원본 폭 — 그림의 원래 폭, 600px 이하 (서버가 남기는 width 상한) */
export function originalImageWidth(naturalWidth: number): number {
const width = Math.round(naturalWidth)
return Math.max(1, Math.min(IMAGE_MAX_WIDTH, Number.isFinite(width) && width > 0 ? width : IMAGE_MAX_WIDTH))
}
/** 크기 줄의 폭 */
export function imageWidthFor(size: SignatureImageSize, naturalWidth: number): number {
return size === 'original' ? originalImageWidth(naturalWidth) : SIGNATURE_IMAGE_SIZES[size]
}
/** 새로 넣는 그림의 폭 — 보통(144), 원래 폭이 더 작으면 원래 폭 (작은 그림을 늘리지 않는다) */
export function insertedImageWidth(naturalWidth: number): number {
return Math.min(SIGNATURE_IMAGE_SIZES.medium, originalImageWidth(naturalWidth))
}
/** 지금 폭이 크기 줄의 어느 칸인가 — 작게 · 보통 · 원본 순으로 먼저 맞는 칸, 어디에도 맞지 않으면 null */
export function imageSizeOf(width: number | null, naturalWidth: number | null): SignatureImageSize | null {
if (width === null) return naturalWidth === null ? null : 'original'
if (width === SIGNATURE_IMAGE_SIZES.small) return 'small'
if (width === SIGNATURE_IMAGE_SIZES.medium) return 'medium'
if (naturalWidth !== null && width === originalImageWidth(naturalWidth)) return 'original'
return null
}
@@ -12,13 +12,19 @@ import {
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID, STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import {
acceptStyleValue,
cellSpanOf,
imageAltOf,
imageWidthOf,
isOutgoingImageAttribute,
isOutgoingStyleValue,
lengthToPx,
listStartOf,
normalizeSignatureImageSrc,
signatureImageIdOf,
signatureImageSrc,
} from './outgoing-html-rules'
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
@@ -119,3 +125,33 @@ describe('숫자 속성 — 서버가 남기는 1~4자리 숫자', () => {
for (const value of ['0', '2;', 'abc', '', null, -1, 10_000]) expect(cellSpanOf(value)).toBe(1)
})
})
describe('그림 — 서명 그림만 남는다 (계약 2절 "서식 본문"의 그림 예외, 서버와 같은 표본)', () => {
it.each(IMAGE_SAMPLES)('%s: %s → 남는다 %s', (attribute, value, kept) => {
expect(isOutgoingImageAttribute(attribute, value)).toBe(kept)
})
it('그 밖의 속성은 남지 않는다', () => {
for (const name of ['style', 'height', 'class', 'onerror', 'srcset', 'title']) {
expect(isOutgoingImageAttribute(name, '1')).toBe(false)
}
})
it('주소는 서버가 다시 쓰는 모양으로 맞춘다 — 앞뒤 공백 없이, id 는 소문자', () => {
const path = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
expect(normalizeSignatureImageSrc(` /api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()} `)).toBe(path)
expect(signatureImageIdOf(path)).toBe(SAMPLE_IMAGE_ID)
expect(signatureImageSrc(SAMPLE_IMAGE_ID.toUpperCase())).toBe(path)
expect(signatureImageSrc('../x')).toBeNull()
expect(normalizeSignatureImageSrc(42)).toBeNull()
})
it('폭은 숫자로, 설명은 그대로 — 형식이 틀리면 null (그 속성만 뺀다)', () => {
expect(imageWidthOf(' 144 ')).toBe(144)
expect(imageWidthOf(96)).toBe(96)
expect(imageWidthOf(600.5)).toBeNull()
expect(imageWidthOf(null)).toBeNull()
expect(imageAltOf(' 로고 ')).toBe(' 로고 ')
expect(imageAltOf(undefined)).toBeNull()
})
})
@@ -193,3 +193,67 @@ export function cellSpanOf(value: unknown): number {
const span = smallNumber(value)
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
}
// ── 그림 (계약 2절 "서식 본문" — 보내는 사람 자신의 서명 그림만 남는다) ─────────────
// <img src="/api/mail-signature/images/{id}" width="144" alt=""> 의 src · width · alt 만 남고, 형식이 틀린 width · alt 는 그 속성만 지운다.
// src 가 서명 그림 주소가 아니면(외부 주소 · data: · 쿼리 · 다른 경로) 그림째 지운다.
// ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.ts 의 parseSignatureImageId · sanitize-outgoing-html.ts 의 keepSignatureImage)와
// 같은 규칙이다 — src · width 는 앞뒤 공백을 떼고 보고, id 는 대소문자를 가리지 않고 소문자로 맞춘다. alt 는 적힌 그대로 본다.
// 같은 표본 표를 양쪽 테스트에 둔다 (test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES).
// 서버는 거기에 더해 그 id 가 보내는 사람의 그림인지 본다 — 편집기는 모르므로, 남의(또는 지운) 그림은 편집기에 깨진 그림으로 보이고 보낼 때 빠진다.
/** 서명 그림 주소의 앞부분 — 그림 주소는 이 뒤에 id 하나만 붙는다 (편집기 · 미리 보기가 이 주소로 그림을 받는다) */
export const SIGNATURE_IMAGE_PATH = '/api/mail-signature/images/'
/** 서명 그림 주소 — id 는 서버가 만든 UUID (쿼리 · 조각 · 하위 경로 없이). 대소문자는 가리지 않는다 */
const SIGNATURE_IMAGE_SRC =
/^\/api\/mail-signature\/images\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i
/** 그림 폭 — 1~600 의 정수, 앞자리 0 · 단위 · 소수 없이 */
const IMAGE_WIDTH = /^(?:[1-9]\d?|[1-5]\d\d|600)$/
/** 그림 설명(alt) 최대 길이 — JS 문자열 길이 */
export const IMAGE_ALT_MAX_LENGTH = 200
/** 그림 폭 상한(px) — 원본이 더 커도 이 폭으로 줄여 넣는다 */
export const IMAGE_MAX_WIDTH = 600
/** 그림 주소 → 서명 그림 id (소문자). 서명 그림 주소가 아니면 null */
export function signatureImageIdOf(src: unknown): string | null {
if (typeof src !== 'string') return null
const match = SIGNATURE_IMAGE_SRC.exec(src.trim())
return match?.[1] ? match[1].toLowerCase() : null
}
/** 남는 그림 주소인가 — 보내는 사람 자신의 서명 그림 주소 형식 */
export function isSignatureImageSrc(value: unknown): value is string {
return signatureImageIdOf(value) !== null
}
/** 서명 그림 id → 그림 주소 (id 형식이 틀리면 null) */
export function signatureImageSrc(id: string): string | null {
const normalized = signatureImageIdOf(`${SIGNATURE_IMAGE_PATH}${id}`)
return normalized === null ? null : `${SIGNATURE_IMAGE_PATH}${normalized}`
}
/** 남는 그림 주소 — 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로. 서명 그림 주소가 아니면 null */
export function normalizeSignatureImageSrc(src: unknown): string | null {
const id = signatureImageIdOf(src)
return id === null ? null : `${SIGNATURE_IMAGE_PATH}${id}`
}
/** 남는 그림 폭 — 1~600 정수(숫자 또는 숫자 글자, 앞뒤 공백은 뗀다)가 아니면 null (폭 속성을 두지 않는다) */
export function imageWidthOf(value: unknown): number | null {
const text = typeof value === 'number' ? String(value) : value
if (typeof text !== 'string' || !IMAGE_WIDTH.test(text.trim())) return null
return Number(text.trim())
}
/** 남는 그림 설명 — 200자를 넘거나 글자가 아니면 null (설명 속성을 두지 않는다) */
export function imageAltOf(value: unknown): string | null {
return typeof value === 'string' && value.length <= IMAGE_ALT_MAX_LENGTH ? value : null
}
/** 그림 속성 하나가 남는가 — 표본 표(test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES)로 서버와 맞춰 본다 */
export function isOutgoingImageAttribute(name: string, value: string): boolean {
if (name === 'src') return isSignatureImageSrc(value)
if (name === 'width') return imageWidthOf(value) !== null
if (name === 'alt') return imageAltOf(value) !== null
return false
}
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { RecipientSuggestion } from '@/shared/types/mail'
import {
RECIPIENT_SUGGESTION_LIMIT,
filterRecipientSuggestions,
hasHangulJamo,
highlightParts,
matchEmail,
matchName,
} from './recipient-match'
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
const DOYUN_KIM: RecipientSuggestion = { name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' }
const PARTNER: RecipientSuggestion = { name: '최도윤', email: 'choi@partner.example', source: 'recent' }
const NO_NAME: RecipientSuggestion = { name: null, email: 'ops@vendor.example', source: 'recent' }
/** 맞은 부분 글자 */
function matched(text: string, range: { start: number; end: number } | null): string | null {
return range ? text.slice(range.start, range.end) : null
}
describe('matchName — 이름 (대소문자 무시 · 초성 · 조합 중인 마지막 글자)', () => {
it.each([
['도윤', '이도윤', '도윤'],
['이도', '이도윤', '이도'],
['ㄷㅇ', '이도윤', '도윤'],
['ㅇㄷㅇ', '이도윤', '이도윤'],
['도ㅇ', '이도윤', '도윤'],
['ㅇ도', '이도윤', '이도'],
// 마지막 글자가 조합 중 — "윤" 을 치는 중이면 "유", 다음 글자의 ㅇ 이 받침으로 붙으면 "동"
['이도유', '이도윤', '이도윤'],
['이동', '이도윤', '이도윤'],
['읻', '이도윤', '이도'],
['닭', '달기봉', '달기'],
['kim', 'Ara KIM', 'KIM'],
[' 도윤 ', '이도윤', '도윤'],
])('%s → %s 에서 "%s"', (query, name, expected) => {
expect(matched(name, matchName(query, name))).toBe(expected)
})
it.each([
['도윤', '김아라'],
['ㄷㅈ', '이도윤'],
['동윤', '이도윤'],
['이동', '이도'],
['윤도', '이도윤'],
['', '이도윤'],
])('%s 는 %s 에 맞지 않는다', (query, name) => {
expect(matchName(query, name)).toBeNull()
})
it('조합 중인 글자는 마지막에만 — 가운데 글자는 그대로 맞아야 한다', () => {
expect(matchName('유ㄴ', '윤나')).toBeNull()
expect(matchName('윤ㄴ', '윤나')).toEqual({ start: 0, end: 2 })
})
it('이모지 같은 두 칸 글자가 있어도 범위는 UTF-16 위치다', () => {
const name = '😀이도윤'
expect(matched(name, matchName('도윤', name))).toBe('도윤')
})
})
describe('matchEmail — 주소 (대소문자 무시)', () => {
it('친 글자가 들어 있는 곳', () => {
expect(matched(DOYUN.email, matchEmail('doyun', DOYUN.email))).toBe('doyun')
expect(matched(DOYUN.email, matchEmail('ARASYSTEM', DOYUN.email))).toBe('arasystem')
expect(matchEmail('xyz', DOYUN.email)).toBeNull()
})
it('완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 (디자인 원본 ComposeAssist)', () => {
expect(matched(DOYUN.email, matchEmail('doyun.lee@partner.example', DOYUN.email))).toBe('doyun.lee@')
expect(matchEmail('@partner.example', DOYUN.email)).toBeNull()
})
})
describe('filterRecipientSuggestions', () => {
const pool = [PARTNER, DOYUN, NO_NAME, DOYUN_KIM]
it('사내를 먼저, 그 안에서는 받은 순서대로', () => {
const result = filterRecipientSuggestions('도윤', pool, new Set())
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, DOYUN_KIM.email, PARTNER.email])
})
it('이미 넣은 주소(대소문자 무시)는 빼고, 같은 주소는 한 번만', () => {
const result = filterRecipientSuggestions('도윤', [...pool, { ...DOYUN, source: 'recent' }], new Set([DOYUN_KIM.email]))
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, PARTNER.email])
})
it('이름이 없으면 주소로만 맞춘다', () => {
expect(filterRecipientSuggestions('vendor', pool, new Set()).map((match) => match.suggestion)).toEqual([NO_NAME])
})
it('빈 글자는 아무것도 보이지 않고, 최대 8개까지', () => {
expect(filterRecipientSuggestions(' ', pool, new Set())).toEqual([])
const many = Array.from({ length: 12 }, (_, index): RecipientSuggestion => ({
name: `도윤${index}`,
email: `doyun${index}@arasystem.it.kr`,
source: 'directory',
}))
expect(filterRecipientSuggestions('도윤', many, new Set())).toHaveLength(RECIPIENT_SUGGESTION_LIMIT)
})
})
describe('보조 함수', () => {
it('highlightParts — 맞은 부분만 strong', () => {
expect(highlightParts('이도윤', { start: 1, end: 3 })).toEqual([
{ text: '이', strong: false },
{ text: '도윤', strong: true },
])
expect(highlightParts('abc', null)).toEqual([{ text: 'abc', strong: false }])
})
it('hasHangulJamo — 초성 · 홀로 쓴 모음', () => {
expect(hasHangulJamo('ㄷㅇ')).toBe(true)
expect(hasHangulJamo('도ㅇ')).toBe(true)
expect(hasHangulJamo('도윤')).toBe(false)
expect(hasHangulJamo('doyun')).toBe(false)
})
})
@@ -0,0 +1,214 @@
// 받는 사람 자동 완성의 거르기 — 순수 함수 (디자인 README "2단계 › 받는 사람 자동 완성")
// - 이름 · 주소에 친 글자가 들어 있으면 맞는다 (대소문자 무시). 맞는 부분은 굵게 보이도록 범위를 준다.
// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤".
// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다.
// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다.
// - 이미 넣은 주소는 뺀다. 순서는 받은 그대로(서버: 사내 → 최근 주고받은 주소, 주고받은 횟수 · 최근성), 최대 8개.
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail } from '@/shared/utils/mail-address'
/** 추천 목록에 보이는 최대 개수 */
export const RECIPIENT_SUGGESTION_LIMIT = 8
/** 글자 범위 — UTF-16 위치, end 는 들지 않는다 */
export interface MatchRange {
start: number
end: number
}
export interface RecipientMatch {
suggestion: RecipientSuggestion
/** 이름에서 맞은 범위 (없으면 null) */
name: MatchRange | null
/** 주소에서 맞은 범위 (없으면 null) */
email: MatchRange | null
}
/** 굵게 보일 조각 — 맞은 부분만 strong */
export interface HighlightPart {
text: string
strong: boolean
}
// ── 한글 ─────────────────────────────────────────────────
const SYLLABLE_START = 0xac00
const SYLLABLE_END = 0xd7a3
const MEDIAL_COUNT = 21
const FINAL_COUNT = 28
/** 초성 — 호환 자모 */
const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ')
/** 받침 — 0 은 받침 없음 */
const FINALS = ['', ...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ')]
/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */
const SPLIT_FINALS: Readonly<Record<string, readonly [rest: string, moved: string]>> = {
ㄳ: ['ㄱ', 'ㅅ'],
ㄵ: ['ㄴ', 'ㅈ'],
ㄶ: ['ㄴ', 'ㅎ'],
ㄺ: ['ㄹ', 'ㄱ'],
ㄻ: ['ㄹ', 'ㅁ'],
ㄼ: ['ㄹ', 'ㅂ'],
ㄽ: ['ㄹ', 'ㅅ'],
ㄾ: ['ㄹ', 'ㅌ'],
ㄿ: ['ㄹ', 'ㅍ'],
ㅀ: ['ㄹ', 'ㅎ'],
ㅄ: ['ㅂ', 'ㅅ'],
}
interface Syllable {
initial: number
medial: number
final: number
}
function syllableOf(char: string): Syllable | null {
const code = char.codePointAt(0) ?? 0
if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) return null
const offset = code - SYLLABLE_START
return {
initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)),
medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT),
final: offset % FINAL_COUNT,
}
}
function composeSyllable(syllable: Syllable): string {
return String.fromCharCode(
SYLLABLE_START + (syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT + syllable.final,
)
}
/** 초성(호환 자모) 하나인가 */
function isInitialJamo(char: string): boolean {
return INITIALS.includes(char)
}
/** 호환 자모(ㄱ~ㅎ · ㅏ~ㅣ)가 들었는가 — 서버 검색은 초성을 모른다 */
export function hasHangulJamo(text: string): boolean {
return /[ㄱ-ㆎ]/.test(text)
}
function initialOf(char: string): string | null {
const syllable = syllableOf(char)
return syllable ? (INITIALS[syllable.initial] ?? null) : null
}
// ── 맞추기 ───────────────────────────────────────────────
/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */
function matchesChar(typed: string, char: string): boolean {
if (typed.toLowerCase() === char.toLowerCase()) return true
return isInitialJamo(typed) && initialOf(char) === typed
}
/**
* 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0.
* - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다
* - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤)
*/
function matchesLastChar(typed: string, char: string, next: string | undefined): number {
if (matchesChar(typed, char)) return 1
const typedSyllable = syllableOf(typed)
const syllable = syllableOf(char)
if (!typedSyllable || !syllable) return 0
if (typedSyllable.initial !== syllable.initial || typedSyllable.medial !== syllable.medial) return 0
if (typedSyllable.final === 0) return 1
if (next === undefined) return 0
const finalJamo = FINALS[typedSyllable.final] ?? ''
const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]
const restSyllable = composeSyllable({ ...typedSyllable, final: FINALS.indexOf(rest) })
return restSyllable === char && initialOf(next) === moved ? 2 : 0
}
/** chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null */
function matchFrom(typed: readonly string[], chars: readonly string[], start: number): number | null {
let index = start
for (let at = 0; at < typed.length; at += 1) {
const char = chars[index]
const typedChar = typed[at] ?? ''
if (char === undefined) return null
if (at === typed.length - 1) {
const used = matchesLastChar(typedChar, char, chars[index + 1])
return used === 0 ? null : index + used
}
if (!matchesChar(typedChar, char)) return null
index += 1
}
return index
}
/** 글자 단위 위치 → UTF-16 위치 */
function utf16Offset(chars: readonly string[], index: number): number {
return chars.slice(0, index).join('').length
}
/** 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위 */
export function matchName(query: string, name: string): MatchRange | null {
const typed = Array.from(query.trim())
const chars = Array.from(name)
if (typed.length === 0) return null
for (let start = 0; start < chars.length; start += 1) {
const end = matchFrom(typed, chars, start)
if (end !== null) return { start: utf16Offset(chars, start), end: utf16Offset(chars, end) }
}
return null
}
/** 주소에서 친 글자가 처음 들어 있는 범위 (대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 */
export function matchEmail(query: string, email: string): MatchRange | null {
const typed = query.trim().toLowerCase()
const target = email.toLowerCase()
// 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다
if (typed === '' || target.length !== email.length) return null
const at = target.indexOf(typed)
if (at >= 0) return { start: at, end: at + typed.length }
const localEnd = typed.indexOf('@')
if (localEnd <= 0) return null
const local = typed.slice(0, localEnd + 1)
const localAt = target.indexOf(local)
return localAt >= 0 ? { start: localAt, end: localAt + local.length } : null
}
/** 추천 하나가 친 글자에 맞는가 — 이름 · 주소 중 하나라도 맞으면 */
export function matchRecipient(query: string, suggestion: RecipientSuggestion): RecipientMatch | null {
const name = suggestion.name ? matchName(query, suggestion.name) : null
const email = matchEmail(query, suggestion.email)
return name || email ? { suggestion, name, email } : null
}
/**
* 친 글자에 맞는 추천 — 이미 넣은 주소(excluded, 정규화한 주소)는 빼고, 같은 주소는 처음 것만, 사내를 먼저, 최대 limit 개.
* 빈 글자면 아무것도 보이지 않는다 (한 글자 이상 쳤을 때만 목록을 연다).
*/
export function filterRecipientSuggestions(
query: string,
suggestions: readonly RecipientSuggestion[],
excluded: ReadonlySet<string>,
limit = RECIPIENT_SUGGESTION_LIMIT,
): RecipientMatch[] {
if (query.trim() === '') return []
const seen = new Set<string>(excluded)
const matches: RecipientMatch[] = []
for (const suggestion of suggestions) {
const key = normalizeEmail(suggestion.email)
if (seen.has(key)) continue
const match = matchRecipient(query, suggestion)
if (!match) continue
seen.add(key)
matches.push(match)
}
const directory = matches.filter((match) => match.suggestion.source === 'directory')
const recent = matches.filter((match) => match.suggestion.source !== 'directory')
return [...directory, ...recent].slice(0, limit)
}
/** 굵게 보일 조각으로 나눈다 */
export function highlightParts(text: string, range: MatchRange | null): HighlightPart[] {
if (!range || range.start >= range.end) return [{ text, strong: false }]
return [
{ text: text.slice(0, range.start), strong: false },
{ text: text.slice(range.start, range.end), strong: true },
{ text: text.slice(range.end), strong: false },
].filter((part) => part.text !== '')
}
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import type { MailSignature } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
import { useMailSignatureStore } from './mail-signature.store'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const SIGNATURE: MailSignature = {
bodyHtml: '<p>김아라</p>',
body: '김아라',
isForNew: true,
isForReply: false,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
describe('mail-signature.store (계약 2절 "서명")', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('쓰기 화면은 받아 둔 서명을 쓴다 — 받는 중이면 그 결과를 기다리고, 다시 묻지 않는다', async () => {
const requests = installMockApi(() => ok(SIGNATURE))
const store = useMailSignatureStore()
const [first, second] = await Promise.all([store.ensureSignature(), store.ensureSignature()])
await store.ensureSignature()
expect(first).toEqual(SIGNATURE)
expect(second).toEqual(SIGNATURE)
expect(requests).toHaveLength(1)
})
it('못 받으면 알리지 않고 null — 다음에 다시 받아 본다', async () => {
let result: MockResult = fail(503, 'MAIL_001')
const requests = installMockApi(() => result)
const store = useMailSignatureStore()
await expect(store.ensureSignature()).resolves.toBeNull()
expect(store.status).toBe('error')
expect(store.error?.code).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
result = ok(SIGNATURE)
await expect(store.ensureSignature()).resolves.toEqual(SIGNATURE)
expect(requests).toHaveLength(2)
})
it('저장한 값이 곧 다음 쓰기 화면의 서명이다 — 사용자가 바뀌면 비운다', async () => {
const requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...SIGNATURE, isForReply: true }) : ok(SIGNATURE)))
const store = useMailSignatureStore()
await store.save({ bodyHtml: '<p>김아라</p>', body: '김아라', isForNew: true, isForReply: true })
await expect(store.ensureSignature()).resolves.toMatchObject({ isForReply: true })
expect(requests.map((request) => request.method)).toEqual(['PUT'])
useAuthStore().clearAuth()
await nextTick()
expect(store.signature).toBeNull()
expect(store.status).toBe('idle')
})
})
@@ -0,0 +1,85 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMail, type MailRequestOptions } from '@/composables/useMail'
import type { MailSignature, SaveSignaturePayload } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 메일 서명 스토어 — 계약 2절 "서명" (사용자마다 하나)
// - 쓰기 화면은 열 때 서명을 넣는다 — 메일 화면에 들어올 때 미리 받아 두고(ensureSignature), 쓰기 화면은 그 값을 쓴다.
// 못 받았으면 서명 없이 연다 (알리지 않는다 — 서명은 쓰기를 막을 일이 아니다)
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창에서 고쳤을 수 있다. 오류는 화면이 직접 그린다
// - 저장(save)한 값이 곧 다음 쓰기 화면의 서명이다
export const useMailSignatureStore = defineStore('mail-signature', () => {
const mailApi = useMail()
const authStore = useAuthStore()
const signature = ref<MailSignature | null>(null)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
let seq = 0
let pending: Promise<MailSignature | null> | null = null
/** 서명을 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
function load(): Promise<MailSignature | null> {
seq += 1
const current = seq
status.value = 'loading'
error.value = null
const request = (async () => {
try {
const result = await mailApi.getSignature({ silent: true })
if (current !== seq) return signature.value
signature.value = result
status.value = 'ready'
return result
} catch (caught) {
if (current !== seq) return signature.value
status.value = 'error'
error.value = toApiError(caught)
return null
} finally {
if (current === seq) pending = null
}
})()
pending = request
return request
}
/** 받아 둔 서명 — 아직 없거나 실패했으면 받는다 (받는 중이면 그 결과를 기다린다) */
function ensureSignature(): Promise<MailSignature | null> {
if (status.value === 'ready') return Promise.resolve(signature.value)
if (pending) return pending
return load()
}
/** 서명 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다) */
async function save(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
const result = await mailApi.saveSignature(payload, options)
seq += 1
pending = null
signature.value = result
status.value = 'ready'
error.value = null
return result
}
function reset() {
seq += 1
pending = null
signature.value = null
status.value = 'idle'
error.value = null
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 서명이 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return { signature, status, error, load, ensureSignature, save, reset }
})
+45
View File
@@ -249,6 +249,35 @@ describe('mail.store', () => {
])
})
it('다른 메일함으로 옮긴다(되돌리기) — 개수를 다시 맞추고, 보이는 목록이 옮긴 곳 · 휴지통이면 새로 고친다', async () => {
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
let patchResult: MockResult = ok(summary('d1', { mailboxIds: ['drafts'] }))
const requests = installMockApi(({ method, url }) => {
if (method === 'PATCH') return patchResult
if (url === '/mailboxes') {
return ok({ items: [INBOX, DRAFTS, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
}
if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
return page([summary('m1')])
})
const store = useMailStore()
await store.loadMailboxes()
await store.openMailbox('trash')
const listsBefore = requests.filter((request) => request.url === '/messages').length
await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(true)
await flushPromises()
const patch = requests.find((request) => request.method === 'PATCH')
expect(patch?.url).toBe('/messages/d1')
expect(patch?.data).toEqual({ mailboxId: 'drafts' })
expect(requests.filter((request) => request.url === '/messages').length).toBe(listsBefore + 1)
expect(requests.filter((request) => request.url === '/mailboxes').length).toBe(2)
patchResult = fail(404, 'RES_001')
await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(false)
})
describe('사용량 (GET /mail-quota)', () => {
const QUOTA = {
usedBytes: 966_367_641,
@@ -313,6 +342,22 @@ describe('mail.store', () => {
})
})
it('다시 저장해 바뀐 임시 저장본 id 를 끝까지 따라간다 — 모르는 id 는 그대로, 사용자가 바뀌면 잊는다', async () => {
const store = useMailStore()
store.recordDraftSuccessor('d1', 'd2')
store.recordDraftSuccessor('d2', 'd3')
store.recordDraftSuccessor('d3', 'd3')
expect(store.latestDraftId('d1')).toBe('d3')
expect(store.latestDraftId('d2')).toBe('d3')
expect(store.latestDraftId('d3')).toBe('d3')
expect(store.latestDraftId('other')).toBe('other')
useAuthStore().expireSession()
await nextTick()
expect(store.latestDraftId('d1')).toBe('d1')
})
it('로그아웃·세션 만료로 사용자가 바뀌면 이전 사용자의 메일을 비운다', async () => {
installMockApi(() => page([summary('m1')]))
const store = useMailStore()
+55 -6
View File
@@ -63,6 +63,13 @@ export const useMailStore = defineStore('mail', () => {
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
const lastBrowseLocation = ref<string | null>(null)
/**
* 이 탭에서 다시 저장해 바뀐 임시 저장본 id — 옛 id → 새 id (계약 2절 "임시 저장": 저장할 때마다 id 가 바뀌고 옛것은 지운다).
* 방문 기록에 남은 옛 주소(?draft=<옛 id>)로 돌아오면 쓰기 화면이 마지막 저장본을 연다 — 떠나기 전 저장처럼
* 주소를 맞추지 못한 채 떠난 경우다 (떠나는 중에 주소를 바꾸면 가던 이동이 취소된다). 화면에 그리지 않으므로 반응형이 아니다
*/
const draftSuccessors = new Map<string, string>()
let mailboxSeq = 0
let quotaSeq = 0
let listSeq = 0
@@ -105,10 +112,10 @@ export const useMailStore = defineStore('mail', () => {
let mailboxLoadsInFlight = 0
/**
* 메일함 목록.
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
*/
async function loadMailboxes(options: { background?: boolean } = {}): Promise<void> {
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
mailboxSeq += 1
const seq = mailboxSeq
if (!options.background) {
@@ -118,16 +125,19 @@ export const useMailStore = defineStore('mail', () => {
mailboxLoadsInFlight += 1
try {
const result = await mailApi.listMailboxes({ silent: true })
if (seq !== mailboxSeq) return
if (seq !== mailboxSeq) return null
mailboxes.value = result.items
mailboxesStatus.value = 'ready'
mailboxesError.value = null
return null
} catch (error) {
if (seq !== mailboxSeq) return
const apiError = toApiError(error)
if (seq !== mailboxSeq) return apiError
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
if (options.background && mailboxesStatus.value === 'ready') return
if (options.background && mailboxesStatus.value === 'ready') return apiError
mailboxesStatus.value = 'error'
mailboxesError.value = toApiError(error)
mailboxesError.value = apiError
return apiError
} finally {
mailboxLoadsInFlight -= 1
}
@@ -341,10 +351,45 @@ export const useMailStore = defineStore('mail', () => {
return next?.id ?? null
}
/**
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 목록이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
* 실패하면 false (알림은 인터셉터가 띄웠다).
*/
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
try {
await mailApi.updateMessage(id, { mailboxId })
} catch {
return false
}
syncCounts()
const shown = currentMailboxId.value
const trashId = mailboxByRole('trash')?.id ?? null
if (shown !== null && (shown === mailboxId || shown === trashId)) void openMailbox(shown)
return true
}
function rememberBrowseLocation(fullPath: string) {
lastBrowseLocation.value = fullPath
}
/** 임시 저장본을 다시 저장해 새 id 를 받았다 — 옛 id 로 열면 새 id 를 연다 */
function recordDraftSuccessor(previousId: string, nextId: string) {
if (previousId !== nextId) draftSuccessors.set(previousId, nextId)
}
/** 이 탭이 아는 마지막 저장본 id — 다시 저장한 적 없으면 그대로 */
function latestDraftId(id: string): string {
let latest = id
// 새 id 는 늘 새것이라 고리가 생기지 않지만, 만에 하나를 위해 기록 수만큼만 따라간다
for (let step = 0; step < draftSuccessors.size; step += 1) {
const next = draftSuccessors.get(latest)
if (next === undefined) break
latest = next
}
return latest
}
function reset() {
mailboxSeq += 1
quotaSeq += 1
@@ -366,6 +411,7 @@ export const useMailStore = defineStore('mail', () => {
selectedStatus.value = 'idle'
selectedError.value = null
lastBrowseLocation.value = null
draftSuccessors.clear()
}
// 로그아웃·세션 만료·다른 계정 로그인 때 이전 사용자의 메일이 화면에 남지 않게 비운다
@@ -411,7 +457,10 @@ export const useMailStore = defineStore('mail', () => {
markRead,
toggleFlag,
removeMessage,
moveMessage,
rememberBrowseLocation,
recordDraftSuccessor,
latestDraftId,
reset,
}
})
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import ToastHost from '@/components/feedback/ToastHost.vue'
import { createTestRouter } from '@/test-utils/router'
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
describe('toast.store', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.useFakeTimers()
})
afterEach(() => {
useToastStore().clear()
vi.useRealTimers()
})
const messages = () => useToastStore().toasts.map((toast) => toast.message)
const undo = { label: '되돌리기', run: () => undefined }
it('단추 없는 알림은 종류별 시간, 단추가 있는 알림은 10초 보인다', () => {
const store = useToastStore()
store.show({ kind: 'success', message: '보냈습니다' })
store.show({ kind: 'success', message: '옮겼습니다', action: undo })
vi.advanceTimersByTime(TOAST_DURATION_MS.success)
expect(messages()).toEqual(['옮겼습니다'])
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - TOAST_DURATION_MS.success - 1)
expect(messages()).toEqual(['옮겼습니다'])
vi.advanceTimersByTime(1)
expect(messages()).toEqual([])
})
it('붙잡은 동안은 닫지 않고, 까닭이 모두 사라지면 시간을 처음부터 다시 센다', () => {
const store = useToastStore()
const id = store.show({ kind: 'success', message: '옮겼습니다', action: undo })
vi.advanceTimersByTime(9_000)
store.hold(id, 'pointer')
store.hold(id, 'focus')
vi.advanceTimersByTime(60_000)
expect(messages()).toEqual(['옮겼습니다'])
store.release(id, 'pointer')
vi.advanceTimersByTime(60_000)
expect(messages()).toEqual(['옮겼습니다'])
store.release(id, 'focus')
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - 1)
expect(messages()).toEqual(['옮겼습니다'])
vi.advanceTimersByTime(1)
expect(messages()).toEqual([])
})
it('ToastHost — 마우스가 올라가 있거나 초점이 안에 있는 동안은 닫히지 않는다 (알림 안에서 옮긴 초점은 그대로)', async () => {
const router = createTestRouter()
const wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, router] } })
useToastStore().show({ kind: 'success', message: '옮겼습니다', action: undo })
await wrapper.vm.$nextTick()
const toast = wrapper.get('.toast')
await toast.trigger('mouseenter')
vi.advanceTimersByTime(60_000)
expect(messages()).toEqual(['옮겼습니다'])
await toast.trigger('focusin')
await toast.trigger('mouseleave')
// 알림 안의 단추로 초점이 옮겨 갔다 — 놓지 않는다
await toast.trigger('focusout', { relatedTarget: toast.get('.toast__action').element })
vi.advanceTimersByTime(60_000)
expect(messages()).toEqual(['옮겼습니다'])
await toast.trigger('focusout', { relatedTarget: document.body })
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS)
expect(messages()).toEqual([])
wrapper.unmount()
})
})
+54 -11
View File
@@ -4,13 +4,13 @@ import type { RouteLocationRaw } from 'vue-router'
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
export type ToastKind = 'error' | 'success'
export interface ToastAction {
label: string
to: RouteLocationRaw
}
/** 알림의 단추 — 다른 화면으로 가는 링크(to) 또는 그 자리에서 하는 일(run, 예: 되돌리기). 누르면 알림을 닫는다 */
export type ToastAction = { label: string; to: RouteLocationRaw } | { label: string; run: () => void }
export interface Toast {
id: number
@@ -38,9 +38,18 @@ export const TOAST_DURATION_MS: Readonly<Record<ToastKind, number>> = {
success: 5000,
}
/** 단추가 있는 알림(되돌리기 · 보낸편지함 보기 등)이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히 둔다 */
export const TOAST_ACTION_DURATION_MS = 10_000
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
export type ToastHoldReason = 'pointer' | 'focus'
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
const timers = new Map<number, ReturnType<typeof setTimeout>>()
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
const durations = new Map<number, number>()
const holds = new Map<number, Set<ToastHoldReason>>()
let nextId = 1
function stopTimer(id: number) {
@@ -49,11 +58,45 @@ export const useToastStore = defineStore('toast', () => {
timers.delete(id)
}
function dismiss(id: number) {
function startTimer(id: number) {
stopTimer(id)
const duration = durations.get(id)
if (duration === undefined) return
timers.set(
id,
setTimeout(() => dismiss(id), duration),
)
}
/** 알림을 잊는다 — 타이머 · 붙잡은 까닭까지 */
function forget(id: number) {
stopTimer(id)
durations.delete(id)
holds.delete(id)
}
function dismiss(id: number) {
forget(id)
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
/** 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 */
function hold(id: number, reason: ToastHoldReason) {
if (!durations.has(id)) return
const reasons = holds.get(id) ?? new Set<ToastHoldReason>()
reasons.add(reason)
holds.set(id, reasons)
stopTimer(id)
}
/** 놓는다 — 붙잡은 까닭이 모두 사라지면 그 알림의 시간을 처음부터 다시 센다 */
function release(id: number, reason: ToastHoldReason) {
const reasons = holds.get(id)
if (!reasons?.delete(reason) || reasons.size > 0) return
holds.delete(id)
startTimer(id)
}
function show(input: ToastInput): number {
const id = nextId
nextId += 1
@@ -67,21 +110,21 @@ export const useToastStore = defineStore('toast', () => {
}
const all = [...toasts.value, next]
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
overflow.forEach((toast) => stopTimer(toast.id))
overflow.forEach((toast) => forget(toast.id))
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
timers.set(
id,
setTimeout(() => dismiss(id), TOAST_DURATION_MS[input.kind]),
)
durations.set(id, next.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[input.kind])
startTimer(id)
return id
}
function clear() {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
durations.clear()
holds.clear()
toasts.value = []
}
return { toasts, show, dismiss, clear }
return { toasts, show, dismiss, hold, release, clear }
})
+29
View File
@@ -63,6 +63,35 @@ a:hover {
outline: none;
}
/*
* 둘 · 셋 중 하나를 고르는 전환 단추 — 설정 화면의 그림 크기 · 미리 볼 메일 (디자인 원본 Settings 의 .seg)
* 회색 바탕 묶음 안에서 고른 칸만 흰 바탕 + 그림자. 단추 높이 36 — 캔버스 확정값, 44px 기준의 밀도 예외(--size-segment-item-lg).
* 사용: <div role="group" class="segmented"><button class="segmented__item" :aria-pressed="…">…</button></div>
*/
.segmented {
display: inline-flex;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
}
.segmented__item {
min-height: var(--size-segment-item-lg);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-weight: 600;
}
.segmented__item[aria-pressed='true'] {
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-segment);
}
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
.visually-hidden {
position: absolute;
+7 -2
View File
@@ -22,6 +22,7 @@
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
--font-size-title-lg: 1.125rem; /* 18px — 설정 화면 제목 (디자인 원본 Settings) */
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
--font-size-display: 1.875rem; /* 30px — 로그인 소개 문구 */
@@ -73,7 +74,8 @@
--color-danger-surface: #fef3f2;
--color-danger-border: #fbd7d3;
--color-warning-surface: #fff7e6;
--color-warning-text: #7a5200;
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
--color-star: #b7791f;
/* ── 간격 (이름의 숫자 = px) ─────────────── */
@@ -116,16 +118,19 @@
--shadow-toast: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.12);
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
--shadow-segment: 0 0.0625rem 0.125rem rgba(16, 24, 28, 0.14); /* 전환 단추의 고른 칸 (디자인 원본 Settings) */
--color-backdrop: rgba(11, 54, 64, 0.32);
/* ── 크기 ───────────────────────────────── */
--size-touch: 2.75rem; /* 44px — 누를 수 있는 최소 영역 */
/*
* 밀도를 위한 작은 조작 영역 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "디자인 원칙", 사용자 확정).
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움)과 "분할 | 목록" 전환(바깥 36px)에만 쓴다.
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움), "분할 | 목록" 전환(바깥 36px),
* 설정 화면의 전환 단추 · 서명 그림의 크기 줄(작게 · 보통 · 원본 · 그림 빼기 — 36px, 캔버스 확정값)에만 쓴다.
*/
--size-compact-control: 2rem; /* 32px */
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
--size-segment-item-lg: 2.25rem; /* 36px — 밀도 예외: 설정 화면의 전환 단추(그림 크기 · 미리 볼 메일) · 그림 빼기 (디자인 원본 Settings) */
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
--size-rail-item: 3.75rem; /* 60px */
+63
View File
@@ -0,0 +1,63 @@
// 단위 테스트 전용 — 손으로 돌리는 시계. advance(ms) 를 부를 때만 시간이 흐르고 그 사이에 닿는 타이머를 차례로 부른다.
// 화면에는 CLOCK 으로 provide 한다: mount(Page, { global: { provide: { [CLOCK as symbol]: clock } } })
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
import type { Clock, ClockTimer } from '@/composables/useClock'
interface PendingTimer {
id: symbol
due: number
/** 같은 시각이면 먼저 건 것부터 */
order: number
callback: () => void
}
export interface ManualClock extends Clock {
/** 시간을 ms 만큼 넘긴다 — 그 사이에 닿는 타이머(넘기는 동안 새로 건 것 포함)를 시각 순서대로 부른다 */
advance: (ms: number) => void
/** 아직 부르지 않은 타이머 수 */
pendingCount: () => number
}
export function createManualClock(start = 0): ManualClock {
let current = start
let nextOrder = 0
const timers: PendingTimer[] = []
function nextDue(limit: number): PendingTimer | null {
let found: PendingTimer | null = null
for (const timer of timers) {
if (timer.due > limit) continue
if (!found || timer.due < found.due || (timer.due === found.due && timer.order < found.order)) found = timer
}
return found
}
return {
now: () => current,
setTimeout: (callback, ms) => {
const timer: PendingTimer = {
id: Symbol('timer'),
due: current + Math.max(0, ms),
order: nextOrder,
callback,
}
nextOrder += 1
timers.push(timer)
return timer.id
},
clearTimeout: (handle: ClockTimer) => {
const index = timers.findIndex((timer) => timer.id === handle)
if (index >= 0) timers.splice(index, 1)
},
advance: (ms) => {
const target = current + ms
for (let timer = nextDue(target); timer; timer = nextDue(target)) {
timers.splice(timers.indexOf(timer), 1)
current = Math.max(current, timer.due)
timer.callback()
}
current = target
},
pendingCount: () => timers.length,
}
}
@@ -122,3 +122,56 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['text-decoration', 'underline', false],
]
/** 표본 그림 id — 서버가 만드는 UUID 모양 */
export const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
/**
* 그림(img) 속성 표본 — [속성, 값, 서버가 남기는가]. 계약 2절 "서식 본문"의 그림 예외 (서명 그림만).
* src 가 남지 않으면 그림째 지운다. width · alt 가 남지 않으면 그 속성만 지운다.
* ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.spec.ts · sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
* 서버는 여기에 더해 id 가 보내는 사람의 그림인지 본다 (표의 id 는 보내는 사람의 그림이라고 본다).
*/
export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'alt', value: string, kept: boolean])[] = [
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`, true],
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
['src', '', false],
['src', '/api/mail-signature/images/', false],
['src', '/api/mail-signature/images/not-a-uuid', false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
['src', `https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
['src', 'https://evil.example/t.png', false],
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
['width', '1', true],
['width', '96', true],
['width', '144', true],
['width', '600', true],
['width', ' 144 ', true],
['width', '0', false],
['width', '601', false],
['width', '1000', false],
['width', '096', false],
['width', '-1', false],
['width', '+5', false],
['width', '14.5', false],
['width', '144px', false],
['width', '50%', false],
['width', '1e2', false],
['width', '', false],
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
['alt', '', true],
['alt', '회사 로고', true],
['alt', 'ㄱ'.repeat(200), true],
['alt', 'ㄱ'.repeat(201), false],
]
+7
View File
@@ -28,6 +28,13 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
{
path: '/settings/mail/signature',
name: 'settings-mail-signature',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}