feat: 메일 16단계 — 주소록 가져오기 · 내보내기 · 여러 서명 · 부재중 서식과 회사 밖 응답 · 2단계 인증 휴대폰 바꾸기 #24

Merged
ttipo merged 5 commits from feat/personal-settings into main 2026-11-10 12:40:35 +09:00
102 changed files with 14738 additions and 924 deletions
Showing only changes of commit 907287021f - Show all commits
@@ -0,0 +1,368 @@
<script setup lang="ts">
// 연락처 내보내기 창 (디자인 원본 Contacts16 "5" — 칸 하나 창 400, 새 그룹 창과 같은 폭)
// 「내보낼 연락처」 내 주소록 전체(처음 — "연락처 342명") · 그룹 하나(고르면 그룹 고르기 칸 44 — 첫 그룹이 골라진다, "거래처 · 24명") +
// "내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다." → 「파일 형식」 vCard (.vcf)(처음) · CSV (.csv) → 파일 이름 → [내보내기] [취소]
// - 파일 이름은 주소록-날짜, 그룹이면 주소록-그룹 이름-날짜. 날짜는 이 PC 의 날짜이고 파일 이름에 쓸 수 없는 글자는 _ 로 (서버가 붙이는 이름과 같은 꼴)
// - 구성원이 없는 그룹을 고르면 [내보내기]를 끄고(aria-disabled) 까닭을 그룹 칸 아래에 — 다른 그룹을 고르면 바로 돌아온다
// - 그룹이 하나도 없으면 「그룹 하나」를 고를 수 없다(disabled)
// [내보내기]: 창을 닫고 바로 내려받는다 — 첨부 받기처럼 이동 직전에 로그인을 확인하고(confirmSession), 파일은 서버가 만든다. 따로 알림은 띄우지 않는다
// 고정 id — #contact-export-dialog · #contact-export-title · #contact-export-empty
import { computed, ref, watch } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useClock } from '@/composables/useClock'
import { useContacts } from '@/composables/useContacts'
import { CONTACT_EXPORT_EXTENSIONS, CONTACT_EXPORT_TEXT } from '@/shared/constants/contacts'
import type { ContactExportFormat } from '@/shared/types/contacts'
import { formatCount, formatDate } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const contactsStore = useContactsStore()
const contactsApi = useContacts()
const clock = useClock()
const EMPTY_ID = 'contact-export-empty'
/** 파일 이름에 쓸 수 없는 글자 (Windows · macOS 공통) */
const UNSAFE_FILE_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g
const scope = ref<'all' | 'group'>('all')
const groupId = ref<string>('')
const format = ref<ContactExportFormat>('vcard')
const groups = computed(() => contactsStore.groups)
const group = computed(() => groups.value.find((item) => item.id === groupId.value) ?? null)
const allHint = computed(() => {
const total = contactsStore.contactTotal
return total === null ? '' : CONTACT_EXPORT_TEXT.allCount(formatCount(total))
})
/** 고른 그룹에 구성원이 없다 — [내보내기]를 끈다 */
const groupEmpty = computed(() => scope.value === 'group' && group.value !== null && group.value.memberCount <= 0)
const unavailable = computed(() => groupEmpty.value || (scope.value === 'group' && group.value === null))
const fileName = computed(() => {
const date = formatDate(new Date(clock.now()))
const extension = CONTACT_EXPORT_EXTENSIONS[format.value]
const parts =
scope.value === 'group' && group.value
? [CONTACT_EXPORT_TEXT.fileBase, group.value.name.replace(UNSAFE_FILE_CHARS, '_'), date]
: [CONTACT_EXPORT_TEXT.fileBase, date]
return `${parts.join('-')}.${extension}`
})
const scopes = computed(() => [
{ value: 'all' as const, label: CONTACT_EXPORT_TEXT.all, hint: allHint.value, disabled: false },
{ value: 'group' as const, label: CONTACT_EXPORT_TEXT.group, hint: CONTACT_EXPORT_TEXT.groupHint, disabled: groups.value.length === 0 },
])
const formats = [
{ value: 'vcard' as const, label: CONTACT_EXPORT_TEXT.vcard, hint: CONTACT_EXPORT_TEXT.vcardHint },
{ value: 'csv' as const, label: CONTACT_EXPORT_TEXT.csv, hint: CONTACT_EXPORT_TEXT.csvHint },
]
function chooseScope(value: 'all' | 'group') {
scope.value = value
if (value === 'group' && !group.value) groupId.value = groups.value[0]?.id ?? ''
}
function submit() {
if (unavailable.value) return
const query = scope.value === 'group' && group.value ? { format: format.value, groupId: group.value.id } : { format: format.value }
emit('close')
void contactsApi.downloadContactExport(query)
}
// 열 때마다 처음 값 — 내 주소록 전체 · vCard
watch(
() => props.open,
(open) => {
if (!open) return
scope.value = 'all'
groupId.value = ''
format.value = 'vcard'
contactsStore.ensureGroups()
},
{ immediate: true },
)
</script>
<template>
<ModalDialog
id="contact-export-dialog"
:open="open"
:title="CONTACT_EXPORT_TEXT.title"
title-id="contact-export-title"
medium
data-contact-export
@cancel="emit('close')"
>
<fieldset class="export-dialog__fieldset">
<legend class="export-dialog__legend">
{{ CONTACT_EXPORT_TEXT.scopeLegend }}
</legend>
<div
v-for="item in scopes"
:key="item.value"
:class="['export-dialog__card', { 'is-checked': scope === item.value, 'is-disabled': item.disabled }]"
>
<label class="export-dialog__option">
<input
type="radio"
name="contact-export-scope"
class="export-dialog__radio"
:value="item.value"
:checked="scope === item.value"
:disabled="item.disabled"
:data-export-scope="item.value"
@change="chooseScope(item.value)"
>
<span class="export-dialog__text">
<span class="export-dialog__label">{{ item.label }}</span>
<span
v-if="item.hint"
class="export-dialog__hint"
>{{ item.hint }}</span>
</span>
</label>
<template v-if="item.value === 'group' && scope === 'group'">
<select
class="export-dialog__select"
:aria-label="CONTACT_EXPORT_TEXT.groupSelect"
:aria-describedby="groupEmpty ? EMPTY_ID : undefined"
:value="groupId"
data-export-group
@change="groupId = ($event.target as HTMLSelectElement).value"
>
<option
v-for="option in groups"
:key="option.id"
:value="option.id"
>
{{ CONTACT_EXPORT_TEXT.groupOption(option.name, formatCount(option.memberCount)) }}
</option>
</select>
<p
v-if="groupEmpty && group"
:id="EMPTY_ID"
role="status"
class="export-dialog__empty"
>
{{ CONTACT_EXPORT_TEXT.groupEmpty(group.name) }}
</p>
</template>
</div>
<p class="export-dialog__note">
{{ CONTACT_EXPORT_TEXT.directoryNote }}
</p>
</fieldset>
<fieldset class="export-dialog__fieldset export-dialog__fieldset--format">
<legend class="export-dialog__legend">
{{ CONTACT_EXPORT_TEXT.formatLegend }}
</legend>
<label
v-for="item in formats"
:key="item.value"
:class="['export-dialog__card', 'export-dialog__option', { 'is-checked': format === item.value }]"
>
<input
type="radio"
name="contact-export-format"
class="export-dialog__radio"
:value="item.value"
:checked="format === item.value"
:data-export-format="item.value"
@change="format = item.value"
>
<span class="export-dialog__text">
<span class="export-dialog__label">{{ item.label }}</span>
<span class="export-dialog__hint">{{ item.hint }}</span>
</span>
</label>
</fieldset>
<p
v-if="!unavailable"
class="export-dialog__file"
>
<span class="export-dialog__file-label">{{ CONTACT_EXPORT_TEXT.fileName }}</span>
<span
class="export-dialog__file-name"
data-export-file-name
>{{ fileName }}</span>
</p>
<template #actions>
<button
type="button"
class="modal-dialog__action export-dialog__submit"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="groupEmpty ? EMPTY_ID : undefined"
autofocus
data-export-submit
@click="submit"
>
{{ CONTACT_EXPORT_TEXT.submit }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-export-cancel
@click="emit('close')"
>
{{ CONTACT_EXPORT_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.export-dialog__fieldset {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
margin: var(--space-4) 0 0;
padding: 0;
border: 0;
}
.export-dialog__fieldset--format {
margin-top: var(--space-8);
}
.export-dialog__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 고르기 상자 — 테두리 상자, 고른 상자는 청록 테두리 + 옅은 바탕 (차단 창과 같은 결) */
.export-dialog__card {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-height: var(--size-touch);
padding: var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.export-dialog__card.is-checked {
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
.export-dialog__card.is-disabled {
color: var(--color-icon-muted);
}
.export-dialog__option {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--space-10);
cursor: pointer;
}
.export-dialog__card.is-disabled .export-dialog__option {
cursor: default;
}
.export-dialog__radio {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.export-dialog__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: var(--line-height-heading);
}
.export-dialog__label {
font-weight: 600;
}
.export-dialog__hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.export-dialog__card.is-disabled .export-dialog__hint {
color: var(--color-icon-muted);
}
/* 그룹 고르기 칸 44 — 고르기 글 선(28)에 맞춰 들여 쓴다 */
.export-dialog__select {
width: calc(100% - var(--space-28));
height: var(--size-touch);
margin-left: var(--space-28);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.export-dialog__select:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.export-dialog__empty {
margin: 0 0 0 var(--space-28);
font-size: var(--font-size-label);
color: var(--color-warning-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
.export-dialog__note {
margin: var(--space-2) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.export-dialog__file {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-8);
margin: var(--space-6) 0 0;
font-size: var(--font-size-label);
}
.export-dialog__file-label {
color: var(--color-text-muted);
}
.export-dialog__file-name {
font-weight: 600;
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
/* 누를 수 없는 [내보내기] — 끄지 않고 흐리게 (초점은 받는다) */
.modal-dialog__action.export-dialog__submit[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,450 @@
<script setup lang="ts">
// 연락처 가져오기 창 (디자인 원본 Contacts16 "2" · "3" — 넓은 창 620, 필터 창 · 연락처 창과 같은 틀)
// ① 파일 고르기(ContactImportPicker): [파일 고르기]는 .vcf · .csv · .xlsx 만 보이게 걸러 둔 파일 고르는 창, 창 어디에 끌어다 놓아도 받는다
// (끄는 동안 놓는 자리가 청록 점선). 파일은 이 브라우저에서 읽고 회사 서버로 올리지 않는다(useContactImport 의 read).
// 읽지 못하면 자리 아래 빨간 글(알림 없음) — 다른 파일을 고르거나 놓으면 지운다. 읽는 동안 [취소]는 읽기를 멈추고 창을 닫는다
// ② 미리 보기: 파일 이름 + [다른 파일 고르기](파일 고르는 창을 바로 연다 — 고른 그룹과 체크 칸은 그대로) · "연락처 340명을 찾았습니다." ·
// 세 묶음(ContactImportSummary) · 그룹(ContactImportGroupFields) · 메모 체크 칸(ContactImportInfoField — 칸이 없는 정보가 있을 때만) ·
// 한도 안내(노란 상자) · [연락처 N명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 단추를 끄고(aria-disabled) 까닭을 단추 옆에.
// 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다)
// [연락처 N명 넣기]: 새 그룹 이름을 비웠으면 칸 아래 "그룹 이름을 넣어 주세요." — 아니면 창을 닫고 넣기를 시작한다(진행 알림 — useContactImport 의 start)
// Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다)
// 고정 id — #contact-import-dialog · #contact-import-title · #contact-import-message · #contact-import-found · #contact-import-nothing
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ContactImportGroupFields from '@/components/contacts/ContactImportGroupFields.vue'
import ContactImportInfoField from '@/components/contacts/ContactImportInfoField.vue'
import ContactImportPicker from '@/components/contacts/ContactImportPicker.vue'
import ContactImportSummary from '@/components/contacts/ContactImportSummary.vue'
import type { ImportGroupMode } from '@/components/contacts/contacts-ui'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { useContactImport } from '@/composables/useContactImport'
import {
CONTACT_IMPORT_ACCEPT,
CONTACT_IMPORT_ERROR_TEXT,
CONTACT_IMPORT_TEXT,
CONTACTS_PANEL_TEXT,
} from '@/shared/constants/contacts'
import {
extraGroupName,
fileGroupsOf,
fileGroupsText,
hasImportedInfo,
importPlanOf,
infoSampleOf,
type ImportExtraGroup,
type ImportPreview,
} from '@/shared/utils/contact-import/records'
import type { ContactFileProblem, ImportedFileKind } from '@/shared/utils/contact-import/types'
import { isFileDrag } from '@/shared/utils/editor-files'
import { formatCount } from '@/shared/utils/format'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
open: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const contactsStore = useContactsStore()
const importer = useContactImport()
const fileInputRef = ref<HTMLInputElement | null>(null)
const pickerRef = ref<InstanceType<typeof ContactImportPicker> | null>(null)
const groupFieldsRef = ref<InstanceType<typeof ContactImportGroupFields> | null>(null)
const submitRef = ref<HTMLButtonElement | null>(null)
const step = ref<'pick' | 'reading' | 'preview'>('pick')
const error = ref<string | null>(null)
const fileName = ref<string>('')
/** 파일을 끌어 창 위에 있는 동안 — 들어온 요소 수 (자식 사이를 지날 때 깜빡이지 않게) */
const dragDepth = ref<number>(0)
const preview = shallowRef<ImportPreview | null>(null)
const groupMode = ref<ImportGroupMode>('none')
const existingGroupId = ref<string>('')
const newGroupName = ref<string>('')
const newGroupError = ref<string | null>(null)
const appendInfo = ref<boolean>(true)
/** 지금 읽는 파일 — 끊으면(창을 닫음 · 다른 파일) 그 결과는 버린다 */
let reading: AbortController | null = null
const dragging = computed(() => dragDepth.value > 0)
const groups = computed(() => contactsStore.groups)
const insertCount = computed(() => preview.value?.toInsert.length ?? 0)
const foundText = computed(() => CONTACT_IMPORT_TEXT.found(formatCount(preview.value?.found ?? 0)))
const groupsText = computed(() =>
preview.value ? fileGroupsText(fileGroupsOf(preview.value.toInsert, groups.value)) : '',
)
const showInfo = computed(() => preview.value !== null && hasImportedInfo(preview.value.toInsert))
const infoSample = computed(() => (preview.value ? infoSampleOf(preview.value.toInsert) : null))
/** 한도 안내 — 남은 자리가 하나도 없으면(주소록이 가득 참) 패널의 3,000개 안내 */
const limitText = computed(() => {
const overLimit = preview.value?.overLimit ?? 0
if (overLimit <= 0) return null
if (insertCount.value === 0) return CONTACTS_PANEL_TEXT.fullNote
return CONTACT_IMPORT_TEXT.limit(formatCount(insertCount.value), formatCount(overLimit))
})
const submitLabel = computed(() =>
insertCount.value > 0 ? CONTACT_IMPORT_TEXT.submit(formatCount(insertCount.value)) : CONTACT_IMPORT_TEXT.submitNone,
)
function problemText(problem: ContactFileProblem, kind: ImportedFileKind | null): string {
if (problem === 'notFound' && kind === 'vcard') return CONTACT_IMPORT_ERROR_TEXT.notFoundCards
return CONTACT_IMPORT_ERROR_TEXT[problem]
}
function stopReading() {
reading?.abort()
reading = null
}
function close() {
stopReading()
dragDepth.value = 0
emit('close')
}
function openPicker() {
fileInputRef.value?.click()
}
async function readFile(file: File) {
stopReading()
const current = new AbortController()
reading = current
step.value = 'reading'
fileName.value = file.name
error.value = null
const outcome = await importer.read(file, current.signal)
if (reading !== current || current.signal.aborted || outcome.status === 'canceled') return
reading = null
if (outcome.status === 'ready') {
preview.value = outcome.preview
step.value = 'preview'
await nextTick()
submitRef.value?.focus()
return
}
preview.value = null
step.value = 'pick'
// 서버에 묻지 못했으면(error) 인터셉터가 사전 문구로 알렸다 — 칸 아래에는 쓰지 않는다
error.value = outcome.status === 'failed' ? problemText(outcome.problem, outcome.kind) : null
await nextTick()
pickerRef.value?.focusPick()
}
/** 고르거나 놓은 파일 — 하나만 받는다 */
function takeFiles(files: readonly File[]) {
const [file] = files
if (!file) return
if (files.length > 1) {
stopReading()
preview.value = null
step.value = 'pick'
error.value = CONTACT_IMPORT_ERROR_TEXT.multiple
void nextTick(() => pickerRef.value?.focusPick())
return
}
void readFile(file)
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
// 같은 파일을 다시 골라도 change 가 오게 비운다
input.value = ''
takeFiles(files)
}
function onDragEnter(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
dragDepth.value += 1
}
function onDragOver(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = step.value === 'reading' ? 'none' : 'copy'
}
function onDragLeave(event: DragEvent) {
if (!isFileDrag(event)) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
/** 창 어디에 놓아도 받는다 — 읽는 동안은 받지 않는다(브라우저가 파일을 열지 않게 막기만) */
function onDrop(event: DragEvent) {
dragDepth.value = 0
if (!isFileDrag(event)) return
event.preventDefault()
if (step.value === 'reading') return
takeFiles(Array.from(event.dataTransfer?.files ?? []))
}
function extraGroup(): ImportExtraGroup | null {
if (groupMode.value === 'existing' && existingGroupId.value) return { kind: 'existing', id: existingGroupId.value }
if (groupMode.value !== 'new') return { kind: 'none' }
const name = extraGroupName(newGroupName.value)
return name ? { kind: 'new', name } : null
}
function submit() {
const current = preview.value
if (!current || insertCount.value === 0) return
const extra = extraGroup()
if (!extra) {
newGroupError.value = CONTACT_IMPORT_TEXT.extraNameRequired
void nextTick(() => groupFieldsRef.value?.focusNewName())
return
}
const plan = importPlanOf(current, groups.value, extra, appendInfo.value && showInfo.value)
emit('close')
void importer.start(plan)
}
function resetForm() {
stopReading()
step.value = 'pick'
error.value = null
fileName.value = ''
dragDepth.value = 0
preview.value = null
groupMode.value = 'none'
existingGroupId.value = ''
newGroupName.value = ''
newGroupError.value = null
appendInfo.value = true
}
watch(
() => props.open,
(open) => {
if (open) {
resetForm()
contactsStore.ensureGroups()
} else {
stopReading()
}
},
{ immediate: true },
)
watch([newGroupName, groupMode], () => {
newGroupError.value = null
})
onBeforeUnmount(stopReading)
</script>
<template>
<ModalDialog
id="contact-import-dialog"
:open="open"
:title="CONTACT_IMPORT_TEXT.title"
:message="step === 'preview' ? '' : CONTACT_IMPORT_TEXT.message"
title-id="contact-import-title"
message-id="contact-import-message"
:described-by="step === 'preview' ? 'contact-import-found' : undefined"
wide
data-contact-import
@cancel="close"
@dragenter="onDragEnter"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<ContactImportPicker
v-if="step !== 'preview' || !preview"
ref="pickerRef"
:reading="step === 'reading'"
:dragging="dragging"
:file-name="fileName"
:error="error"
@pick="openPicker"
/>
<template v-else>
<div class="import-dialog__file">
<AppIcon
name="file-text"
:size="16"
class="import-dialog__file-icon"
/>
<span
class="import-dialog__file-name"
data-import-file-name
>{{ fileName }}</span>
<button
type="button"
class="import-dialog__text-button"
data-import-pick-another
@click="openPicker"
>
{{ CONTACT_IMPORT_TEXT.pickAnother }}
</button>
</div>
<p
id="contact-import-found"
class="import-dialog__found"
data-import-found
>
{{ foundText }}
</p>
<ContactImportSummary :preview="preview" />
<template v-if="insertCount > 0">
<ContactImportGroupFields
ref="groupFieldsRef"
v-model:mode="groupMode"
v-model:existing-id="existingGroupId"
v-model:new-name="newGroupName"
:groups-text="groupsText"
:new-name-error="newGroupError"
:groups="groups"
:groups-full="contactsStore.isGroupsFull"
/>
<ContactImportInfoField
v-if="showInfo"
v-model="appendInfo"
:sample="infoSample"
/>
</template>
<p
v-if="limitText"
role="status"
class="import-dialog__limit"
data-import-limit
>
<AppIcon
name="alert-triangle"
:size="15"
:stroke-width="1.9"
class="import-dialog__limit-icon"
/>
<span>{{ limitText }}</span>
</p>
</template>
<input
ref="fileInputRef"
type="file"
class="visually-hidden"
:accept="CONTACT_IMPORT_ACCEPT"
tabindex="-1"
aria-hidden="true"
data-import-file
@change="onFileChange"
>
<template #actions>
<span
v-if="step === 'preview' && preview && insertCount === 0"
id="contact-import-nothing"
role="status"
class="import-dialog__nothing"
>{{ CONTACT_IMPORT_TEXT.nothing }}</span>
<button
v-if="step === 'preview' && preview"
ref="submitRef"
type="button"
class="modal-dialog__action import-dialog__submit"
:aria-disabled="insertCount === 0 ? 'true' : undefined"
:aria-describedby="insertCount === 0 ? 'contact-import-nothing' : undefined"
data-import-submit
@click="submit"
>
{{ submitLabel }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-import-cancel
@click="close"
>
{{ CONTACT_IMPORT_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 파일 줄 — 아이콘 · 이름(줄임) · [다른 파일 고르기] (위 글과 붙여 둔다) */
.import-dialog__file {
display: flex;
align-items: center;
gap: var(--space-8);
min-width: 0;
margin-top: calc(var(--space-4) * -1);
}
.import-dialog__file-icon {
color: var(--color-text-muted);
}
.import-dialog__file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-dialog__text-button {
flex: 0 0 auto;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.import-dialog__text-button:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.import-dialog__found {
margin: calc(var(--space-6) * -1) 0 var(--space-4);
font-size: var(--font-size-body);
font-weight: 600;
}
/* 한도 안내 — 노란 상자 12.5 */
.import-dialog__limit {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: var(--space-6) 0 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-dialog__limit-icon {
margin-top: var(--space-2);
}
/* 넣을 연락처가 없을 때 — 단추 옆(왼쪽)의 까닭 */
.import-dialog__nothing {
align-self: center;
margin-right: auto;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 누를 수 없는 [연락처 넣기] — 끄지 않고 흐리게 (초점은 받는다) */
.modal-dialog__action.import-dialog__submit[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,262 @@
<script setup lang="ts">
// 가져오기 창 ② 의 그룹 (디자인 원본 Contacts16 "3")
// - 파일에 적힌 그룹 글: "파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다." (records.ts 의 fileGroupsText)
// - 「가져온 연락처를 모두 넣을 그룹」: 넣지 않음(처음) · 이미 있는 그룹(고르기 칸 44 — "거래처 · 4명") · 새 그룹(이름 칸 44 · 50자)
// 그룹이 100개면 「새 그룹」을 끄고(aria-disabled — 초점은 받는다) 아래에 "그룹은 100개까지 만들 수 있습니다.", 그룹이 하나도 없으면 「이미 있는 그룹」을 끈다
// 새 그룹 이름을 비운 채 넣으려 하면 칸 아래 "그룹 이름을 넣어 주세요."(창이 정한다). 새 그룹 이름이 이미 있는 그룹과 같으면 그 그룹에 넣는다
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { ImportGroupMode } from '@/components/contacts/contacts-ui'
import {
CONTACT_GROUP_NAME_MAX_LENGTH,
CONTACT_IMPORT_TEXT,
CONTACTS_PANEL_TEXT,
} from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import { formatCount } from '@/shared/utils/format'
interface Props {
groupsText: string
mode: ImportGroupMode
existingId: string
newName: string
newNameError: string | null
groups: readonly ContactGroup[]
groupsFull: boolean
}
interface Emits {
(e: 'update:mode', value: ImportGroupMode): void
(e: 'update:existingId', value: string): void
(e: 'update:newName', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const NEW_NOTE_ID = 'contact-import-new-group-note'
const NEW_ERROR_ID = 'contact-import-new-group-error'
const nameRef = ref<HTMLInputElement | null>(null)
const modes = computed(() => [
{ value: 'none' as const, label: CONTACT_IMPORT_TEXT.extraNone, unavailable: false },
{ value: 'existing' as const, label: CONTACT_IMPORT_TEXT.extraExisting, unavailable: props.groups.length === 0 },
{ value: 'new' as const, label: CONTACT_IMPORT_TEXT.extraNew, unavailable: props.groupsFull },
])
/** 고를 수 없는 줄은 눌러도 바꾸지 않는다 (aria-disabled — 화살표로 와도 앞 고르기가 남는다) */
function onModeClick(event: MouseEvent, unavailable: boolean) {
if (unavailable) event.preventDefault()
}
function onModeChange(value: ImportGroupMode) {
emit('update:mode', value)
if (value === 'existing' && !props.groups.some((group) => group.id === props.existingId)) {
emit('update:existingId', props.groups[0]?.id ?? '')
}
if (value === 'new') void nextTick(() => nameRef.value?.focus())
}
defineExpose({
focusNewName: () => nameRef.value?.focus(),
})
</script>
<template>
<div class="import-groups">
<div class="import-groups__file">
<p class="import-groups__label">
{{ CONTACT_IMPORT_TEXT.groupsLabel }}
</p>
<p
class="import-groups__text"
data-import-groups-text
>
{{ groupsText }}
</p>
</div>
<fieldset class="import-groups__fieldset">
<legend class="import-groups__label">
{{ CONTACT_IMPORT_TEXT.extraLegend }}
</legend>
<div class="import-groups__modes">
<label
v-for="item in modes"
:key="item.value"
:class="['import-groups__mode', { 'is-unavailable': item.unavailable }]"
>
<input
type="radio"
name="contact-import-group-mode"
class="import-groups__radio"
:value="item.value"
:checked="mode === item.value"
:aria-disabled="item.unavailable ? 'true' : undefined"
:aria-describedby="item.value === 'new' && item.unavailable ? NEW_NOTE_ID : undefined"
:data-import-group-mode="item.value"
@click="onModeClick($event, item.unavailable)"
@change="onModeChange(item.value)"
>
<span>{{ item.label }}</span>
</label>
</div>
<p
v-if="groupsFull"
:id="NEW_NOTE_ID"
class="import-groups__note"
>
{{ CONTACTS_PANEL_TEXT.groupsFullNote }}
</p>
<select
v-if="mode === 'existing'"
class="import-groups__control"
:aria-label="CONTACT_IMPORT_TEXT.extraLegend"
:value="existingId"
data-import-group-select
@change="emit('update:existingId', ($event.target as HTMLSelectElement).value)"
>
<option
v-for="group in groups"
:key="group.id"
:value="group.id"
>
{{ CONTACT_IMPORT_TEXT.groupOption(group.name, formatCount(group.memberCount)) }}
</option>
</select>
<template v-if="mode === 'new'">
<input
ref="nameRef"
class="import-groups__control"
type="text"
:value="newName"
:aria-label="CONTACT_IMPORT_TEXT.extraNewName"
:placeholder="CONTACT_IMPORT_TEXT.extraNewName"
:maxlength="CONTACT_GROUP_NAME_MAX_LENGTH"
autocomplete="off"
:aria-invalid="newNameError ? 'true' : 'false'"
:aria-describedby="newNameError ? NEW_ERROR_ID : undefined"
data-import-group-name
@input="emit('update:newName', ($event.target as HTMLInputElement).value)"
>
<p
v-if="newNameError"
:id="NEW_ERROR_ID"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ newNameError }}</span>
</p>
</template>
<p class="import-groups__hint">
{{ CONTACT_IMPORT_TEXT.extraHint }}
</p>
</fieldset>
</div>
</template>
<style scoped>
.import-groups {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-top: var(--space-6);
}
.import-groups__file {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.import-groups__label {
margin: 0;
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.import-groups__text {
margin: 0;
color: var(--color-text-strong);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-groups__fieldset {
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.import-groups__modes {
display: flex;
flex-wrap: wrap;
gap: 0 var(--space-22);
}
/* 고르기 줄 44 */
.import-groups__mode {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.import-groups__mode.is-unavailable {
opacity: 0.5;
cursor: default;
}
.import-groups__radio {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
.import-groups__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 고르기 칸 · 이름 칸 280 × 44 */
.import-groups__control {
width: min(17.5rem, 100%);
height: var(--size-touch);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.import-groups__control:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.import-groups__control[aria-invalid='true'] {
border-color: var(--color-danger);
}
.import-groups__hint {
margin: var(--space-4) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
// 가져오기 창 ② 의 메모 체크 칸 (디자인 원본 Contacts16 "3")
// 「주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기」(처음 켜짐) + "메모가 1,000자를 넘게 되면 붙이지 않습니다."
// 켜 두면 아래에 견본 — "예: 강민지의 메모": 원래 메모(흐리게) · 빈 줄 · "가져온 정보" · "회사 주소: …" 줄들 (records.ts 의 infoSampleOf)
import { CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
import type { ImportInfoSample } from '@/shared/utils/contact-import/records'
interface Props {
modelValue: boolean
sample: ImportInfoSample | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const HELP_ID = 'contact-import-info-help'
</script>
<template>
<div class="import-info">
<label class="import-info__check">
<input
type="checkbox"
class="import-info__input"
:checked="modelValue"
:aria-describedby="HELP_ID"
data-import-info
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
>
<span>{{ CONTACT_IMPORT_TEXT.info }}</span>
</label>
<p
:id="HELP_ID"
class="import-info__help"
>
{{ CONTACT_IMPORT_TEXT.infoHint }}
</p>
<div
v-if="modelValue && sample"
class="import-info__sample"
data-import-info-sample
>
<span class="import-info__caption">{{ CONTACT_IMPORT_TEXT.infoSample(sample.name) }}</span>
<span
v-for="(line, index) in sample.original"
:key="`o${index}`"
class="import-info__line import-info__line--original"
>{{ line }}</span>
<span
v-if="sample.original.length > 0"
class="import-info__gap"
aria-hidden="true"
/>
<span
v-for="(line, index) in sample.added"
:key="`a${index}`"
class="import-info__line"
>{{ line }}</span>
</div>
</div>
</template>
<style scoped>
.import-info {
display: flex;
flex-direction: column;
}
/* 체크 줄 44 — 위 12, 글이 두 줄이면 상자를 첫 줄에 맞춘다 */
.import-info__check {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding-top: var(--space-12);
cursor: pointer;
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-info__input {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.import-info__help {
margin: var(--space-2) 0 0 var(--space-28);
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 견본 — 옅은 회색 상자, 원래 메모는 흐리게 */
.import-info__sample {
display: flex;
flex-direction: column;
margin: var(--space-8) 0 0 var(--space-28);
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
}
.import-info__caption {
margin-bottom: var(--space-4);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.import-info__line {
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.import-info__line--original {
color: var(--color-text-muted);
}
.import-info__gap {
min-height: var(--space-8);
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
// 가져오기 창 ① 파일 고르기 (디자인 원본 Contacts16 "2") — 놓는 자리 · 읽지 못한 까닭 · 받는 형식 안내
// - 처음: 올리기 아이콘 + [파일 고르기](채운 단추 — 창의 처음 초점) + "파일을 여기로 끌어다 놓아도 됩니다."
// - 끄는 동안(dragging — 창 어디에 끌어 와도): 자리가 2px 청록 점선 · 옅은 청록 바탕, "놓으면 이 파일에서 연락처를 찾습니다"
// - 읽는 동안: 도는 아이콘 + "파일을 읽는 중…" + 파일 이름. 화면 낭독기에는 늘 있는 상태 영역(role=status — 글만 바꾼다)이 같은 글을 읽는다
// - 읽지 못했으면 자리 아래 빨간 글(role=alert — 알림은 띄우지 않는다). 다른 파일을 고르거나 놓으면 창이 지운다
// 끌어다 놓기 처리는 창(ContactImportDialog)이 한다 — 창 어디에 놓아도 받는다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
interface Props {
reading: boolean
dragging: boolean
fileName: string
error: string | null
}
interface Emits {
(e: 'pick'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const pickRef = ref<HTMLButtonElement | null>(null)
defineExpose({
/** [파일 고르기]에 초점을 둔다 — 읽지 못한 뒤 */
focusPick: () => pickRef.value?.focus(),
})
</script>
<template>
<div class="import-picker">
<div
:class="['import-picker__zone', { 'is-dragging': dragging && !reading }]"
data-import-zone
>
<template v-if="reading">
<AppIcon
name="loader"
:size="24"
:stroke-width="2"
class="import-picker__spinner"
/>
<!-- 같은 글을 화면 낭독기는 아래 상태 영역(늘 있다)에서 읽는다 — 두 번 읽지 않게 숨긴다 -->
<p
class="import-picker__reading"
aria-hidden="true"
>
{{ CONTACT_IMPORT_TEXT.reading }}
</p>
<p class="import-picker__file">
{{ fileName }}
</p>
</template>
<template v-else-if="dragging">
<AppIcon
name="upload"
:size="28"
class="import-picker__icon import-picker__icon--active"
/>
<p class="import-picker__dropping">
{{ CONTACT_IMPORT_TEXT.dropping }}
</p>
</template>
<template v-else>
<AppIcon
name="upload"
:size="28"
:stroke-width="1.5"
class="import-picker__icon"
/>
<button
ref="pickRef"
type="button"
class="import-picker__pick"
autofocus
data-import-pick
@click="emit('pick')"
>
{{ CONTACT_IMPORT_TEXT.pick }}
</button>
<p class="import-picker__hint">
{{ CONTACT_IMPORT_TEXT.dropHint }}
</p>
</template>
</div>
<!-- 상태 영역은 늘 두고 글만 바꾼다 — 글과 함께 생기는 영역은 화면 낭독기가 읽지 않기도 한다 -->
<p
class="visually-hidden"
role="status"
data-import-status
>
{{ reading ? CONTACT_IMPORT_TEXT.reading : '' }}
</p>
<p
v-if="error"
role="alert"
class="import-picker__error"
data-import-error
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
class="import-picker__error-icon"
/>
<span>{{ error }}</span>
</p>
<p class="import-picker__formats">
{{ CONTACT_IMPORT_TEXT.formats }}
</p>
</div>
</template>
<style scoped>
.import-picker {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin-top: var(--space-6);
}
/* 놓는 자리 168 — 1px 회색 점선 · 옅은 회색 바탕 (디자인 원본 Contacts16 "2") */
.import-picker__zone {
min-height: 10.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) dashed var(--color-icon-muted);
border-radius: var(--radius-lg);
background: var(--color-surface-panel);
text-align: center;
}
/* 끄는 동안 — 2px 청록 점선 · 옅은 청록 바탕 (테두리가 1px 굵어진 만큼 안쪽을 줄여 자리가 그대로) */
.import-picker__zone.is-dragging {
padding: calc(var(--space-20) - var(--border-width));
border: calc(var(--border-width) * 2) dashed var(--color-brand);
background: var(--color-brand-tint);
}
.import-picker__icon {
color: var(--color-icon-muted);
}
.import-picker__icon--active {
color: var(--color-brand-strong);
}
.import-picker__pick {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.import-picker__pick:hover {
background: var(--color-brand-strong);
}
.import-picker__hint,
.import-picker__formats,
.import-picker__file {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.import-picker__file {
max-width: 100%;
overflow-wrap: anywhere;
}
.import-picker__dropping {
margin: 0;
font-weight: 600;
color: var(--color-brand-strong);
}
.import-picker__reading {
margin: 0;
font-weight: 600;
color: var(--color-text-strong);
}
.import-picker__spinner {
color: var(--color-brand);
animation: import-picker-spin 0.9s linear infinite;
}
@keyframes import-picker-spin {
to {
transform: rotate(360deg);
}
}
/* 읽지 못한 까닭 — 칸 아래 빨간 글 12.5 */
.import-picker__error {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
word-break: keep-all;
overflow-wrap: anywhere;
}
.import-picker__error-icon {
margin-top: var(--space-2);
}
@media (prefers-reduced-motion: reduce) {
.import-picker__spinner {
animation: none;
}
}
</style>
@@ -0,0 +1,378 @@
<script setup lang="ts">
// 가져오기 창 ② 미리 보기의 세 묶음 (디자인 원본 Contacts16 "3") — 글과 숫자로만 나눈다(뱃지 없음)
// - 넣을 연락처: 앞 6명의 표(이름 · 이메일 · 전화 · 회사 · 그룹) + "그 밖에 N명"
// - 이미 있는 연락처는 건너뜁니다 · 넣지 못하는 줄(vCard 는 연락처): 눌러 펼친다(aria-expanded). 펼친 목록은 6줄 높이까지 늘고 넘으면 목록 안에서 넘긴다
// 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 "12번째 연락처". 빈 묶음은 두지 않는다
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { CONTACT_IMPORT_PREVIEW_ROWS, CONTACT_IMPORT_TEXT } from '@/shared/constants/contacts'
import {
duplicateReasonText,
importPositionText,
previewRowsOf,
type ImportPreview,
} from '@/shared/utils/contact-import/records'
import { formatCount } from '@/shared/utils/format'
interface Props {
preview: ImportPreview
}
const props = defineProps<Props>()
const duplicatesOpen = ref<boolean>(false)
const problemsOpen = ref<boolean>(false)
const rows = computed(() => previewRowsOf(props.preview))
const others = computed(() => Math.max(0, props.preview.toInsert.length - CONTACT_IMPORT_PREVIEW_ROWS))
const vcard = computed(() => props.preview.kind === 'vcard')
const problemLabel = computed(() =>
vcard.value ? CONTACT_IMPORT_TEXT.problemCards : CONTACT_IMPORT_TEXT.problemLines,
)
const problemCount = computed(() => {
const count = formatCount(props.preview.problems.length)
return vcard.value ? CONTACT_IMPORT_TEXT.people(count) : CONTACT_IMPORT_TEXT.lineCount(count)
})
const duplicates = computed(() =>
props.preview.duplicates.map((duplicate) => ({
key: `${duplicate.position}`,
position: importPositionText(props.preview.kind, duplicate.position),
name: duplicate.name,
detail: duplicate.detail,
reason: duplicateReasonText(props.preview.kind, duplicate),
})),
)
const problems = computed(() =>
props.preview.problems.map((problem) => ({
key: `${problem.position}`,
position: importPositionText(props.preview.kind, problem.position),
what: problem.what,
reason: problem.reason,
})),
)
</script>
<template>
<div
class="import-summary"
data-import-summary
>
<div
v-if="preview.toInsert.length > 0"
class="import-summary__insert"
>
<div class="import-summary__head">
<span class="import-summary__label">{{ CONTACT_IMPORT_TEXT.insertSection }}</span>
<span
class="import-summary__count"
data-import-insert-count
>{{ CONTACT_IMPORT_TEXT.people(formatCount(preview.toInsert.length)) }}</span>
</div>
<div
role="table"
class="import-summary__table"
:aria-label="CONTACT_IMPORT_TEXT.insertTable(formatCount(rows.length))"
>
<div
role="row"
class="import-summary__row import-summary__row--head"
>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.name }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.email }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.phone }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.company }}</span>
<span role="columnheader">{{ CONTACT_IMPORT_TEXT.columns.groups }}</span>
</div>
<div
v-for="row in rows"
:key="row.key"
role="row"
class="import-summary__row"
data-import-row
>
<span
role="cell"
class="import-summary__cell import-summary__cell--name"
>{{ row.name }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.email }}</span>
<span
role="cell"
class="import-summary__cell import-summary__cell--number"
>{{ row.phone }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.company }}</span>
<span
role="cell"
class="import-summary__cell"
>{{ row.groups }}</span>
</div>
</div>
<p
v-if="others > 0"
class="import-summary__others"
>
{{ CONTACT_IMPORT_TEXT.others(formatCount(others)) }}
</p>
</div>
<div
v-if="duplicates.length > 0"
class="import-summary__section"
>
<button
type="button"
class="import-summary__toggle"
:aria-expanded="duplicatesOpen ? 'true' : 'false'"
aria-controls="contact-import-duplicates"
data-import-duplicates-toggle
@click="duplicatesOpen = !duplicatesOpen"
>
<AppIcon
:name="duplicatesOpen ? 'chevron-down' : 'chevron-right'"
:size="14"
:stroke-width="2"
class="import-summary__chevron"
/>
<span class="import-summary__label">{{ CONTACT_IMPORT_TEXT.duplicateSection }}</span>
<span class="import-summary__count">{{ CONTACT_IMPORT_TEXT.people(formatCount(duplicates.length)) }}</span>
</button>
<ul
v-show="duplicatesOpen"
id="contact-import-duplicates"
class="import-summary__list"
:aria-label="CONTACT_IMPORT_TEXT.duplicateSection"
data-import-duplicates
>
<li
v-for="item in duplicates"
:key="item.key"
class="import-summary__item"
>
<span class="import-summary__position">{{ item.position }}</span>
<span class="import-summary__who"><span class="import-summary__who-name">{{ item.name }}</span>{{ item.detail }}</span>
<span class="import-summary__reason">{{ item.reason }}</span>
</li>
</ul>
</div>
<div
v-if="problems.length > 0"
class="import-summary__section"
>
<button
type="button"
class="import-summary__toggle"
:aria-expanded="problemsOpen ? 'true' : 'false'"
aria-controls="contact-import-problems"
data-import-problems-toggle
@click="problemsOpen = !problemsOpen"
>
<AppIcon
:name="problemsOpen ? 'chevron-down' : 'chevron-right'"
:size="14"
:stroke-width="2"
class="import-summary__chevron"
/>
<span class="import-summary__label">{{ problemLabel }}</span>
<span class="import-summary__count">{{ problemCount }}</span>
</button>
<ul
v-show="problemsOpen"
id="contact-import-problems"
class="import-summary__list"
:aria-label="problemLabel"
data-import-problems
>
<li
v-for="item in problems"
:key="item.key"
class="import-summary__item import-summary__item--problem"
>
<span class="import-summary__position">{{ item.position }}</span>
<span class="import-summary__problem">
<span class="import-summary__what">{{ item.what }}</span>
<span class="import-summary__problem-reason">{{ item.reason }}</span>
</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.import-summary {
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.import-summary__insert {
display: flex;
flex-direction: column;
padding: var(--space-10) var(--space-12);
}
.import-summary__head {
display: flex;
align-items: baseline;
gap: var(--space-8);
padding-bottom: var(--space-6);
}
.import-summary__label {
font-weight: 600;
}
.import-summary__count {
margin-left: auto;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.import-summary__table {
display: flex;
flex-direction: column;
font-size: var(--font-size-label);
}
/* 표 칸 — 이름 64 · 이메일 1.7fr · 전화 96 · 회사 1fr · 그룹 76 (디자인 원본 Contacts16 "3") */
.import-summary__row {
display: grid;
grid-template-columns: 4rem minmax(0, 1.7fr) 6rem minmax(0, 1fr) 4.75rem;
column-gap: var(--space-10);
align-items: center;
min-height: 1.875rem;
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.import-summary__row--head {
min-height: 1.75rem;
border-bottom-color: var(--color-border);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.import-summary__cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__cell--name {
font-weight: 600;
color: var(--color-text);
}
.import-summary__cell--number {
font-variant-numeric: tabular-nums;
}
.import-summary__others {
margin: var(--space-6) 0 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.import-summary__section {
border-top: var(--border-width) solid var(--color-border);
}
.import-summary__section:first-child {
border-top: 0;
}
/* 펼치기 줄 44 */
.import-summary__toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
background: var(--color-surface);
color: var(--color-text);
text-align: left;
}
.import-summary__toggle:hover {
background: var(--color-surface-panel);
}
.import-summary__chevron {
color: var(--color-text-muted);
}
/* 펼친 목록 — 6줄 높이(186)까지, 넘으면 목록 안에서 넘긴다 */
.import-summary__list {
max-height: 11.625rem;
margin: 0;
padding: 0 var(--space-16) var(--space-6) 2.125rem;
overflow-y: auto;
list-style: none;
}
.import-summary__item {
display: grid;
grid-template-columns: 4.75rem minmax(0, 1fr) auto;
column-gap: var(--space-10);
align-items: center;
min-height: 1.875rem;
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-label);
}
.import-summary__item--problem {
grid-template-columns: 4.75rem minmax(0, 1fr);
padding: var(--space-6) 0;
}
.import-summary__position {
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.import-summary__who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__who-name {
margin-right: var(--space-6);
font-weight: 600;
color: var(--color-text);
}
.import-summary__reason {
color: var(--color-text-muted);
}
.import-summary__problem {
display: flex;
flex-direction: column;
min-width: 0;
}
.import-summary__what {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-strong);
}
.import-summary__problem-reason {
color: var(--color-warning-text);
}
</style>
@@ -1,13 +1,16 @@
<script setup lang="ts">
// 주소록 화면의 창 묶음 — 패널 · 목록 · 상세가 연 창을 그린다 (useContactsDialogs · 메일함 패널의 MailboxDialogs 와 같은 결)
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog)
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog) ·
// 가져오기(ContactImportDialog) · 내보내기(ContactExportDialog — 16단계)
// - 창은 한 번 그리면 두고 열고 닫기만 한다 — 닫을 때 초점이 연 단추로 돌아가게(ModalDialog). 닫히는 동안 글이 바뀌지 않게 마지막 요청을 쥐고 있는다
// - 닫은 뒤 연 단추가 사라져(지운 연락처의 [고치기] 등) 초점을 잃었으면 목록 칸으로 옮긴다
import { computed, nextTick, shallowRef, watch } from 'vue'
import ContactDialog from '@/components/contacts/ContactDialog.vue'
import ContactExportDialog from '@/components/contacts/ContactExportDialog.vue'
import ContactGroupMembersDialog from '@/components/contacts/ContactGroupMembersDialog.vue'
import ContactGroupNameDialog from '@/components/contacts/ContactGroupNameDialog.vue'
import ContactImportDialog from '@/components/contacts/ContactImportDialog.vue'
import type { ContactsDialogRequest } from '@/composables/useContactsDialogs'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { ContactPrefill } from '@/shared/utils/contacts'
@@ -75,4 +78,12 @@ watch(
:group="membersTarget"
@close="close"
/>
<ContactImportDialog
:open="kind === 'import'"
@close="close"
/>
<ContactExportDialog
:open="kind === 'export'"
@close="close"
/>
</template>
+123 -13
View File
@@ -6,6 +6,9 @@
// - 줄(ContactsListItem) — 이름 차례(서버), 100명씩 받고 끝에 [더 보기]
// - 상태: 불러오는 중(뼈대) · 못 받음(사내 목록은 "사내 목록을 불러오지 못했습니다" + [다시 시도], 없는 그룹은 [전체 연락처로]) ·
// 찾은 것 없음 · 처음(연락처 없음 — [연락처 추가]) · 빈 그룹([구성원 넣기]) · 빈 사내 목록 (디자인 원본 ContactsEdit "상태")
// - 16단계(디자인 원본 Contacts16 "1"): 「전체 연락처」 머리 오른쪽에 [가져오기] · [내보내기](도구 단추 — 그룹 머리와 같은 꼴).
// 연락처가 하나도 없으면 [내보내기]를 끄고(aria-disabled) 까닭 "내보낼 연락처가 없습니다."(title · 숨긴 글), 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로,
// 가져오는 동안(되돌리기 포함)은 [가져오기]가 바쁜 표시(aria-busy — 눌러도 아무 일이 없다). 빈 주소록 안내에는 테두리 단추 [가져오기]와 안내 한 줄을 더한다
// data-contacts-list — 지운 뒤 · 창을 닫은 뒤 초점을 잃으면 여기로 (useContactActions · ContactsDialogs)
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -21,11 +24,13 @@ import {
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_PANEL_TEXT,
CONTACTS_TRANSFER_TEXT,
DIRECTORY_KIND_TEXT,
} from '@/shared/constants/contacts'
import { contactDisplayName, contactListMeta, primaryEmail } from '@/shared/utils/contacts'
import { contactsItemLocation, contactsListLocation, type ContactsRouteState } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useContactImportStore } from '@/stores/contact-import.store'
import { useContactsStore } from '@/stores/contacts.store'
interface Props {
@@ -35,10 +40,16 @@ interface Props {
const props = defineProps<Props>()
const store = useContactsStore()
const importStore = useContactImportStore()
const dialogs = useContactsDialogs()
const actions = useContactActions()
const router = useRouter()
/** 「전체 연락처」 머리의 [가져오기] · [내보내기]가 꺼진 까닭 (숨긴 글 — aria-describedby) */
const EXPORT_NOTE_ID = 'contacts-export-note'
/** 패널의 3,000개 안내 (ContactsPanel) — [가져오기]가 꺼진 까닭으로 함께 쓴다 */
const FULL_NOTE_ID = 'contacts-full-note'
/** 목록 줄 — 연락처 · 사내 항목을 같은 모양으로 */
interface ListRow {
id: string
@@ -93,6 +104,10 @@ const groupEmpty = computed(() => group.value !== null && group.value.memberCoun
const composing = computed(() => group.value !== null && store.composingGroupId === group.value.id)
/** 없는 그룹 — 다른 곳에서 지웠거나 주소에 남은 옛 id */
const missingGroup = computed(() => source.value.kind === 'group' && store.listError?.code === 'RES_001')
/** 연락처가 하나도 없다 — [내보내기]를 끈다 (수를 모르면 끄지 않는다) */
const nothingToExport = computed(() => store.contactTotal === 0)
/** 이 탭에서 가져오기 · 되돌리기가 도는 중 — [가져오기]의 바쁜 표시 */
const importing = computed(() => importStore.busy !== null)
function itemLocation(id: string) {
return contactsItemLocation(source.value, id, props.state.q)
@@ -117,6 +132,15 @@ function leaveToAll() {
function addContact() {
if (!store.isFull) dialogs.openContact(null)
}
/** [가져오기] — 3,000개 · 가져오는 중이면 아무 일이 없다 */
function openImport() {
if (!store.isFull && !importing.value) dialogs.openImport()
}
function openExport() {
if (!nothingToExport.value) dialogs.openExport()
}
</script>
<template>
@@ -166,6 +190,35 @@ function addContact() {
class="visually-hidden"
>{{ CONTACT_GROUP_MENU_TEXT.composeEmptyNote }}</span>
</div>
<div
v-else-if="source.kind === 'all'"
class="contacts-list__actions"
>
<MailToolButton
icon="upload"
:label="CONTACTS_TRANSFER_TEXT.import"
:unavailable="store.isFull"
:busy="importing"
:describedby="store.isFull ? FULL_NOTE_ID : undefined"
:title="store.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined"
data-contacts-import
@click="openImport"
/>
<MailToolButton
icon="download"
:label="CONTACTS_TRANSFER_TEXT.export"
:unavailable="nothingToExport"
:describedby="nothingToExport ? EXPORT_NOTE_ID : undefined"
:title="nothingToExport ? CONTACTS_TRANSFER_TEXT.exportEmptyNote : undefined"
data-contacts-export
@click="openExport"
/>
<span
v-if="nothingToExport"
:id="EXPORT_NOTE_ID"
class="visually-hidden"
>{{ CONTACTS_TRANSFER_TEXT.exportEmptyNote }}</span>
</div>
</div>
<p
v-if="directoryNote"
@@ -295,19 +348,39 @@ function addContact() {
:hint="CONTACTS_EMPTY_TEXT.firstHint"
data-contacts-empty="first"
>
<button
type="button"
class="contacts-list__primary-button"
:aria-disabled="store.isFull ? 'true' : undefined"
@click="addContact"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<div class="contacts-list__first-actions">
<button
type="button"
class="contacts-list__primary-button"
:aria-disabled="store.isFull ? 'true' : undefined"
@click="addContact"
>
<AppIcon
name="user-plus"
:size="15"
:stroke-width="2"
/>
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
</button>
<button
type="button"
class="contacts-list__outline-button contacts-list__outline-button--icon"
:aria-disabled="store.isFull ? 'true' : undefined"
:aria-busy="importing ? 'true' : undefined"
data-contacts-first-import
@click="openImport"
>
<AppIcon
name="upload"
:size="15"
:stroke-width="1.9"
/>
<span>{{ CONTACTS_TRANSFER_TEXT.import }}</span>
</button>
</div>
<p class="contacts-list__first-note contacts-list__first-note--import">
{{ CONTACTS_TRANSFER_TEXT.firstImportNote }}
</p>
<p class="contacts-list__first-note">
{{ CONTACTS_EMPTY_TEXT.firstNote }}
</p>
@@ -514,6 +587,43 @@ function addContact() {
color: var(--color-text-muted);
}
/* 빈 주소록의 단추 둘 — [연락처 추가] · 테두리 [가져오기] (디자인 원본 Contacts16 "1") */
.contacts-list__first-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-8);
margin-top: var(--space-6);
}
.contacts-list__first-actions .contacts-list__primary-button,
.contacts-list__first-actions .contacts-list__outline-button {
margin-top: 0;
}
.contacts-list__outline-button--icon {
display: inline-flex;
align-items: center;
gap: var(--space-8);
color: var(--color-text-strong);
}
.contacts-list__outline-button--icon[aria-disabled='true'],
.contacts-list__outline-button--icon[aria-disabled='true']:hover {
background: transparent;
color: var(--color-text-strong);
opacity: 0.5;
cursor: not-allowed;
}
.contacts-list__outline-button--icon[aria-busy='true'] {
cursor: progress;
}
.contacts-list__first-note--import {
margin-top: var(--space-6);
}
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.contacts-list {
@@ -1,4 +1,5 @@
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog)
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog),
// 가져오기 창의 그룹 칸(ContactImportGroupFields) ↔ 창(ContactImportDialog)
/** 그룹 줄 ⋯ 메뉴가 할 수 있는 일 */
export type ContactGroupAction = 'rename' | 'compose' | 'delete'
@@ -24,6 +25,9 @@ export interface ContactEntryPatch {
value?: string
}
/** 가져오기 창 — 가져온 연락처를 모두 넣을 그룹 (넣지 않음 · 이미 있는 그룹 · 새 그룹) */
export type ImportGroupMode = 'none' | 'existing' | 'new'
/** 키 하나를 뺀 기록 — 같은 주소 안내를 지울 때 */
export function withoutKey<T>(record: Readonly<Record<number, T>>, key: number): Record<number, T> {
const next: Record<number, T> = { ...record }
+18 -2
View File
@@ -8,6 +8,7 @@
// 초점이 든 알림이 닫히면(시간이 다 됨 — 마감이 있는 알림 포함 · 단추 · 넘침 · 세션이 바뀜) 초점을 알림에 들어오기 전 자리로 돌려준다 —
// 그 요소가 사라졌으면 화면의 main 으로 (키보드 사용자가 자리를 잃고 body 로 빠지지 않게 — 2026-10-29 리뷰)
// 단추는 한 번만 움직인다 — 닫히는 동안(퇴장 전환) 다시 눌러도 할 일을 또 하지 않는다 (toast.store 의 takeAction)
// ✕로 닫을 때까지 남는 알림(sticky — 16단계 주소록 가져오기 결과)은 성공 알림에도 ✕ 를 단다(브랜드 바탕용 색 — 디자인 원본 Contacts16 "4")
import { ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -150,10 +151,11 @@ watch(
{{ toast.action.label }}
</button>
<button
v-if="toast.kind === 'error'"
v-if="toast.kind === 'error' || toast.sticky"
type="button"
class="toast__close"
:class="['toast__close', { 'toast__close--on-brand': toast.kind !== 'error' }]"
aria-label="알림 닫기"
data-toast-close
@click="toastStore.dismiss(toast.id)"
>
<AppIcon
@@ -342,6 +344,20 @@ watch(
background: var(--color-surface-muted);
}
/* 브랜드 바탕(성공 알림) 위의 ✕ — 흐린 청록 글자, 마우스를 올리면 흰 글자 (디자인 원본 Contacts16 "4") */
.toast__close--on-brand {
color: var(--color-on-brand-muted);
}
.toast__close--on-brand:hover {
background: var(--color-on-brand-track);
color: var(--color-on-brand);
}
.toast__close--on-brand:focus-visible {
outline-color: var(--color-on-brand-link);
}
/* 인쇄하지 않는다 — 고정 위치라 남아 있으면 쪽마다 찍힌다 (9단계 인쇄 화면) */
@media print {
.toast-host {
@@ -9,6 +9,7 @@
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
// [임시 저장] 다음 자리(signature 슬롯)에 쓰기 화면이 [서명 ▾]를 둔다 (16단계 — ComposeSignatureMenu, 디자인 원본 Signature16 6)
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 까닭 #compose-send-why · 줄 .compose__footer
import { computed, nextTick, ref, watch } from 'vue'
@@ -191,6 +192,8 @@ watch(unavailable, (off) => {
>
임시 저장
</button>
<!-- [서명 ▾] (16단계) — 쓰기 화면이 넣는다 -->
<slot name="signature" />
<p
v-if="blockedReason"
:id="REASON_ID"
@@ -0,0 +1,237 @@
<script setup lang="ts">
// 쓰기 화면 [서명 ▾] (디자인 원본 Signature16 6 · README "16단계 — 여러 서명") — 아래 단추 줄의 [임시 저장] 다음.
// 단추는 [임시 저장]과 같은 글자 단추(44)에 ▾. 화면 낭독은 툴바의 글꼴 단추처럼 지금 본문에 든 서명까지 읽는다("서명: 대외" · "서명: 넣지 않음") —
// 서명 자리가 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) "서명"만.
// 메뉴는 편집기 메뉴(EditorMenu — 항목 40 · 폭 220): 서명(만든 차례 — 지금 본문에 든 서명에 ✓, "기본" 표시 없음) · 넣지 않음 · 가는 선 · 서명 관리.
// 서명이 없으면 넣지 않음과 서명 관리만. 키보드는 [보내기 ▾]와 같다 — Enter · Space · 아래 화살표로 열면 첫 항목에 초점, 마우스로 열면 메뉴 자체에,
// Esc 는 메뉴만 닫고 [서명 ▾]로 돌아간다. 아래 자리가 모자라면 위로 연다(usePopupPlacement — 아래 단추 줄에서는 보통 위).
// 고르면 select(서명 id · 넣지 않음은 null), 서명 관리는 manage — 본문의 서명 자리를 바꾸는 일과 낭독 글(announcement)은 쓰기 화면이 정한다.
// 고정 id · 이름 — 단추 #compose-signature · 메뉴 .compose-signature__menu · 낭독 [data-signature-status]
import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
import type { ComposeSignatureState } from '@/shared/types/mail-signature'
interface SignatureChoice {
id: string
name: string
}
interface Props {
/** 내 서명 — 만든 차례 */
signatures: readonly SignatureChoice[]
/** 지금 본문에 든 서명 */
state: ComposeSignatureState
/** 누를 수 없다 — 편집기를 불러오는 중 · 보내는 중 등 */
disabled?: boolean
/** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 (role=status) */
announcement?: string
}
interface Emits {
/** 서명을 골랐다 — 넣지 않음은 null */
(e: 'select', id: string | null): void
/** 서명 관리 — 설정 › 메일 › 서명으로 */
(e: 'manage'): void
/** 메뉴를 열었다 — 서명을 아직 받지 못했으면 다시 받아 본다 */
(e: 'open'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
announcement: '',
})
const emit = defineEmits<Emits>()
/** 메뉴 항목 id — 서명 id(서버가 만든 값)와 겹치지 않게 앞에 붙인다 */
const SIGNATURE_ITEM_PREFIX = 'signature:'
const NONE_ITEM = 'none'
const MANAGE_ITEM = 'manage'
/** 키보드로 열면 첫 항목부터 — 켜진(✓) 항목이 있어도 */
const FIRST_ITEM = '[role^="menuitem"]'
const buttonRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const menuOpen = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
/** 위로 연다 — 단추 아래가 모자랄 때 (메뉴를 그린 직후 · 초점을 주기 전에 잰다) */
const { upward } = usePopupPlacement({
open: menuOpen,
trigger: () => buttonRef.value,
popup: () => {
const menu: unknown = menuRef.value?.$el
return menu instanceof HTMLElement ? menu : null
},
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
/** 단추의 화면 낭독 이름 — "서명: 대외" · "서명: 넣지 않음" · 고친 서명이면 "서명" */
const buttonLabel = computed(() => {
const { state } = props
if (state.kind === 'none') return COMPOSE_SIGNATURE_TEXT.buttonLabel(COMPOSE_SIGNATURE_TEXT.none)
if (state.kind === 'signature') {
const current = props.signatures.find((signature) => signature.id === state.id)
if (current) return COMPOSE_SIGNATURE_TEXT.buttonLabel(current.name)
}
return COMPOSE_SIGNATURE_TEXT.button
})
const items = computed<EditorMenuItem[]>(() => {
const { state } = props
return [
...props.signatures.map((signature) => ({
id: `${SIGNATURE_ITEM_PREFIX}${signature.id}`,
role: 'menuitemradio' as const,
label: signature.name,
checked: state.kind === 'signature' && state.id === signature.id,
})),
{
id: NONE_ITEM,
role: 'menuitemradio' as const,
label: COMPOSE_SIGNATURE_TEXT.none,
checked: state.kind === 'none',
},
menuSeparator('separator'),
{ id: MANAGE_ITEM, label: COMPOSE_SIGNATURE_TEXT.manage },
]
})
function openMenu(keyboard: boolean) {
if (props.disabled) return
byKeyboard.value = keyboard
menuOpen.value = true
emit('open')
}
function onClick(event: MouseEvent) {
if (menuOpen.value) {
menuOpen.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || menuOpen.value) return
event.preventDefault()
openMenu(true)
}
function onMenuClose(restoreFocus: boolean) {
menuOpen.value = false
if (restoreFocus) buttonRef.value?.focus()
}
function onSelect(id: string) {
menuOpen.value = false
buttonRef.value?.focus()
if (id === MANAGE_ITEM) emit('manage')
else if (id === NONE_ITEM) emit('select', null)
else if (id.startsWith(SIGNATURE_ITEM_PREFIX)) emit('select', id.slice(SIGNATURE_ITEM_PREFIX.length))
}
// 보내는 중 · 양식을 새로 채움 등으로 꺼지면 메뉴도 닫는다
watch(
() => props.disabled,
(off) => {
if (off) menuOpen.value = false
},
)
</script>
<template>
<div class="compose-signature">
<button
id="compose-signature"
ref="buttonRef"
type="button"
class="compose-signature__button"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:aria-label="buttonLabel"
:disabled="disabled"
@click="onClick"
@keydown="onKeydown"
>
<span>{{ COMPOSE_SIGNATURE_TEXT.button }}</span>
<AppIcon
name="chevron-down"
:size="12"
:stroke-width="2.25"
/>
</button>
<EditorMenu
v-if="menuOpen"
ref="menuRef"
:class="['compose-signature__menu', { 'compose-signature__menu--up': upward }]"
:label="COMPOSE_SIGNATURE_TEXT.menuLabel"
:items="items"
:trigger="trigger"
:focus-content="!byKeyboard"
:initial-focus="FIRST_ITEM"
@select="onSelect"
@close="onMenuClose"
/>
<span
class="visually-hidden"
role="status"
data-signature-status
>{{ announcement }}</span>
</div>
</template>
<style scoped>
/* 메뉴가 이 단추 아래(모자라면 위)에서 열린다 */
.compose-signature {
position: relative;
display: inline-flex;
}
/* [임시 저장]과 같은 글자 단추 44 + ▾ (디자인 원본 Signature16 6) */
.compose-signature__button {
display: inline-flex;
align-items: center;
gap: var(--space-4);
min-height: var(--size-touch);
padding: 0 var(--space-10) 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.compose-signature__button:hover:not(:disabled),
.compose-signature__button[aria-expanded='true'] {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose-signature__button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 메뉴 폭 220 — 긴 이름은 한 줄로 줄인다 */
.compose-signature__menu {
width: 13.75rem;
box-sizing: border-box;
}
.compose-signature__menu :deep(.menu__label) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
.compose-signature .compose-signature__menu--up {
top: auto;
bottom: calc(100% + var(--space-4));
}
</style>
@@ -19,6 +19,11 @@ interface Props {
align?: 'start' | 'end'
/** 마우스로 열었다 — 항목 대신 메뉴 자체에 초점 (EditorPopup) */
focusContent?: boolean
/**
* 키보드로 열 때 초점을 줄 항목 (선택자) — 주지 않으면 켜진 항목, 그다음 첫 항목 (EditorPopup).
* 메뉴 단추(쓰기 화면 [서명 ▾] — 16단계)는 켜진 항목이 있어도 첫 항목에서 시작한다
*/
initialFocus?: string
}
interface Emits {
@@ -30,6 +35,7 @@ interface Emits {
withDefaults(defineProps<Props>(), {
align: 'start',
focusContent: false,
initialFocus: '',
})
const emit = defineEmits<Emits>()
@@ -66,6 +72,7 @@ function select(item: EditorMenuItem, event: MouseEvent) {
:trigger="trigger"
:align="align"
:focus-content="focusContent"
:initial-focus="initialFocus"
@close="(restore) => emit('close', restore)"
>
<template
@@ -10,6 +10,8 @@
// 맞춘 내용으로 편집기를 만들므로 실행 취소 기록은 맞춘 내용에서 시작한다.
// 보내기 직전에 다시 재어(getContentForSend) 넘으면 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않는다.
// - 붙여 넣거나 끌어다 놓은 파일은 본문에 넣지 않고 files 로 넘긴다 — 쓰기 화면이 첨부로 올린다.
// - 서명 자리(16단계): 쓰기 화면의 [서명 ▾]가 replaceSignature 로 그 자리만 바꾸고, 어느 서명이 들었는지는
// signaturePlace · signatureFingerprint 로 견준다 (mail-signature-block.ts — 앱과 같은 경계라 저장본에서도 다시 찾는다).
// - 편집 영역(.compose-field)의 아래 선이 포커스 선이다 (styles/base.css). 첨부 영역의 위 선은 없다.
import {
Editor,
@@ -34,6 +36,13 @@ import type {
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToTextWithQuoteStart, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
findSignatureBlock,
replaceSignatureTransaction,
signatureFingerprintOf,
signatureNodeOf,
withSignatureBoundary,
} from '@/components/mail/editor/mail-signature-block'
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'
@@ -150,7 +159,9 @@ const editor = markRaw(
* 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트. 원문 인용이 있으면 서식 본문에 인용 경계 표시를 두고
* 텍스트에서 인용이 시작하는 자리를 함께 준다 (11단계 — 대용량 첨부 덩이 자리)
*/
function contentOf(doc: ProseMirrorNode): MailEditorContent {
function contentOf(edited: ProseMirrorNode): MailEditorContent {
// 서명 자리 앞의 빈 줄을 지킨다 — 한글 조합 중에 빠진 채 저장 · 보내도 다시 열 때 서명 자리를 찾게 (mail-signature-block.ts)
const doc = withSignatureBoundary(edited)
const { text, quoteStart } = docToTextWithQuoteStart(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
@@ -235,6 +246,26 @@ function markSaved(savePoint: EditorSavePoint) {
opened = { doc, settled: doc }
}
/** 본문 안 서명 자리의 비교 값 — 없으면 null (16단계 [서명 ▾], mail-signature-block.ts) */
function signaturePlace(): string | null {
const found = findSignatureBlock(editor.state.doc)
return found ? signatureFingerprintOf(found.node) : null
}
/** 서명 HTML 의 비교 값 — 편집기 스키마로 읽은 모양으로 (읽을 것이 없으면 빈 글) */
function signatureFingerprint(html: string): string {
const node = signatureNodeOf(editor.schema, html)
return node ? signatureFingerprintOf(node) : ''
}
/** 서명 자리만 바꾼다 — 실행 취소로 되돌린다. 서명 글의 주소를 자동 링크로 바꾸지 않는다 (서명에 있는 그대로) */
function replaceSignature(html: string | null) {
if (!mounted) return
const node = html === null ? null : signatureNodeOf(editor.schema, html)
const tr = replaceSignatureTransaction(editor.state, node)
if (tr?.docChanged) editor.view.dispatch(tr.setMeta('preventAutolink', true))
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -273,7 +304,17 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus, isChanged, captureForSave, markSaved })
defineExpose({
getContent,
getContentForSend,
focus,
isChanged,
captureForSave,
markSaved,
signaturePlace,
signatureFingerprint,
replaceSignature,
})
</script>
<template>
@@ -59,6 +59,18 @@ export interface MailEditorHandle {
captureForSave: () => MailEditorSnapshot | null
/** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
markSaved: (savePoint: EditorSavePoint) => void
/**
* 본문 안 서명 자리의 비교 값 — 서명 자리가 없으면 null (16단계 [서명 ▾] — 어느 서명이 들었는지 signatureFingerprint 와 견준다).
* 글(공백을 하나로)과 그림으로 만든 값이라 서명 안을 고쳤으면 어느 서명과도 같지 않다
*/
signaturePlace: () => string | null
/** 서명 HTML 의 비교 값 — signaturePlace 와 같은 규칙(편집기 스키마로 읽은 모양) */
signatureFingerprint: (html: string) => string
/**
* 서명 자리만 바꾼다 — 쓴 글 · 인용은 그대로이고 실행 취소로 되돌린다. html 이 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다.
* 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다
*/
replaceSignature: (html: string | null) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -3,6 +3,7 @@
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// 서명 자리(16단계 — [서명 ▾] 가 바꾸는 자리)는 블록 노드 하나다 — 빈 줄 뒤의 속성 없는 div 로 나가고 들어온다 (mail-signature-block.ts).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -23,6 +24,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
import { MailSignatureBlock } from '@/components/mail/editor/mail-signature-block'
import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
@@ -266,6 +268,7 @@ export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {})
MailTableCell,
MailTableHeader,
SignatureImage,
MailSignatureBlock,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
@@ -0,0 +1,266 @@
import { Editor, createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { EditorState, TextSelection } from '@tiptap/pm/state'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
MAIL_SIGNATURE_NODE,
findSignatureBlock,
replaceSignatureTransaction,
signatureFingerprintOf,
signatureNodeOf,
withSignatureBoundary,
} from '@/components/mail/editor/mail-signature-block'
import { buildQuoteHtml } from '@/shared/utils/mail-quote'
import { newMailHtmlWithSignature, signatureBlockHtml } from '@/shared/utils/mail-signature'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
// 쓰기 본문의 서명 자리 (16단계 — 디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
const schema = getSchema(createMailEditorExtensions())
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
/** 서버가 정화해 준 서명 HTML 모양 — 글꼴 div 로 감쌌다 */
const OUTSIDE =
'<div style="font-family: \'Malgun Gothic\'; font-size: 14px; line-height: 1.75">' +
'<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<p style="margin: 0">ARA SYSTEM · <img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`
const INSIDE = '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>'
/** 보낸 본문의 감싸개 (toMailHtml · 앱 kMailBodyWrapperStyle) */
const WRAPPER_STYLE = "font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75"
function doc(html: string): ProseMirrorNode {
return createDocument(normalizeIncomingHtml(html), schema)
}
/** 맨 위 단계 블록의 모양 — 서명 자리는 [서명:글], 원문 인용은 [인용], 빈 문단은 '' */
function blocks(node: ProseMirrorNode): string[] {
const out: string[] = []
node.forEach((child) => {
if (child.type.name === MAIL_SIGNATURE_NODE) out.push(`[서명:${child.textContent}]`)
else if (child.type.name === 'blockquote') out.push('[인용]')
else out.push(child.textContent)
})
return out
}
/** 보내는 모양 — 쓰기 화면과 같은 길 (getHTML → toMailHtml) */
function mailHtml(node: ProseMirrorNode): string {
return toMailHtml(getHTMLFromFragment(node.content, schema))
}
/** 서버의 보내는 정화가 하는 일 중 경계에 닿는 것 — data-* 를 지운다 (sanitize-outgoing-html.ts) */
function sanitizeLikeServer(html: string): string {
return html.replace(/ data-[a-z-]+="[^"]*"/g, '')
}
function apply(node: ProseMirrorNode, signatureHtml: string | null): ProseMirrorNode {
const state = EditorState.create({ doc: node, schema })
const signature = signatureHtml === null ? null : signatureNodeOf(schema, signatureHtml)
const tr = replaceSignatureTransaction(state, signature)
return tr ? state.apply(tr).doc : node
}
describe('서명 자리 — 읽기', () => {
it('새 메일은 쓸 줄 · 빈 줄 하나 · 서명 자리 — 서명 안의 글꼴 div 는 풀고 문단만 둔다', () => {
const node = doc(newMailHtmlWithSignature(OUTSIDE))
expect(blocks(node)).toEqual(['', '', '[서명:김아라 · 인프라팀ARA SYSTEM · ]'])
expect(findSignatureBlock(node)?.index).toBe(2)
})
it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 자리 · 인용', () => {
const html = buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(INSIDE))
expect(blocks(doc(html))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
})
it('보낸(저장한) 본문은 빈 문단 뒤 속성 없는 div 로 나가고, 서버가 정화한 뒤 다시 열어도 그 자리를 찾는다', () => {
const node = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(INSIDE)))
const sent = mailHtml(node)
// 앱과 같은 경계 — 빈 문단(<br>) 바로 뒤의 속성 없는 div, 안의 문단은 margin: 0
expect(sent).toContain(
'<p style="margin: 0"><br></p><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p></div>',
)
const reopened = doc(sanitizeLikeServer(sent))
expect(blocks(reopened)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
const before = findSignatureBlock(node)
const after = findSignatureBlock(reopened)
expect(before && after && signatureFingerprintOf(after.node)).toBe(before && signatureFingerprintOf(before.node))
})
it('앱이 만든 본문(compose_body.dart)도 서명 자리로 읽는다 — 감싸개 · 빈 문단 · 서명 div · 원문 인용', () => {
const app =
`<div style="${WRAPPER_STYLE}">` +
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br></p>' +
'<div><p style="margin: 0">김아라 · 인프라팀</p></div>' +
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px"><p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>' +
'<table style="margin: 0 0 12px"><tbody><tr><td>제목</td><td>일정</td></tr></tbody></table><p style="margin: 0">원문</p></blockquote></div>'
expect(blocks(doc(app))).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', '[인용]'])
})
it.each([
['빈 문단 뒤가 아니다', '<p>글</p><div><p>서명 같은 글</p></div>'],
['속성이 있다', '<p>글</p><p></p><div style="color: #344054"><p>꾸민 div</p></div>'],
['글도 그림도 없다(인용 경계 표시가 남은 빈 div)', '<p>글</p><p></p><div></div><p>다음</p>'],
['원문 인용 안이다', '<p>글</p><blockquote><p>원본 메일</p><p></p><div><p>남의 서명</p></div></blockquote>'],
])('속성 없는 div 라도 서명 자리가 아니다 — %s', (_label, html) => {
expect(findSignatureBlock(doc(html))).toBeNull()
})
})
describe('서명 자리 — 바꾸기 (replaceSignatureTransaction)', () => {
const NEW_MAIL = newMailHtmlWithSignature(OUTSIDE)
it('다른 서명으로 — 그 자리만 바뀌고 쓴 글 · 인용은 그대로', () => {
const reply = buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', signatureBlockHtml(OUTSIDE))
const written = doc(reply.replace('<p></p>', '<p>보내 주신 견적서 잘 받았습니다.</p>'))
const changed = apply(written, INSIDE)
expect(blocks(changed)).toEqual(['보내 주신 견적서 잘 받았습니다.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
// 텍스트 대체 본문 — 내 글 · 빈 줄 · 서명 · 인용 줄 (서명 뒤에는 빈 줄이 없다 — 앱과 같다)
expect(docToText(changed)).toBe(
'보내 주신 견적서 잘 받았습니다.\n\n김아라 · 인프라팀\n내선 1234\n> 원본 메일\n> 제목: 일정\n>\n> 원문',
)
})
it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 뺀다', () => {
expect(blocks(apply(doc(NEW_MAIL.replace('<p></p>', '<p>안녕하세요.</p>')), null))).toEqual(['안녕하세요.'])
})
it('서명 자리가 없으면 처음 넣는 자리에 — 새 메일은 쓴 글 끝에 빈 줄 하나 뒤로 (있는 빈 줄을 쓴다)', () => {
expect(blocks(apply(doc('<p>안녕하세요.</p>'), INSIDE))).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]'])
expect(blocks(apply(doc('<p>안녕하세요.</p><p></p><p></p>'), INSIDE))).toEqual([
'안녕하세요.',
'',
'[서명:김아라 · 인프라팀내선 1234]',
'',
])
// 아무것도 쓰지 않았다 — 쓸 줄 · 빈 줄 · 서명 (처음 넣는 모양과 같다)
expect(blocks(apply(doc('<p></p>'), INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]'])
})
it('서명 자리가 없으면 처음 넣는 자리에 — 답장 · 전달은 인용 위', () => {
const reply = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>'))
expect(blocks(apply(reply, INSIDE))).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
const written = doc(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>').replace('<p></p>', '<p>네.</p>'))
expect(blocks(apply(written, INSIDE))).toEqual(['네.', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
})
it('서명 자리도 없고 넣지 않음이면 바꿀 것이 없다', () => {
const state = EditorState.create({ doc: doc('<p>글</p>'), schema })
expect(replaceSignatureTransaction(state, null)).toBeNull()
})
})
describe('어느 서명이 들었나 (signatureFingerprintOf)', () => {
it('같은 서명은 같은 값 — 서명 안을 고치면 달라진다, 공백 · nbsp 는 하나로 본다', () => {
const inserted = findSignatureBlock(doc(newMailHtmlWithSignature(INSIDE)))
const fresh = signatureNodeOf(schema, INSIDE)
expect(inserted && fresh && signatureFingerprintOf(inserted.node)).toBe(fresh && signatureFingerprintOf(fresh))
const spaced = signatureNodeOf(schema, '<p>김아라 \u00a0· 인프라팀</p>')
const plain = signatureNodeOf(schema, '<p>김아라 · 인프라팀</p>')
expect(spaced && signatureFingerprintOf(spaced)).toBe(plain && signatureFingerprintOf(plain))
const edited = signatureNodeOf(schema, INSIDE.replace('1234', '5678'))
expect(edited && signatureFingerprintOf(edited)).not.toBe(fresh && signatureFingerprintOf(fresh))
})
it('그림도 견준다 — 글이 같아도 그림이 다르면 다른 서명', () => {
const withLogo = signatureNodeOf(schema, OUTSIDE)
const withoutLogo = signatureNodeOf(schema, OUTSIDE.replace(/<img[^>]*>/, ''))
expect(withLogo && signatureFingerprintOf(withLogo)).not.toBe(withoutLogo && signatureFingerprintOf(withoutLogo))
})
})
describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-11-10 리뷰 M3)', () => {
const editors: Editor[] = []
beforeAll(installProseMirrorDom)
afterEach(() => {
for (const editor of editors.splice(0)) editor.destroy()
})
/** 쓰기 화면의 편집기와 같게 — 플러그인(서명 자리 지키기)은 화면에 붙일 때 켜진다 */
function mountedEditor(content: string): Editor {
const editor = new Editor({ element: null, extensions: createMailEditorExtensions(), content })
editors.push(editor)
editor.mount(document.createElement('div'))
return editor
}
/** 서명 앞 빈 줄 안에 커서 — 맨 위 단계 index 번째 문단의 처음 */
function cursorInto(editor: Editor, index: number) {
let pos = 0
for (let at = 0; at < index; at += 1) pos += editor.state.doc.child(at).nodeSize
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 1)))
}
/** 저장하고 다시 연 모양 — 보내는 모양(빈 줄을 지킨 문서 → toMailHtml)에서 서버 정화처럼 data-* 를 지우고 다시 읽는다 */
function reopened(editor: Editor): ProseMirrorNode {
const html = toMailHtml(getHTMLFromFragment(withSignatureBoundary(editor.state.doc).content, schema))
return doc(sanitizeLikeServer(html))
}
const WRITTEN = newMailHtmlWithSignature(INSIDE).replace('<p></p>', '<p>안녕하세요.</p>')
const SIGNED = '[서명:김아라 · 인프라팀내선 1234]'
it('서명 자리가 하나 더 들어오면(서명 안에서 복사해 붙여 넣기 등) 처음부터 있던 것만 두고 나머지는 문단으로 푼다', () => {
const editor = mountedEditor(newMailHtmlWithSignature(INSIDE).replace('<p></p>', '<p>첫 줄</p>'))
editor.commands.insertContentAt(0, signatureBlockHtml('<p>복사한 서명</p>'))
const signatures: string[] = []
editor.state.doc.forEach((child) => {
if (child.type.name === MAIL_SIGNATURE_NODE) signatures.push(child.textContent)
})
expect(signatures).toEqual(['김아라 · 인프라팀내선 1234'])
expect(editor.state.doc.textContent).toContain('복사한 서명')
})
it('서명 위 빈 줄에 글을 쓰면 그 아래 · 서명 위에 빈 줄을 다시 둔다 — 커서는 쓰던 자리, 실행 취소는 입력과 함께, 다시 열어도 서명 자리', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.commands.insertContent('감사합니다.')
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
expect(editor.state.selection.$from.parent.textContent).toBe('감사합니다.')
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감사합니다.', '', SIGNED])
editor.commands.undo()
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 둔다 — 쓴 글은 그대로, 커서는 윗줄 끝, 다시 열어도 서명 자리', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.commands.keyboardShortcut('Backspace')
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
expect(editor.state.selection.$from.parent.textContent).toBe('안녕하세요.')
expect(editor.state.selection.$from.parentOffset).toBe('안녕하세요.'.length)
expect(blocks(reopened(editor)).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 조합이 끝난 다음 입력에서 편집기도 채운다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.view.dispatch(editor.state.tr.insertText('감').setMeta('composition', 1))
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '감', SIGNED])
expect(blocks(withSignatureBoundary(editor.state.doc)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '감', '', SIGNED])
editor.commands.insertContent(' ')
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감 ', '', SIGNED])
})
it('빈 줄이 있으면 그대로 — 앱이 만든 저장본 모양도 바꾸지 않는다', () => {
const app = doc(
`<div style="${WRAPPER_STYLE}">` +
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br></p>' +
'<div><p style="margin: 0">김아라 · 인프라팀</p></div></div>',
)
expect(withSignatureBoundary(app)).toBe(app)
expect(mailHtml(app)).toContain('<p style="margin: 0"><br></p><div><p style="margin: 0">김아라 · 인프라팀</p></div>')
})
})
@@ -0,0 +1,269 @@
// 쓰기 본문의 서명 자리 — 16단계 [서명 ▾] (디자인 원본 Signature16 6 · 계약 2절 "앱이 만드는 모양")
// 서명 자리는 빈 줄 하나 뒤의 서명 덩어리다. 편집기 안에서는 블록 노드 하나(mailSignature — 안은 서명의 문단들)이고,
// 보낼 때는 속성 없는 <div> 로 나간다 — 앱과 같은 경계라 임시 저장본을 다시 열어도(웹 · 앱 모두) 그 자리를 다시 찾는다.
// - 읽기: 쓰기 화면이 처음 넣는 서명은 표시를 단 div(data-mail-signature — mail-signature.ts 의 signatureBlockHtml),
// 저장본 · 보내기 취소로 되돌린 메일은 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"(서버 정화가 data-* 를 지운다).
// 맨 위 단계 = 문서 바로 아래 · 또는 문서에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래. 원문 인용 안의 div 는 서명 자리가 아니다.
// - 서명 자리는 하나다 — 붙여 넣기 · 끌어 놓기로 하나 더 생기면 처음부터 있던 것만 두고 나머지는 문단으로 푼다(appendTransaction).
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미룬다),
// 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf). 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
// - 바꾸기(replaceSignatureTransaction): 그 자리만 새 서명으로 — 쓴 글 · 인용 · 첨부는 그대로이고 실행 취소로 되돌린다.
// 넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다.
// - 어느 서명이 들었는지(signatureFingerprintOf)는 글(공백을 하나로 줄여)과 그림 주소로 견준다 — 고쳤으면 어느 서명과도 같지 않다.
import { Node, createDocument } from '@tiptap/core'
import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
import { blockLines, normalizeIncomingHtml } from '@/components/mail/editor/mail-serialize'
import { SIGNATURE_IMAGE_NODE } from '@/components/mail/editor/signature-image'
import { MAIL_SIGNATURE_MARK_ATTRIBUTE } from '@/shared/constants/mail-signature'
import { signatureBlockHtml } from '@/shared/utils/mail-signature'
import { normalizeSignatureImageSrc } from '@/shared/utils/outgoing-html-rules'
export const MAIL_SIGNATURE_NODE = 'mailSignature'
/** 빈 줄로 보는 공백 — JS 의 \s 는 nbsp(U+00A0)도 포함한다 */
const BLANK = /^\s*$/
/** 글도 그림도 없는 문단인가 (DOM) — <p></p> · <p><br></p> · 공백뿐 */
function isEmptyParagraphElement(element: Element | null): boolean {
return (
element !== null &&
element.tagName === 'P' &&
BLANK.test(element.textContent ?? '') &&
element.querySelector('img') === null
)
}
/** 맨 위 단계인가 — 문서(body) 바로 아래, 또는 body 에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래 */
function isTopLevelElement(element: HTMLElement): boolean {
const parent = element.parentElement
if (!parent) return false
if (parent.tagName === 'BODY') return true
const grand = parent.parentElement
return (
parent.tagName === 'DIV' &&
!parent.hasAttribute(MAIL_SIGNATURE_MARK_ATTRIBUTE) &&
grand?.tagName === 'BODY' &&
grand.children.length === 1
)
}
/**
* 저장본의 서명 경계인가 — 맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다 (계약 2절 "앱이 만드는 모양").
* 인용 경계 표시(대용량 첨부)가 남은 빈 div 는 서명이 아니다
*/
export function isSignatureBoundaryElement(element: HTMLElement): boolean {
if (element.tagName !== 'DIV' || element.attributes.length > 0) return false
if (!isTopLevelElement(element)) return false
if (!isEmptyParagraphElement(element.previousElementSibling)) return false
return !BLANK.test(element.textContent ?? '') || element.querySelector('img') !== null
}
/** 글도 그림도 없는 문단인가 (편집기 문서) */
export function isEmptyParagraphNode(node: ProseMirrorNode): boolean {
if (node.type.name !== 'paragraph') return false
let empty = BLANK.test(node.textContent)
if (empty) {
node.descendants((child) => {
if (child.type.name === SIGNATURE_IMAGE_NODE) empty = false
return empty
})
}
return empty
}
/** 문서 맨 위 단계의 서명 자리 — 없으면 null */
export function findSignatureBlock(doc: ProseMirrorNode): { node: ProseMirrorNode; pos: number; index: number } | null {
let pos = 0
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (child.type.name === MAIL_SIGNATURE_NODE) return { node: child, pos, index }
pos += child.nodeSize
}
return null
}
/** 서명 비교 값 — 글(공백을 하나로)과 그림 주소. 같은 서명을 넣은 자리 · 저장본에서 되찾은 자리가 같은 값이 된다 */
export function signatureFingerprintOf(node: ProseMirrorNode): string {
const text = blockLines(node).join('\n').replace(/\s+/g, ' ').trim()
const images: string[] = []
node.descendants((child) => {
if (child.type.name === SIGNATURE_IMAGE_NODE) images.push(normalizeSignatureImageSrc(child.attrs.src) ?? '')
return true
})
return `${text}\n${images.join(' ')}`
}
/** 서명 HTML → 서명 자리 노드 (편집기 스키마로 읽는다 — 스키마에 없는 것은 빠진다). 읽을 것이 없으면 null */
export function signatureNodeOf(schema: Schema, html: string): ProseMirrorNode | null {
const doc = createDocument(signatureBlockHtml(normalizeIncomingHtml(html)), schema)
const first = doc.firstChild
return first?.type.name === MAIL_SIGNATURE_NODE ? first : null
}
/** 맨 위 단계의 첫 원문 인용 자리 — 없으면 null */
function mailQuoteIndex(doc: ProseMirrorNode): number | null {
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (child.type.name === 'blockquote' && child.attrs.mailQuote) return index
}
return null
}
/** 맨 위 단계 index 번째 블록이 시작하는 자리 */
function positionOfIndex(doc: ProseMirrorNode, index: number): number {
let pos = 0
for (let at = 0; at < index && at < doc.childCount; at += 1) pos += doc.child(at).nodeSize
return pos
}
/**
* 서명 자리가 없을 때 넣을 자리와 넣을 것 — 처음 넣는 자리와 같다: 새 메일은 본문 끝, 답장 · 전달은 원문 인용 위.
* 쓴 글의 마지막 블록 뒤에 빈 줄 하나 · 서명 (그 자리에 이미 빈 줄이 있으면 그것을 쓴다). 쓴 글이 없으면 쓸 줄 · 빈 줄 · 서명
*/
function insertionOf(doc: ProseMirrorNode): { index: number; blankLines: number } {
const limit = mailQuoteIndex(doc) ?? doc.childCount
let last = -1
for (let index = 0; index < limit; index += 1) {
if (!isEmptyParagraphNode(doc.child(index))) last = index
}
if (last < 0) {
// 쓴 글이 없다 — 쓸 줄과 빈 줄 하나를 둔다 (있는 빈 줄을 먼저 쓴다)
const present = Math.min(limit, 2)
return { index: present, blankLines: 2 - present }
}
const after = last + 1
const reuse = after < limit && isEmptyParagraphNode(doc.child(after))
return reuse ? { index: after + 1, blankLines: 0 } : { index: after, blankLines: 1 }
}
/**
* 서명 자리를 바꾸는 거래 — signature 가 null 이면 서명 자리와 그 앞의 빈 줄을 뺀다(없으면 바꿀 것이 없다 — null).
* 서명 자리가 없으면 처음 넣는 자리에 넣는다
*/
export function replaceSignatureTransaction(state: EditorState, signature: ProseMirrorNode | null): Transaction | null {
const { doc, schema } = state
const found = findSignatureBlock(doc)
const tr = state.tr
if (found && signature) {
tr.replaceWith(found.pos, found.pos + found.node.nodeSize, signature)
return tr
}
if (found) {
const before = found.index > 0 ? doc.child(found.index - 1) : null
const start = before && isEmptyParagraphNode(before) ? found.pos - before.nodeSize : found.pos
tr.delete(start, found.pos + found.node.nodeSize)
return tr
}
const paragraph = schema.nodes.paragraph
if (!signature || !paragraph) return null
const { index, blankLines } = insertionOf(doc)
const blanks = Array.from({ length: blankLines }, () => paragraph.create())
tr.insert(positionOfIndex(doc, index), Fragment.fromArray([...blanks, signature]))
return tr
}
/** 서명 자리 바로 앞이 빈 줄이 아니면 그 서명 자리 — 그 앞에 빈 줄을 넣어야 경계가 선다. 서명 자리가 없거나 이미 빈 줄이면 null */
function blockMissingBlankLine(doc: ProseMirrorNode): { pos: number; index: number } | null {
const found = findSignatureBlock(doc)
if (!found) return null
const before = found.index > 0 ? doc.child(found.index - 1) : null
return before && isEmptyParagraphNode(before) ? null : found
}
/**
* 보내고 저장할 문서 — 서명 자리 바로 앞에 빈 줄이 없으면 하나 넣은 문서 (있으면 그대로).
* 편집기는 늘 빈 줄을 지키지만(아래 appendTransaction) 한글 조합 중에는 미루므로, 보내는 · 저장하는 모양에서 한 번 더 지킨다 —
* 빈 줄이 빠지면 다시 열 때 서명 자리를 찾지 못한다(계약 2절 "앱이 만드는 모양"의 경계). 쓴 글은 지우지 않는다
*/
export function withSignatureBoundary(doc: ProseMirrorNode): ProseMirrorNode {
const missing = blockMissingBlankLine(doc)
const paragraph = doc.type.schema.nodes.paragraph
if (!missing || !paragraph) return doc
const children: ProseMirrorNode[] = []
doc.forEach((child, _offset, index) => {
if (index === missing.index) children.push(paragraph.create())
children.push(child)
})
return doc.copy(Fragment.fromArray(children))
}
const signatureBlockKey = new PluginKey('mailSignatureBlock')
/**
* 서명 자리는 하나 — 붙여 넣기(서명 안에서 복사한 글은 서명 자리째 붙는다) 등으로 둘 이상이 되면 처음부터 있던 것만 두고 나머지는 문단으로 푼다.
* 처음부터 있던 것을 모르면(없었거나 지워졌다) 첫 것을 둔다
*/
function unwrapExtraSignatures(
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
tr: Transaction,
) {
const blocks: { node: ProseMirrorNode; pos: number }[] = []
newState.doc.forEach((child, offset) => {
if (child.type.name === MAIL_SIGNATURE_NODE) blocks.push({ node: child, pos: offset })
})
if (blocks.length < 2) return
const previous = findSignatureBlock(oldState.doc)
let kept = 0
if (previous) {
let mapped: number | null = previous.pos
for (const transaction of transactions) {
const result = transaction.mapping.mapResult(mapped)
mapped = result.deleted ? null : result.pos
if (mapped === null) break
}
const index = blocks.findIndex((block) => block.pos === mapped)
if (index >= 0) kept = index
}
// 뒤에서부터 풀어 앞 블록의 자리가 바뀌지 않게
for (let index = blocks.length - 1; index >= 0; index -= 1) {
if (index === kept) continue
const block = blocks[index]
if (block) tr.replaceWith(block.pos, block.pos + block.node.nodeSize, block.node.content)
}
}
/**
* 서명 자리 지키기 — 문서가 바뀐 거래 뒤에 (1) 서명 자리를 하나로 (2) 서명 자리 바로 앞에 빈 줄을 둔다.
* (2)는 그 빈 줄에 글을 쓰거나(Backspace · Delete 로) 빈 줄을 지웠을 때 빈 줄을 다시 넣는 것이다 — 쓴 글은 그대로 두고 그 아래 · 서명 위에 새 빈 줄,
* 커서는 쓰던 자리 그대로, 실행 취소는 그 입력과 한 번에 되돌린다(덧붙인 거래는 입력과 같은 기록 묶음).
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 다음 거래 · 보내고 저장하는 모양(withSignatureBoundary)이 채운다
*/
function guardSignatureBlock(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
const tr = newState.tr
unwrapExtraSignatures(transactions, oldState, newState, tr)
const composing = transactions.some((transaction) => Boolean(transaction.getMeta('composition')))
const paragraph = newState.schema.nodes.paragraph
const missing = composing ? null : blockMissingBlankLine(tr.doc)
if (missing && paragraph) tr.insert(missing.pos, paragraph.create())
return tr.docChanged ? tr : null
}
/** 서명 자리 노드 — 쓰기 본문 스키마(mail-extensions.ts)에 든다. 서명 칸(SignatureEditor) 스키마에는 없다 */
export const MailSignatureBlock = Node.create({
name: MAIL_SIGNATURE_NODE,
group: 'block',
content: 'block+',
parseHTML() {
return [
// 쓰기 화면이 처음 넣는 서명 — 표시로 안다 (속성 없는 div 규칙보다 먼저)
{ tag: `div[${MAIL_SIGNATURE_MARK_ATTRIBUTE}]`, priority: 60 },
// 저장본 · 앱이 만든 본문 — 빈 문단 뒤의 속성 없는 div
{ tag: 'div', getAttrs: (element: HTMLElement) => (isSignatureBoundaryElement(element) ? null : false) },
]
},
// 보낼 때 · 저장할 때 속성 없는 div — 앱과 같은 경계 (표시는 서버 정화가 어차피 지운다)
renderHTML() {
return ['div', 0]
},
addProseMirrorPlugins() {
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock })]
},
})
@@ -0,0 +1,155 @@
<script setup lang="ts">
// 메일에 넣을 서명 — 새 메일 · 답장과 전달 (디자인 원본 Signature16 1). 지금의 두 체크(새 메일 · 답장과 전달)를 대신한다.
// 고를 것은 서명(만든 차례) · 넣지 않음. 고르는 대로 바로 저장한다(mail-signature.store 의 chooseDefault — 한 번에 하나,
// 끝내 실패하면 서버가 확인한 값으로 되돌린다 · 오류 알림은 인터셉터). 저장이 끝나면 고른 칸 오른쪽에 "저장했습니다"(초록 ✓ · role=status),
// 다시 고르면 내린다. 서명이 하나도 없으면 이 칸을 감춘다(서명 화면이 그리지 않는다).
// 고정 id — 새 메일 #signature-default-new · 답장과 전달 #signature-default-reply · 안내 #signature-defaults-hint · 저장 글 [data-default-saved]
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_DEFAULTS_TEXT } from '@/shared/constants/mail-signature'
import type { SignatureUse } from '@/shared/types/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
const store = useMailSignatureStore()
/** "넣지 않음" 의 값 — select 는 글만 받는다 */
const NONE_VALUE = ''
const HINT_ID = 'signature-defaults-hint'
const FIELDS: readonly { use: SignatureUse; id: string; label: string }[] = [
{ use: 'new', id: 'signature-default-new', label: SIGNATURE_DEFAULTS_TEXT.newMessage },
{ use: 'reply', id: 'signature-default-reply', label: SIGNATURE_DEFAULTS_TEXT.reply },
]
/** "저장했습니다"를 보일 칸 — 마지막으로 고른 칸의 저장이 다 끝났고 성공했을 때 */
const savedField = ref<SignatureUse | null>(null)
let choiceSeq = 0
function valueOf(use: SignatureUse): string {
const id = use === 'new' ? store.defaults.newMessageId : store.defaults.replyId
return id ?? NONE_VALUE
}
async function choose(use: SignatureUse, event: Event) {
const value = (event.target as HTMLSelectElement).value
choiceSeq += 1
const mine = choiceSeq
savedField.value = null
const saved = await store.chooseDefault(use, value === NONE_VALUE ? null : value)
if (mine === choiceSeq && saved) savedField.value = use
}
</script>
<template>
<fieldset class="signature-defaults">
<legend class="signature-defaults__legend">
{{ SIGNATURE_DEFAULTS_TEXT.legend }}
</legend>
<div
v-for="field in FIELDS"
:key="field.use"
class="signature-defaults__row"
>
<label
:for="field.id"
class="signature-defaults__label"
>{{ field.label }}</label>
<select
:id="field.id"
class="form-input signature-defaults__select"
:value="valueOf(field.use)"
:aria-describedby="HINT_ID"
@change="(event) => choose(field.use, event)"
>
<option
v-for="signature in store.signatures"
:key="signature.id"
:value="signature.id"
>
{{ signature.name }}
</option>
<option :value="NONE_VALUE">
{{ SIGNATURE_DEFAULTS_TEXT.none }}
</option>
</select>
<span
class="signature-defaults__saved"
role="status"
:data-default-saved="field.use"
>
<template v-if="savedField === field.use">
<AppIcon
name="check"
:size="14"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_DEFAULTS_TEXT.saved }}</span>
</template>
</span>
</div>
<p
:id="HINT_ID"
class="signature-defaults__hint"
>
{{ SIGNATURE_DEFAULTS_TEXT.hint }}
</p>
</fieldset>
</template>
<style scoped>
.signature-defaults {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin: 0;
padding: 0;
border: 0;
}
.signature-defaults__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-defaults__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
}
/* 칸 이름 — 폭 84 (디자인 원본 Signature16) */
.signature-defaults__label {
flex: 0 0 5.25rem;
color: var(--color-text-strong);
}
/* 고르기 — 폭 240, 좁으면 줄어든다 */
.signature-defaults__select {
width: 15rem;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 0 var(--space-10);
font: inherit;
}
/* "저장했습니다" — 초록 12.5 + ✓ (보내기 · 읽기 설정과 같은 색) */
.signature-defaults__saved {
display: inline-flex;
align-items: center;
gap: var(--space-6);
font-size: var(--font-size-label);
color: var(--color-success-text);
white-space: nowrap;
}
.signature-defaults__hint {
margin: var(--space-2) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
// 서명 지우기 확인 (디자인 원본 Signature16 4, 공용 모양 ModalDialog — role=alertdialog, 메일함 지우기 창과 같은 폭 360)
// "‘대외’ 서명을 지울까요?" · 기본으로 넣던 곳마다 다른 글(새 메일 · 답장과 전달 · 둘 다 · 기본 아님) · [지우기](빨강 채움) [취소].
// 처음 초점은 [취소]이고 Esc 도 [취소]와 같다. 지우는 동안은 [지우기]를 누를 수 없다.
// 고정 id — #signature-delete-dialog · #signature-delete-title · #signature-delete-message · #signature-delete-confirm · #signature-delete-cancel
import { computed } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { SIGNATURE_DELETE_TEXT } from '@/shared/constants/mail-signature'
import { signatureDeleteMessage, type SignatureUses } from '@/shared/utils/mail-signature'
interface Props {
open: boolean
/** 지울 서명의 이름 */
name: string
/** 그 서명을 기본으로 넣는 메일 */
uses: SignatureUses
/** 지우는 중 */
busy?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
const title = computed(() => SIGNATURE_DELETE_TEXT.title(props.name))
const message = computed(() => signatureDeleteMessage(props.uses))
</script>
<template>
<ModalDialog
id="signature-delete-dialog"
:open="open"
role="alertdialog"
:title="title"
:message="message"
title-id="signature-delete-title"
message-id="signature-delete-message"
@cancel="emit('cancel')"
>
<template #actions>
<button
id="signature-delete-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:disabled="busy"
:aria-busy="busy"
@click="emit('confirm')"
>
{{ SIGNATURE_DELETE_TEXT.confirm }}
</button>
<!-- 처음 초점 — 실수로 Enter 를 눌러도 지워지지 않게 -->
<button
id="signature-delete-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
@click="emit('cancel')"
>
{{ SIGNATURE_DELETE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,99 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { editorOf, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import SignatureEditor from './SignatureEditor.vue'
// 공용 편집기 — 서명 칸 · 부재중 자동 응답 내용 칸(16단계)이 함께 쓴다 (prop 은 phase16 shared-editor.md)
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
describe('SignatureEditor — 공용 편집기 (16단계)', () => {
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
document.body.innerHTML = ''
})
function mountEditor(props: Record<string, unknown> = {}) {
const wrapper = mount(SignatureEditor, {
attachTo: document.body,
props: { labelledBy: 'field-label', ...props },
})
mounted.push(wrapper)
return wrapper
}
const toolNames = (wrapper: VueWrapper) =>
wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
it('기본값은 서명 칸 그대로 — 편집 영역 #signature-body · 툴바 "서명 서식" · 그림 단추', () => {
const wrapper = mountEditor()
expect(wrapper.find('#signature-body').exists()).toBe(true)
expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
'aria-label': '서명 서식',
'aria-controls': 'signature-body',
})
expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크', '그림 넣기'])
})
it('부재중 내용 칸처럼 — id · 툴바 이름을 바꾸고 그림을 끄면 그림 단추 · 그림 노드가 없고 파일을 받지 않는다', async () => {
const wrapper = mountEditor({
bodyId: 'vacation-body',
toolbarLabel: '자동 응답 서식',
allowImages: false,
initialHtml: `<p>휴가 중입니다.<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p>`,
})
const body = wrapper.get('#vacation-body')
expect(body.attributes('aria-labelledby')).toBe('field-label')
expect(wrapper.get('[role="toolbar"]').attributes()).toMatchObject({
'aria-label': '자동 응답 서식',
'aria-controls': 'vacation-body',
})
expect(toolNames(wrapper)).toEqual(['굵게', '기울임', '밑줄', '글자 색: 기본', '링크'])
// 저장한 글에 그림이 섞여 와도 스키마가 받지 않아 빠진다
expect(body.find('img').exists()).toBe(false)
body.element.dispatchEvent(pasteEvent({ files: [new File(['x'], '사진.png', { type: 'image/png' })] }))
expect(wrapper.emitted('files')).toBeUndefined()
})
it('getText — 서식을 뺀 글(글자 수를 셀 때), getContent — 쓰기 본문과 같은 서식 본문 · 글', async () => {
const wrapper = mountEditor({ allowImages: false, initialHtml: '<p><strong>안녕하세요</strong>, 김아라입니다.</p><p>복귀는 11월 23일입니다.</p>' })
const exposed = wrapper.vm as unknown as {
getText: () => string
getContent: () => { text: string; html: string | null }
}
expect(exposed.getText()).toBe('안녕하세요, 김아라입니다.\n복귀는 11월 23일입니다.')
const content = exposed.getContent()
expect(content.text).toBe(exposed.getText())
expect(content.html).toMatch(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75"><p style="margin: 0"><strong>/)
editorOf(wrapper.element).commands.clearContent(true)
expect(exposed.getText()).toBe('')
expect(exposed.getContent()).toEqual({ text: '', html: null, quoteStart: null })
})
it('invalid — 편집 영역 aria-invalid · 빨간 테두리, disabled — 읽기만 · 툴바 단추를 끈다(바꿈으로 알리지 않는다)', async () => {
const wrapper = mountEditor({ allowImages: false, initialHtml: '<p>내용</p>' })
const body = () => wrapper.get('#signature-body')
await wrapper.setProps({ invalid: true, describedBy: 'field-error' })
expect(body().attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'field-error' })
expect(wrapper.classes()).toContain('signature-editor--invalid')
await wrapper.setProps({ invalid: false, disabled: true })
expect(body().attributes('aria-invalid')).toBeUndefined()
expect(body().attributes('contenteditable')).toBe('false')
expect(body().attributes('aria-disabled')).toBe('true')
expect(wrapper.findAll('[role="toolbar"] button').every((button) => button.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.classes()).toContain('signature-editor--disabled')
await wrapper.setProps({ disabled: false })
expect(body().attributes('contenteditable')).toBe('true')
expect(wrapper.emitted('change')).toBeUndefined()
})
})
@@ -6,10 +6,14 @@
// - 내용이 바뀌면 change 로 문서를 넘긴다 — 미리 보기가 그 문서를 그린다
// - 바꿨는가(isChanged)는 처음 불러온 서명, 저장한 뒤에는 저장한 서명(markSaved)과 견준다 — 서명 화면의 나가기 확인
// TipTap 은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 서명 화면이 지연 로딩한다.
// 16단계 — 공용 편집기: 부재중 자동 응답의 내용 칸도 이것을 쓴다(쓰는 법은 prop 머리말). 기본값은 서명 칸 그대로다.
// - bodyId: 편집 영역 id(툴바의 aria-controls) · toolbarLabel: 툴바 이름 · allowImages: false 면 그림 단추 · 그림 노드가 없고
// 붙여 넣거나 끌어다 놓은 파일은 받지 않는다(무시) · invalid: aria-invalid + 빨간 테두리 · disabled: 고칠 수 없게(흐리게, 툴바 끔)
// - 편집 영역의 최소 높이는 CSS 변수 --signature-editor-min-height(기본 8.25rem = 132) — 쓰는 쪽 감싸개에서 바꾼다
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 { computed, markRaw, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { EditorSavePoint, MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
@@ -21,7 +25,12 @@ import {
} 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 {
SIGNATURE_BODY_ID,
SIGNATURE_TEXT,
SIGNATURE_TOOLBAR_LABEL,
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'
@@ -34,19 +43,29 @@ interface Props {
labelledBy: string
/** 아래 안내 · 오류 id (aria-describedby) */
describedBy?: string | null
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림) */
images: ReadonlyMap<string, SignatureImage>
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림). 그림을 쓰지 않는 칸은 주지 않는다 */
images?: ReadonlyMap<string, SignatureImage>
/** 그림을 더 넣을 수 없다 (올리는 중) */
imageDisabled?: boolean
/** 파일을 끌고 있다 — 칸 위에 덮개를 보인다 */
dragging?: boolean
/** 편집 영역의 id — 툴바가 aria-controls 로 가리킨다. 한 화면에 칸이 둘이면 다르게 준다 (기본 "signature-body") */
bodyId?: string
/** 툴바의 화면 낭독 이름 (기본 "서명 서식") */
toolbarLabel?: string
/** 그림을 쓴다 — false 면 그림 단추가 없고, 붙여 넣은 그림은 빠지며, 붙여 넣거나 끌어다 놓은 파일은 받지 않는다 */
allowImages?: boolean
/** 잘못된 입력 — 편집 영역이 aria-invalid 이고 테두리가 빨갛다 (오류 글은 describedBy 로 잇는다) */
invalid?: boolean
/** 고칠 수 없다 — 편집 영역을 읽기만 하고(흐리게) 툴바 단추를 끈다 */
disabled?: boolean
}
interface Emits {
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 */
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 (allowImages 가 false 면 보내지 않는다) */
(e: 'files', files: File[]): void
/** 그림 단추 — 파일 고르기를 연다 */
(e: 'pick-image'): void
@@ -55,14 +74,20 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
initialHtml: '',
describedBy: null,
images: () => new Map<string, SignatureImage>(),
imageDisabled: false,
dragging: false,
bodyId: SIGNATURE_BODY_ID,
toolbarLabel: SIGNATURE_TOOLBAR_LABEL,
allowImages: true,
invalid: false,
disabled: false,
})
const emit = defineEmits<Emits>()
/** 화면에 하나뿐인 서명 칸 */
const SIGNATURE_BODY_ID = 'signature-body'
const IMAGE_SIZES: readonly SignatureImageSize[] = ['small', 'medium', 'original']
/** 그림 크기 줄의 이름표 id — 한 화면에 칸이 여럿이어도 겹치지 않게 */
const imageSizeLabelId = `${useId()}-image-size-label`
const bodyRef = ref<HTMLElement | null>(null)
const toolbarRef = ref<InstanceType<typeof SignatureToolbar> | null>(null)
@@ -74,36 +99,44 @@ let mounted = false
*/
let baseline: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
const extensions = createSignatureEditorExtensions({
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
})
const extensions = createSignatureEditorExtensions(
{
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
},
{ images: props.allowImages },
)
/**
* 붙여 넣거나 끌어다 놓은 파일 — 그림을 쓰는 칸이면 files 로 넘기고, 아니면 받지 않는다(무시 — 글이 함께 오면 글은 그대로 붙는다).
* 넘길 파일이 없으면 false (편집기가 글을 붙인다)
*/
function takeFiles(files: File[]): boolean {
if (files.length === 0) return false
if (props.allowImages) emit('files', files)
return true
}
const editor = markRaw(
new Editor({
element: null,
extensions,
editable: !props.disabled,
content: props.initialHtml ? normalizeIncomingHtml(props.initialHtml) : '',
editorProps: {
attributes: () => ({
id: SIGNATURE_BODY_ID,
id: props.bodyId,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': props.labelledBy,
...(props.describedBy ? { 'aria-describedby': props.describedBy } : {}),
...(props.invalid ? { 'aria-invalid': 'true' } : {}),
...(props.disabled ? { 'aria-disabled': 'true' } : {}),
}),
handlePaste: (_view, event) => {
const files = pastedFiles(event.clipboardData)
if (files.length === 0) return false
emit('files', files)
return true
},
handlePaste: (_view, event) => takeFiles(pastedFiles(event.clipboardData)),
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
return takeFiles(Array.from(event.dataTransfer?.files ?? []))
},
transformPastedHTML: normalizeIncomingHtml,
},
@@ -180,6 +213,11 @@ function getContent(): MailEditorContent {
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)), quoteStart: null }
}
/** 보이는 글 — 서식을 뺀 텍스트(getContent 의 text 와 같다, 끝의 빈 줄은 뺀다). 글자 수를 셀 때 */
function getText(): string {
return docToText(editor.state.doc)
}
/** 서명이 쓰는 그림 id */
function imageIds(): Set<string> {
return signatureImageIdsOf(editor.state.doc)
@@ -224,23 +262,39 @@ onBeforeUnmount(() => {
// 오류 안내가 생기거나 사라지면 서명 칸의 aria 속성을 다시 그린다
watch(
() => props.describedBy,
() => [props.describedBy, props.invalid],
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
// 고칠 수 없게 되면 읽기만 — 바꿈(change)으로 알리지 않는다
watch(
() => props.disabled,
(disabled) => {
if (!editor.isDestroyed) editor.setEditable(!disabled, false)
},
)
defineExpose({ getContent, getText, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
</script>
<template>
<div :class="['signature-editor', { 'is-dragging': dragging }]">
<div
:class="[
'signature-editor',
{ 'is-dragging': dragging, 'signature-editor--invalid': invalid, 'signature-editor--disabled': disabled },
]"
>
<SignatureToolbar
ref="toolbarRef"
:editor="editor"
:revision="revision"
:controls="SIGNATURE_BODY_ID"
:controls="bodyId"
:label="toolbarLabel"
:allow-images="allowImages"
:image-disabled="imageDisabled"
:disabled="disabled"
@pick-image="emit('pick-image')"
/>
<div class="signature-editor__frame">
@@ -257,16 +311,16 @@ defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSaveP
</div>
</div>
<div
v-if="selectedImage"
v-if="selectedImage && allowImages"
class="signature-editor__sizes"
>
<span
id="signature-image-size-label"
:id="imageSizeLabelId"
class="signature-editor__sizes-label"
>{{ SIGNATURE_TEXT.imageSizeLabel }}</span>
<div
role="group"
aria-labelledby="signature-image-size-label"
:aria-labelledby="imageSizeLabelId"
class="segmented"
>
<button
@@ -310,12 +364,32 @@ defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSaveP
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
/* 잘못된 입력 — 빨간 테두리, 초점이 있으면 빨간 고리 (테두리 입력칸 .form-input 과 같은 색) */
.signature-editor--invalid,
.signature-editor--invalid:has(.ProseMirror:focus) {
border-color: var(--color-danger);
}
.signature-editor--invalid:has(.ProseMirror:focus) {
box-shadow: 0 0 0 var(--border-width) var(--color-danger);
}
/* 고칠 수 없다 — 다른 꺼진 칸처럼 흐리게 */
.signature-editor--disabled {
opacity: 0.6;
}
.signature-editor--disabled .signature-editor__body :deep(.ProseMirror) {
cursor: not-allowed;
}
.signature-editor__frame {
position: relative;
}
/* 편집 영역 최소 높이 132 — 쓰는 쪽이 --signature-editor-min-height 로 바꿀 수 있다 (부재중 내용 칸 150) */
.signature-editor__body :deep(.ProseMirror) {
min-height: 8.25rem;
min-height: var(--signature-editor-min-height, 8.25rem);
padding: var(--space-14) var(--space-16);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
@@ -0,0 +1,94 @@
<script setup lang="ts">
// 서명이 없을 때 (디자인 원본 Signature16 2) — 점선 상자 안에 펜 아이콘 · "아직 서명이 없습니다" · 안내 · [새 서명](채운 단추).
// 메일에 넣을 서명 칸과 목록은 그리지 않는다(고를 서명이 없다 — 서명 화면이 정한다).
// 고정 id — [새 서명] #signature-empty-new
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
interface Emits {
(e: 'create'): void
}
const emit = defineEmits<Emits>()
</script>
<template>
<div class="signature-empty">
<AppIcon
name="edit"
:size="36"
:stroke-width="1.5"
class="signature-empty__icon"
/>
<p class="signature-empty__title">
{{ SIGNATURE_TEXT.emptyTitle }}
</p>
<p class="signature-empty__body">
{{ SIGNATURE_TEXT.emptyBody }}
</p>
<button
id="signature-empty-new"
type="button"
class="signature-empty__new"
@click="emit('create')"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ SIGNATURE_TEXT.newSignature }}</span>
</button>
</div>
</template>
<style scoped>
/* 점선 상자 — 위아래 40 · 좌우 24 (디자인 원본 Signature16 2) */
.signature-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
padding: var(--space-40) var(--space-24);
border: var(--border-width) dashed var(--color-border-strong);
border-radius: var(--radius-lg);
text-align: center;
word-break: keep-all;
overflow-wrap: anywhere;
}
.signature-empty__icon {
color: var(--color-icon-muted);
}
.signature-empty__title {
margin: var(--space-4) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.signature-empty__body {
max-width: 25rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.signature-empty__new {
margin-top: var(--space-6);
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.signature-empty__new:hover {
background: var(--color-brand-strong);
}
</style>
@@ -1,109 +1,129 @@
<script setup lang="ts">
// 메일 서명 설정 (디자인 원본 Settings · README "2단계 › 서명") — 설정 › 메일 › 서명 화면이 지연 로딩한다 (TipTap 이 이 청크에만 있다)
// 위에서부터 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → 서명을 넣을 메일(새 메일 · 답장 · 전달) → [저장] → 미리 보기.
// 서명 하나 고치기 (디자인 원본 Settings · Signature16 1 · 3 · README "2단계 › 서명" · "16단계 — 여러 서명")
// 이름 → 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → [저장] (+ [이 서명 지우기] — 새 서명은 [취소]) → 미리 보기.
// 고른 서명마다 새로 만든다(서명 화면 SignatureSettings 가 key 로) — 새 서명은 처음 [저장]에 만들고, 그 뒤로는 같은 칸에서 고친다.
// - 이름: 앞뒤 공백을 뺀 1~30자(칸이 30자에서 막는다), 같은 이름은 영문 대소문자를 가리지 않고 본다. [저장]할 때 걸리면 칸 아래 안내 대신 빨간 글과
// 이름 칸에 초점(알림 없음), 이름을 고치면 안내로 돌아간다. 서버가 거절하면(VAL_001) 목록을 조용히 다시 받아 그사이 다른 창에서 같은 이름을
// 만들었으면 같은 빨간 글, 아니면 서명 칸 아래에 사전 문구(본문 길이 · 그림 3장 등).
// - 그림: 그림 단추 · 붙여 넣기 · 끌어다 놓기(화면 어디든) → 사전 검사(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(나가기)
// 3장은 서명 칸에 있는 서로 다른 그림으로 센다 — 뺀 그림은 세지 않는다. 한 사람 20장은 서버가 올리기에서 막는다(VAL_001 — 알림은 인터셉터).
// - 저장: 새 서명은 POST /mail-signatures, 있던 서명은 PUT /mail-signatures/{id} { name, bodyHtml, body } — 본문이 비었으면 둘 다 null.
// 길이 한도(서식 10,000 · 텍스트 2,000)는 보내기 전에 먼저 막는다(VAL_001). 저장한 뒤 글은 기본으로 넣는 서명이면
// "저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.", 아니면 "저장했습니다." — 무엇이든 다시 바꾸면 내린다.
// - 바꿈(dirty)은 처음(저장한 뒤에는 저장한) 이름 · 서명과 다르거나 그림을 올리는 중 — 서명 화면이 다른 서명을 고르거나 떠날 때 묻는다.
// 고정 id — 화면 검증 · 테스트가 쓴다: 이름 #signature-name(안내 #signature-name-hint · 오류 #signature-name-error) · 서명 칸 #signature-body ·
// 그림 고르기 #signature-image-input · 저장 #signature-save · 상태 #signature-save-status · 오류 #signature-error ·
// 지우기 #signature-delete · 취소 #signature-cancel · 크기 단추 [data-size] · 미리 보기 단추 [data-mode]
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, nextTick, 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 { useMailSignatures } from '@/composables/useMailSignatures'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGE_ACCEPT,
SIGNATURE_IMAGE_INLINE_CODES,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_NAME_MAX_LENGTH,
SIGNATURE_SAVE_INLINE_CODES,
SIGNATURE_TEXT,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/shared/constants/mail-signature'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import type { SignatureImage } from '@/shared/types/mail'
import type { MailSignature } from '@/shared/types/mail-signature'
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 {
precheckSignatureImage,
signatureImageUploadName,
signatureNameProblem,
signatureUsesOf,
} from '@/shared/utils/mail-signature'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
interface Props {
/** 서버의 서명 — 이 값으로 칸을 채운다 */
signature: MailSignature
/** 고치는 서명 — 새 서명이면 null (처음 [저장]에 만든다) */
signature: MailSignature | null
/** 다른 서명의 이름 — 같은 이름 검사 */
otherNames: readonly string[]
}
interface Emits {
/** 저장했다 — 새 서명이면 만든 서명 */
(e: 'saved', signature: MailSignature): void
/** [이 서명 지우기] — 묻는 창은 서명 화면이 띄운다 */
(e: 'delete'): void
/** 새 서명의 [취소] */
(e: 'cancel'): void
/** 저장하지 않은 바꿈이 있는가 */
(e: 'dirty', dirty: boolean): void
/** 저장하는 중인가 */
(e: 'saving', saving: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { uploadSignatureImage } = useMail()
const authStore = useAuthStore()
const { uploadSignatureImage } = useMailSignatures()
const signatureStore = useMailSignatureStore()
const LABEL_ID = 'signature-label'
const HINT_ID = 'signature-hint'
const ERROR_ID = 'signature-error'
const NAME_HINT_ID = 'signature-name-hint'
const NAME_ERROR_ID = 'signature-name-error'
/** 처음 내용 — 서식 서명, 없으면 텍스트 서명을 문단으로 (다른 메일 프로그램이 텍스트만 남긴 경우) */
const initialHtml =
props.signature.bodyHtml ?? (props.signature.body ? textToParagraphHtml(props.signature.body) : '')
props.signature?.bodyHtml ?? (props.signature?.body ? textToParagraphHtml(props.signature.body) : '')
const editorRef = ref<InstanceType<typeof SignatureEditor> | null>(null)
const nameInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const forNew = ref<boolean>(props.signature.isForNew)
const forReply = ref<boolean>(props.signature.isForReply)
const name = ref<string>(props.signature?.name ?? '')
/** 처음(저장한 뒤에는 저장한) 이름 — 이것과 다르면 바꾼 것이다 */
const savedName = ref<string>(props.signature?.name ?? '')
const nameError = ref<'empty' | 'duplicate' | null>(null)
/** 미리 보기가 그릴 문서 — 편집기가 바뀔 때마다 */
const previewDoc = shallowRef<ProseMirrorNode | null>(null)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
/** 저장한 뒤 글 — 저장한 때 그 서명이 기본으로 들어가는지로 고른다 */
const savedMessage = ref<string>('')
/** 서명 칸 아래 오류 — 사전 문구 */
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])),
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,
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp,
unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp,
const nameErrorText = computed(() => {
if (nameError.value === 'empty') return SIGNATURE_TEXT.nameEmpty
return nameError.value === 'duplicate' ? SIGNATURE_TEXT.nameDuplicate : null
})
/** 떠나거나 다른 서명을 고르면 잃는 것이 있다 — 저장하지 않은 이름 · 서명 칸, 올리는 중인 그림 */
const hasUnsavedChanges = computed(
() => editorChanged.value || uploading.value > 0 || name.value !== savedName.value,
)
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
function markChanged() {
if (saveState.value === 'saved') saveState.value = 'idle'
}
function onNameInput() {
nameError.value = null
markChanged()
}
function onEditorReady(doc: ProseMirrorNode) {
previewDoc.value = doc
}
@@ -138,7 +158,7 @@ async function addImage(file: File) {
markChanged()
} catch (error) {
const apiError = toApiError(error)
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 10장 초과 VAL_001 등)은 인터셉터가 알렸다
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 20장 초과 VAL_001 등)은 인터셉터가 알렸다
if (SIGNATURE_IMAGE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
} finally {
uploading.value -= 1
@@ -150,7 +170,7 @@ async function addImages(files: File[]) {
for (const file of files) await addImage(file)
}
// 화면 어디에 그림을 놓아도 받는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
// 화면 어디에 그림을 놓아도 지금 고른 서명에 넣는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
const { dragging } = useWindowFileDrop((files) => void addImages(files))
function pickImage() {
@@ -165,9 +185,34 @@ function onFilesChosen(event: Event) {
void addImages(files)
}
function focusName() {
nameInputRef.value?.focus()
}
/** 이름이 걸렸다 — 칸 아래 빨간 글 · 이름 칸에 초점 */
function showNameProblem(problem: 'empty' | 'duplicate') {
nameError.value = problem
void nextTick(focusName)
}
/** 서버가 저장을 거절했다(VAL_001) — 목록을 다시 받아 그사이 같은 이름이 생겼으면 이름 칸, 아니면 서명 칸 아래에 사전 문구 */
async function explainRejection(message: string, trimmedName: string) {
await signatureStore.refresh()
const others = signatureStore.signatures
.filter((signature) => signature.id !== props.signature?.id)
.map((signature) => signature.name)
if (signatureNameProblem(trimmedName, others) === 'duplicate') showNameProblem('duplicate')
else errorMessage.value = message
}
async function save() {
const editor = editorRef.value
if (!editor || saveState.value === 'saving') return
const problem = signatureNameProblem(name.value, props.otherNames)
if (problem) {
showNameProblem(problem)
return
}
const content = editor.getContent()
// 길이 한도 — 서버도 같은 한도로 VAL_001 을 준다 (서식은 정화한 뒤 재지만 정화는 거의 줄이지 않는다)
if ((content.html?.length ?? 0) > SIGNATURE_HTML_MAX_LENGTH || content.text.length > SIGNATURE_TEXT_MAX_LENGTH) {
@@ -179,34 +224,82 @@ async function save() {
saveState.value = 'saving'
// 저장할 때의 모양 — 저장이 끝나면 이것을 "바꾸지 않은" 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다)
const savePoint = editor.captureSavePoint()
const options = { forNew: forNew.value, forReply: forReply.value }
const trimmedName = name.value.trim()
const typedName = name.value
const payload = { name: trimmedName, bodyHtml: content.html, body: content.text === '' ? null : content.text }
const options = { silent: SIGNATURE_SAVE_INLINE_CODES }
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 },
)
const current = props.signature
const saved = current
? await signatureStore.update(current.id, payload, options)
: await signatureStore.create(payload, options)
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
savedName.value = saved.name
// 저장하는 동안 이름을 더 고치지 않았으면 저장한 이름(앞뒤 공백을 뺀)으로 맞춘다
if (name.value === typedName) name.value = saved.name
savedMessage.value =
signatureUsesOf(saved.id, signatureStore.defaults) !== null ? SIGNATURE_TEXT.saved : SIGNATURE_TEXT.savedPlain
saveState.value = 'saved'
emit('saved', saved)
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) await explainRejection(apiError.message, trimmedName)
}
}
watch([forNew, forReply], markChanged)
watch(hasUnsavedChanges, (dirty) => emit('dirty', dirty), { immediate: true })
watch(
() => saveState.value === 'saving',
(saving) => emit('saving', saving),
)
defineExpose({ focusName })
</script>
<template>
<div class="signature-form">
<div class="signature-form__name">
<label
for="signature-name"
class="signature-form__label"
>{{ SIGNATURE_TEXT.nameLabel }}</label>
<input
id="signature-name"
ref="nameInputRef"
v-model="name"
type="text"
class="form-input signature-form__name-input"
autocomplete="off"
:maxlength="SIGNATURE_NAME_MAX_LENGTH"
:placeholder="SIGNATURE_TEXT.namePlaceholder"
:aria-invalid="nameError ? 'true' : undefined"
:aria-describedby="nameError ? NAME_ERROR_ID : NAME_HINT_ID"
@input="onNameInput"
>
<p
v-if="nameErrorText"
:id="NAME_ERROR_ID"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ nameErrorText }}</span>
</p>
<p
v-else
:id="NAME_HINT_ID"
class="form-message"
>
{{ SIGNATURE_TEXT.nameHint }}
</p>
</div>
<div class="signature-form__field">
<p
:id="LABEL_ID"
@@ -270,31 +363,6 @@ watch([forNew, forReply], markChanged)
</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"
@@ -317,27 +385,38 @@ watch([forNew, forReply], markChanged)
:size="15"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_TEXT.saved }}</span>
<span>{{ savedMessage }}</span>
</template>
</span>
<button
v-if="signature"
id="signature-delete"
type="button"
class="signature-form__delete"
:disabled="saveState === 'saving'"
@click="emit('delete')"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ SIGNATURE_TEXT.delete }}</span>
</button>
<button
v-else
id="signature-cancel"
type="button"
class="signature-form__cancel"
:disabled="saveState === 'saving'"
@click="emit('cancel')"
>
{{ SIGNATURE_TEXT.cancel }}
</button>
</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"
/>
<SignaturePreview :doc="previewDoc" />
</div>
</template>
@@ -348,6 +427,20 @@ watch([forNew, forReply], markChanged)
gap: var(--space-22);
}
.signature-form__name {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 이름 칸 — 폭 320, 칸이 좁으면 칸 폭 (디자인 원본 Signature16) */
.signature-form__name-input {
width: 20rem;
max-width: 100%;
box-sizing: border-box;
font: inherit;
}
.signature-form__field {
display: flex;
flex-direction: column;
@@ -357,6 +450,7 @@ watch([forNew, forReply], markChanged)
.signature-form__label {
margin: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-form__alert {
@@ -383,46 +477,11 @@ watch([forNew, forReply], markChanged)
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);
gap: var(--space-8) var(--space-12);
}
.signature-form__save {
@@ -442,7 +501,9 @@ watch([forNew, forReply], markChanged)
background: var(--color-brand-strong);
}
.signature-form__save:disabled {
.signature-form__save:disabled,
.signature-form__delete:disabled,
.signature-form__cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -455,6 +516,42 @@ watch([forNew, forReply], markChanged)
font-weight: 500;
}
/* [이 서명 지우기] — [저장]과 떨어뜨려 오른쪽 끝 (디자인 원본 Signature16 1) */
.signature-form__delete {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-danger);
font-weight: 600;
}
.signature-form__delete:hover:not(:disabled) {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
/* 새 서명의 [취소] — 보조 단추 (디자인 원본 Signature16 3) */
.signature-form__cancel {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.signature-form__cancel:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.signature-form__rule {
width: 100%;
height: var(--border-width);
@@ -0,0 +1,214 @@
<script setup lang="ts">
// 내 서명 목록 (디자인 원본 Signature16 1 · 3) — 만든 차례, 줄 높이 52.
// 고른 줄은 메일함 패널의 지금 메일함처럼 청록 바탕에 굵은 글자(aria-current). 이름 아래 작은 회색 글은 그 서명을 기본으로 넣는 메일(뱃지 없음),
// 아직 저장하지 않은 새 줄은 "저장하지 않음". 아래에 [새 서명](글자 단추) · 개수 글 — 10개면 [새 서명]은 aria-disabled(초점은 받는다)이고
// 개수 글이 그 까닭으로 바뀐다(필터의 50개와 같은 꼴). 칸 폭이 640 보다 좁으면 목록이 위로 올라가 칸 폭을 쓴다(컨테이너 쿼리 — SignatureSettings).
// 고정 id · 표시 — 목록 이름 #signature-list-label · 줄 [data-signature-row="<id | new>"] · [새 서명] #signature-new · 개수 글 #signature-count
import AppIcon from '@/components/AppIcon.vue'
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
export interface SignatureListRow {
/** 서명 id — 아직 저장하지 않은 새 줄은 'new' */
key: string
name: string
/** 이름 아래 회색 글 — 없으면 빈 글 */
sub: string
current: boolean
}
interface Props {
rows: readonly SignatureListRow[]
/** 저장한 서명 수 */
count: number
/** 더 만들 수 없다 (10개) */
full: boolean
}
interface Emits {
(e: 'pick', key: string): void
(e: 'create'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const COUNT_ID = 'signature-count'
function onCreate() {
if (props.full) return
emit('create')
}
</script>
<template>
<div class="signature-list">
<p
id="signature-list-label"
class="signature-list__label"
>
{{ SIGNATURE_TEXT.listLabel }}
</p>
<ul
class="signature-list__rows"
aria-labelledby="signature-list-label"
>
<li
v-for="row in rows"
:key="row.key"
class="signature-list__item"
>
<button
type="button"
:class="['signature-list__row', { 'signature-list__row--current': row.current }]"
:aria-current="row.current ? 'true' : undefined"
:data-signature-row="row.key"
@click="emit('pick', row.key)"
>
<span class="signature-list__name">{{ row.name }}</span>
<span
v-if="row.sub"
class="signature-list__sub"
>{{ row.sub }}</span>
</button>
</li>
</ul>
<button
id="signature-new"
type="button"
class="signature-list__new"
:aria-disabled="full ? 'true' : undefined"
:aria-describedby="full ? COUNT_ID : undefined"
@click="onCreate"
>
<AppIcon
name="plus"
:size="16"
/>
<span>{{ SIGNATURE_TEXT.newSignature }}</span>
</button>
<p
:id="COUNT_ID"
class="signature-list__count"
>
{{ full ? SIGNATURE_TEXT.full : SIGNATURE_TEXT.count(count) }}
</p>
</div>
</template>
<style scoped>
/* 목록 — 폭 208 (디자인 원본 Signature16 1). 칸이 640 보다 좁으면 위로 올라가 칸 폭을 쓴다 */
.signature-list {
flex: 0 0 13rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
@container signature-settings (max-width: 40rem) {
.signature-list {
flex-basis: auto;
width: 100%;
}
}
.signature-list__label {
margin: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-list__rows {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.signature-list__item + .signature-list__item {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 줄 — 높이 52 (--size-header-min 재사용 — 밀도 예외 아님) */
.signature-list__row {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
width: 100%;
min-height: var(--size-header-min);
box-sizing: border-box;
padding: var(--space-6) var(--space-12);
border: 0;
background: var(--color-surface);
color: var(--color-text);
text-align: left;
}
.signature-list__row:hover {
background: var(--color-surface-panel);
}
.signature-list__row--current,
.signature-list__row--current:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
.signature-list__name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.signature-list__row--current .signature-list__name {
font-weight: 600;
}
.signature-list__sub {
font-size: var(--font-size-small);
line-height: 1.4;
color: var(--color-text-muted);
}
.signature-list__row--current .signature-list__sub {
color: var(--color-brand-strong);
}
/* [새 서명] — 글자 단추 44 (아이콘이 목록 왼쪽 선에 오게 왼쪽 -2) */
.signature-list__new {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
margin-left: calc(var(--space-2) * -1);
padding: 0 var(--space-12) 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.signature-list__new:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 10개 — 초점은 받고 누를 수 없다. 마우스를 올려도 그대로 */
.signature-list__new[aria-disabled='true'] {
color: var(--color-icon-muted);
cursor: not-allowed;
}
.signature-list__count {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 서명 미리 보기 (디자인 원본 Settings) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// 서명 미리 보기 (디자인 원본 Settings · Signature16) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// - 새 메일: (여기에 쓴 내용) · 빈 줄 · 서명 / 답장: 같은 위에 원문 인용(자리만 — 실제 메일이 아니다)
// - 서명은 편집 중인 문서를 편집기 스키마로 그린다 (DOMSerializer — 스키마가 그리는 태그 · 속성뿐이라 남의 HTML 을 넣지 않는다).
// 그 쓰기 종류에 넣지 않기로 했으면 서명을 빼고 아래 안내가 바뀐다.
// - 16단계: 서명이 여럿이라 고른 서명이 그 메일에 들어갈 자리를 늘 보인다 (기본으로 넣을지는 위의 "메일에 넣을 서명"이 정한다)
import { DOMSerializer, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, watch } from 'vue'
@@ -18,10 +18,6 @@ export type SignaturePreviewMode = 'new' | 'reply'
interface Props {
/** 편집 중인 서명 문서 (없으면 아직 불러오는 중) */
doc: ProseMirrorNode | null
/** 새 메일에 넣는다 */
forNew: boolean
/** 답장 · 전달에 넣는다 */
forReply: boolean
}
const props = defineProps<Props>()
@@ -34,12 +30,7 @@ const MODES: readonly { mode: SignaturePreviewMode; label: string }[] = [
{ 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
})
const note = computed(() => SIGNATURE_PREVIEW_NOTES[mode.value])
/** 서명 문서를 그린다 — 스키마의 toDOM 결과만 붙인다 */
function render() {
@@ -92,7 +83,6 @@ watch([() => props.doc, signatureRef], render, { flush: 'post', immediate: true
</p>
<p><br></p>
<div
v-show="shown"
ref="signatureRef"
class="signature-preview__signature"
data-signature-preview
@@ -0,0 +1,328 @@
<script setup lang="ts">
// 설정 › 메일 › 서명 — 여러 서명 (디자인 원본 Signature16 · README "16단계 — 여러 서명" · 계약 2절 "서명").
// 서명 화면(MailSignaturePage)이 받은 뒤 지연 로딩한다 — TipTap 이 이 청크에만 있다.
// 위에서부터 "메일에 넣을 서명"(기본 둘 — 서명이 있을 때만) → 왼쪽 내 서명 목록 · 오른쪽 고른 서명 고치기(SignatureForm).
// 칸 폭이 640 보다 좁으면 목록을 위로 올리고 고치는 칸을 그 아래에 둔다 — 창 폭이 아니라 이 칸의 폭을 본다(컨테이너 쿼리).
// - 처음 고른 서명은 새 메일에 넣는 서명, 넣지 않음이면 첫 서명. 서명이 없으면 안내와 [새 서명]만(SignatureEmpty).
// - [새 서명]: 목록 끝에 "저장하지 않음" 줄이 생기고 빈 이름 칸(초점) · 서명 칸이 열린다. 처음 [저장]에 만든다 — 서명이 하나도 없던 직원이면
// 두 곳의 기본이 그 서명이 된다(서버). [취소]는 새 줄을 빼고 전에 고른 서명을 연다. 10개면 [새 서명]을 누를 수 없다.
// - 저장하지 않은 것이 있으면 다른 서명을 고르거나 [새 서명] · [취소]를 누를 때 나가기 확인과 같은 창으로 묻는다
// (처음 초점 [계속 고치기] · [바꾼 내용 버리기]). 다른 화면으로 갈 때는 지금 서명 화면의 나가기 확인 그대로(useLeaveConfirm) —
// 저장하는 중이거나 세션이 끝났을 때(제한된 세션 포함)는 묻지 않는다. 메일에 넣을 서명 두 칸은 고를 때 바로 저장되므로 묻는 까닭이 아니다.
// - [이 서명 지우기]: 묻고(SignatureDeleteDialog — 기본이던 곳마다 다른 글) 지운 뒤 "‘영문’ 서명을 지웠습니다." 알림과 목록의 첫 서명을 연다.
// 마지막 서명을 지우면 서명이 없을 때의 화면이 된다. 지운 서명을 기본으로 넣던 곳은 넣지 않음이 된다(다른 서명이 저절로 기본이 되지 않는다).
// 고정 id — 다른 서명 확인 #signature-switch-dialog(· -stay · -confirm) · 나가기 확인 #signature-leave-dialog(· -stay · -confirm)
import { computed, nextTick, ref, watch } from 'vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import SignatureDefaultsField from '@/components/mail/editor/signature/SignatureDefaultsField.vue'
import SignatureDeleteDialog from '@/components/mail/editor/signature/SignatureDeleteDialog.vue'
import SignatureEmpty from '@/components/mail/editor/signature/SignatureEmpty.vue'
import SignatureForm from '@/components/mail/editor/signature/SignatureForm.vue'
import SignatureList, { type SignatureListRow } from '@/components/mail/editor/signature/SignatureList.vue'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useToast } from '@/composables/useToast'
import {
MAIL_SIGNATURE_MAX_COUNT,
SIGNATURE_DELETE_TEXT,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_SWITCH_DIALOG,
SIGNATURE_TEXT,
} from '@/shared/constants/mail-signature'
import type { MailSignature } from '@/shared/types/mail-signature'
import { signatureUsesOf, signatureUsesText } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
/** 목록의 아직 저장하지 않은 새 줄 */
const NEW_KEY = 'new'
type Selection = { kind: 'saved'; id: string } | { kind: 'new' }
/** 묻는 창의 답을 기다리는 일 — 다른 서명 고르기 · 새 서명 · 새 서명 [취소] */
type PendingSwitch = { kind: 'pick'; id: string } | { kind: 'create' } | { kind: 'cancel' }
const store = useMailSignatureStore()
const authStore = useAuthStore()
const toast = useToast()
const selection = ref<Selection | null>(initialSelection())
/** 고치는 칸을 새로 만든다 — 다른 서명을 열 때마다 (새 서명을 처음 저장한 것은 같은 칸이다) */
const formKey = ref<number>(0)
const formRef = ref<InstanceType<typeof SignatureForm> | null>(null)
const rootRef = ref<HTMLElement | null>(null)
/** 고치는 칸에 저장하지 않은 것이 있다 · 저장하는 중 (SignatureForm 이 알린다) */
const formDirty = ref<boolean>(false)
const formSaving = ref<boolean>(false)
const pendingSwitch = ref<PendingSwitch | null>(null)
/** [새 서명]을 열기 전에 고르던 서명 — [취소]가 돌아간다 */
let beforeNew: string | null = null
/** 지울 서명 — 묻는 창이 열린 동안 */
const deleteTarget = ref<MailSignature | null>(null)
const deleting = ref<boolean>(false)
const selectedId = computed(() => (selection.value?.kind === 'saved' ? selection.value.id : null))
const selectedSignature = computed(() => store.signatureById(selectedId.value))
const isFull = computed(() => store.signatures.length >= MAIL_SIGNATURE_MAX_COUNT)
const otherNames = computed(() =>
store.signatures.filter((signature) => signature.id !== selectedId.value).map((signature) => signature.name),
)
const rows = computed<SignatureListRow[]>(() => {
const saved = store.signatures.map((signature) => ({
key: signature.id,
name: signature.name,
sub: signatureUsesText(signatureUsesOf(signature.id, store.defaults)),
current: signature.id === selectedId.value,
}))
if (selection.value?.kind !== 'new') return saved
return [...saved, { key: NEW_KEY, name: SIGNATURE_TEXT.newSignature, sub: SIGNATURE_TEXT.unsavedRow, current: true }]
})
/** 묻는 창의 글 — 지금 고치는 서명의 이름으로 (새 서명은 이름 대신 "새 서명에 쓴 내용") */
const switchMessage = computed(() => {
const pending = pendingSwitch.value
const current = selectedSignature.value
if (!pending) return ''
if (pending.kind === 'cancel') return SIGNATURE_SWITCH_DIALOG.cancelNew
if (!current) return pending.kind === 'pick' ? SIGNATURE_SWITCH_DIALOG.pickFromNew : SIGNATURE_SWITCH_DIALOG.cancelNew
return pending.kind === 'pick' ? SIGNATURE_SWITCH_DIALOG.pick(current.name) : SIGNATURE_SWITCH_DIALOG.create(current.name)
})
const switchCopy = computed(() => ({
title: SIGNATURE_SWITCH_DIALOG.title,
message: switchMessage.value,
stay: SIGNATURE_SWITCH_DIALOG.stay,
leave: SIGNATURE_SWITCH_DIALOG.discard,
}))
const deleteUses = computed(() =>
deleteTarget.value ? signatureUsesOf(deleteTarget.value.id, store.defaults) : null,
)
/**
* 다른 화면으로 떠나기 전 확인 — 저장하지 않은 서명이 있을 때. 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다).
* 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다
*/
const { confirming: leaveConfirming, stay: stayOnPage, leave: leavePage } = useLeaveConfirm({
active: () => formDirty.value && !formSaving.value && authStore.canUseApp,
unloadActive: () => formDirty.value && authStore.canUseApp,
})
function initialSelection(): Selection | null {
const first = store.defaultSignature('new') ?? store.signatures[0] ?? null
return first ? { kind: 'saved', id: first.id } : null
}
/** 서명을 연다 — 고치는 칸을 새로 만든다 (바꾼 것은 버린다) */
function open(next: Selection | null) {
selection.value = next
formKey.value += 1
formDirty.value = false
formSaving.value = false
}
/** 목록의 줄 — 지금 줄이면 아무것도 하지 않는다 */
function onPick(key: string) {
if (key === NEW_KEY || key === selectedId.value) return
if (formDirty.value) {
pendingSwitch.value = { kind: 'pick', id: key }
return
}
open({ kind: 'saved', id: key })
}
function onCreate() {
if (isFull.value) return
// 새 서명을 이미 열었다 — 이름 칸으로
if (selection.value?.kind === 'new') {
formRef.value?.focusName()
return
}
if (formDirty.value) {
pendingSwitch.value = { kind: 'create' }
return
}
startNew()
}
function startNew() {
beforeNew = selectedId.value
open({ kind: 'new' })
// 이름 칸에 초점 (디자인 원본 Signature16 3)
void nextTick(() => formRef.value?.focusName())
}
function onCancel() {
if (formDirty.value) {
pendingSwitch.value = { kind: 'cancel' }
return
}
cancelNew()
}
/** 새 줄을 빼고 전에 고른 서명을 연다 (없으면 첫 서명, 서명이 없으면 빈 화면) — 초점은 연 줄로 */
function cancelNew() {
const back = store.signatureById(beforeNew) ?? store.signatures[0] ?? null
open(back ? { kind: 'saved', id: back.id } : null)
focusCurrent()
}
/** 지금 줄(없으면 빈 화면의 [새 서명])에 초점 — 누른 단추가 사라졌을 때 */
function focusCurrent() {
void nextTick(() => {
const target =
rootRef.value?.querySelector<HTMLElement>('[data-signature-row][aria-current="true"]') ??
rootRef.value?.querySelector<HTMLElement>('#signature-empty-new')
target?.focus()
})
}
function onSwitchStay() {
pendingSwitch.value = null
}
/** [바꾼 내용 버리기] — 바꾼 것을 버리고 누른 일을 한다 */
function onSwitchDiscard() {
const pending = pendingSwitch.value
pendingSwitch.value = null
if (!pending) return
if (pending.kind === 'pick') open({ kind: 'saved', id: pending.id })
else if (pending.kind === 'create') startNew()
else cancelNew()
}
/** 새 서명을 처음 저장했다 — 그 서명을 고른 것으로 (칸은 그대로 둔다 — 쓰던 자리 · 실행 취소 기록이 남게) */
function onSaved(signature: MailSignature) {
if (selection.value?.kind === 'new') selection.value = { kind: 'saved', id: signature.id }
}
function onDeleteRequest() {
deleteTarget.value = selectedSignature.value
}
function onDeleteCancel() {
if (!deleting.value) deleteTarget.value = null
}
async function onDeleteConfirm() {
const target = deleteTarget.value
if (!target || deleting.value) return
deleting.value = true
try {
await store.remove(target.id)
} catch {
// 지우지 못했다 — 알림은 인터셉터. 창만 닫는다
deleting.value = false
deleteTarget.value = null
return
}
deleting.value = false
deleteTarget.value = null
toast.success(SIGNATURE_DELETE_TEXT.done(target.name))
const first = store.signatures[0] ?? null
open(first ? { kind: 'saved', id: first.id } : null)
focusCurrent()
}
// 고치던 서명이 목록에서 사라졌다(다시 받은 목록 — 다른 창에서 지웠다) — 쓰던 것은 그대로 두고 새 서명으로 본다([저장]하면 새로 만든다)
watch(selectedSignature, (current) => {
if (current === null && selection.value?.kind === 'saved') selection.value = { kind: 'new' }
})
</script>
<template>
<div
ref="rootRef"
class="signature-settings"
>
<SignatureDefaultsField v-if="store.signatures.length > 0" />
<div
v-if="selection"
class="signature-settings__body"
>
<div class="signature-settings__layout">
<SignatureList
:rows="rows"
:count="store.signatures.length"
:full="isFull"
@pick="onPick"
@create="onCreate"
/>
<div class="signature-settings__form">
<SignatureForm
ref="formRef"
:key="formKey"
:signature="selectedSignature"
:other-names="otherNames"
@saved="onSaved"
@delete="onDeleteRequest"
@cancel="onCancel"
@dirty="(dirty) => (formDirty = dirty)"
@saving="(saving) => (formSaving = saving)"
/>
</div>
</div>
</div>
<SignatureEmpty
v-else
@create="onCreate"
/>
<SignatureDeleteDialog
:open="deleteTarget !== null"
:name="deleteTarget?.name ?? ''"
:uses="deleteUses"
:busy="deleting"
@confirm="onDeleteConfirm"
@cancel="onDeleteCancel"
/>
<!-- 저장하지 않은 채 다른 서명을 고르거나 [새 서명] · [취소]를 누를 때 -->
<LeaveConfirmDialog
:open="pendingSwitch !== null"
id-prefix="signature-switch"
:copy="switchCopy"
@stay="onSwitchStay"
@leave="onSwitchDiscard"
/>
<!-- 저장하지 않은 채 다른 화면으로 갈 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="signature-leave"
:copy="SIGNATURE_LEAVE_DIALOG"
@stay="stayOnPage"
@leave="leavePage"
/>
</div>
</template>
<style scoped>
.signature-settings {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
/* 목록과 고치는 칸을 담은 칸 — 이 칸의 폭으로 배치를 고른다 (컨테이너 쿼리, SignatureList 도 같은 이름을 본다) */
.signature-settings__body {
container: signature-settings / inline-size;
}
/* 목록 왼쪽(208) · 고치는 칸 오른쪽 — 사이 24 (디자인 원본 Signature16 1) */
.signature-settings__layout {
display: flex;
align-items: flex-start;
gap: var(--space-24);
}
.signature-settings__form {
flex: 1 1 auto;
min-width: 0;
}
/* 칸이 640 보다 좁으면 목록을 위로 */
@container signature-settings (max-width: 40rem) {
.signature-settings__layout {
flex-direction: column;
align-items: stretch;
}
}
</style>
@@ -4,6 +4,7 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). Esc → 서명 칸, 서명 칸에서 Alt+F10 → 툴바
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 팝오버에서 고른 값은 서명 칸으로 돌아간다
// 그림 단추는 파일 고르기를 연다 — 올리기는 서명 화면(SignatureForm)이 한다 (pick-image).
// 16단계 — 부재중 내용 칸도 쓴다: 이름(label — "자동 응답 서식" 등) · 그림 단추 없이(allowImages: false) · 칸을 고칠 수 없을 때 모두 끔(disabled).
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref } from 'vue'
@@ -16,6 +17,7 @@ import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/compon
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'
import { SIGNATURE_TOOLBAR_LABEL } from '@/shared/constants/mail-signature'
interface Props {
editor: Editor
@@ -23,8 +25,14 @@ interface Props {
revision: number
/** 서명 칸 id — aria-controls */
controls: string
/** 툴바의 화면 낭독 이름 — 주지 않으면 "서명 서식" */
label?: string
/** 그림 단추를 둔다 — false 면 없다 (부재중 내용 칸) */
allowImages?: boolean
/** 그림을 더 넣을 수 없다 (올리는 중 등) */
imageDisabled?: boolean
/** 칸을 고칠 수 없다 — 모든 단추를 끈다 */
disabled?: boolean
}
interface Emits {
@@ -33,7 +41,10 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
label: SIGNATURE_TOOLBAR_LABEL,
allowImages: true,
imageDisabled: false,
disabled: false,
})
const emit = defineEmits<Emits>()
@@ -60,7 +71,9 @@ const marks = computed(() => [
{ 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 focusableTools = computed(() =>
TOOL_ORDER.filter((id) => id !== 'image' || (props.allowImages && !props.imageDisabled)),
)
const rovingTool = computed<ToolId>(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : 'bold',
)
@@ -179,8 +192,9 @@ defineExpose({
ref="rootRef"
class="signature-toolbar"
role="toolbar"
aria-label="서명 서식"
:aria-label="label"
:aria-controls="controls"
:aria-disabled="disabled ? 'true' : undefined"
@keydown="onKeydown"
@focusin="onFocusIn"
>
@@ -191,6 +205,7 @@ defineExpose({
:label="item.label"
:shortcut="EDITOR_SHORTCUTS[item.id]"
:pressed="item.pressed"
:disabled="disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
@@ -208,6 +223,7 @@ defineExpose({
title="글자 색"
haspopup="dialog"
:expanded="popup?.id === 'textColor'"
:disabled="disabled"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
@@ -235,6 +251,7 @@ defineExpose({
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="popup?.id === 'link'"
:disabled="disabled"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
@@ -254,10 +271,11 @@ defineExpose({
</div>
<EditorToolButton
v-if="allowImages"
tool="image"
label="그림 넣기"
title="그림 넣기 (PNG, JPG, GIF)"
:disabled="imageDisabled"
:disabled="imageDisabled || disabled"
:focusable="rovingTool === 'image'"
@press="emit('pick-image')"
>
@@ -1,7 +1,8 @@
// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings)
// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings · Signature16)
// 쓸 수 있는 것: 문단 · 줄바꿈, 굵게 · 기울임 · 밑줄, 글자 색, 링크, 서명 그림. 그 밖(목록 · 표 · 글꼴 · 글자 크기 등)은
// 스키마에 없어 붙여 넣어도 글자만 남는다. 값 형식은 쓰기 본문과 같다 (서버 정화 허용 목록 — mail-format-extensions.ts).
// 서명은 쓰기 화면을 열 때 본문에 그대로 들어가므로, 이 스키마는 쓰기 본문 스키마(mail-extensions.ts)의 일부여야 한다.
// 16단계 — 부재중 자동 응답의 내용 칸도 이 편집기를 쓴다(그림 없이 — images: false 면 스키마에 그림이 없어 붙여 넣은 그림도 빠진다).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TextStyle } from '@tiptap/extension-text-style'
import { StarterKit } from '@tiptap/starter-kit'
@@ -53,13 +54,21 @@ const STARTER_KIT_OPTIONS = {
link: false,
} as const
export function createSignatureEditorExtensions(hooks: Partial<SignatureEditorHooks> = {}): AnyExtension[] {
export interface SignatureEditorSchemaOptions {
/** 서명 그림을 받는다 — 주지 않으면 받는다 (부재중 내용 칸은 false) */
images?: boolean
}
export function createSignatureEditorExtensions(
hooks: Partial<SignatureEditorHooks> = {},
options: SignatureEditorSchemaOptions = {},
): AnyExtension[] {
return [
StarterKit.configure(STARTER_KIT_OPTIONS),
createMailLinkExtension(),
TextStyle,
MailColor,
SignatureImage,
...(options.images === false ? [] : [SignatureImage]),
SignatureBehavior.configure({
openLink: hooks.openLink ?? null,
focusToolbar: hooks.focusToolbar ?? null,
@@ -1,5 +1,7 @@
<script setup lang="ts">
// 2단계 인증 켜기 창의 ① — QR · 키 · [복사] (디자인 원본 SettingsTwoFactor "켜기 창" · 계약 5절 "2단계 인증")
// 휴대폰 바꾸기 창(16단계 — TwoFactorReplaceDialog)도 같은 부품을 쓴다 — 머리 글 · 닫을 때의 안내 · 키 이름 id 만 다르게 준다
// (두 창이 한 화면에 함께 있어 id 가 겹치지 않게)
// "인증 앱으로 QR 코드를 찍어 주세요" + QR(화면이 otpauthUrl 로 그린다 — QrCodeImage) + "인증 앱에서 [+] → 「QR 코드 스캔」…" +
// "찍을 수 없으면 이 키를 입력하세요" — 32자 키(4자씩 띄움 · 고정폭 · 띄어쓰기 없이 조각만 — 골라 복사해도 키 그대로) + [복사](36 — 밀도 예외) +
// "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." + 켜지 않고 닫으면 키를 버린다는 안내
@@ -8,7 +10,7 @@
// - 키는 일부러 두 줄(한 줄에 4묶음)로 그리고, 줄은 묶음 사이에서만 바뀐다 — 묶음 · 줄은 줄바꿈 없는 inline-block 덩어리다.
// block 으로 나누면 골라 복사할 때 줄바꿈이 끼므로 inline-block 으로 둔다(글자 사이에 띄어쓰기 · 줄바꿈 문자를 넣지 않는다 — 읽어 주는 글도 32자 그대로).
// 좁은 폭에서는 줄 안에서도 묶음 사이에서만 줄이 바뀐다 (2026-10-31 브라우저 점검 — 620 창에서 마지막 묶음이 "EL / WN" 으로 갈렸다)
// 고정 id — #two-factor-key-label
// 고정 id — #two-factor-key-label (keyLabelId 로 바꾼다)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -28,13 +30,23 @@ interface Props {
error: ApiError | null
/** 직원 주소 — "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." */
email: string
/** 머리 글 — 휴대폰 바꾸기 창은 "새 휴대폰의 인증 앱으로 …" */
title?: string
/** 마치지 않고 닫으면 키를 버린다는 안내 */
discardNote?: string
/** "찍을 수 없으면 이 키를 입력해 주세요" 의 id — [복사]의 aria-describedby 가 가리킨다 */
keyLabelId?: string
}
interface Emits {
(e: 'retry'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
title: TWO_FACTOR_ENABLE_TEXT.scanTitle,
discardNote: TWO_FACTOR_ENABLE_TEXT.discardNote,
keyLabelId: 'two-factor-key-label',
})
const emit = defineEmits<Emits>()
const toast = useToast()
@@ -54,7 +66,7 @@ async function copyKey() {
<template>
<div class="scan">
<span class="scan__title">{{ TWO_FACTOR_ENABLE_TEXT.scanTitle }}</span>
<span class="scan__title">{{ title }}</span>
<div
v-if="status === 'error' && error"
class="scan__load-error"
@@ -96,7 +108,7 @@ async function copyKey() {
</p>
<div class="scan__key">
<span
id="two-factor-key-label"
:id="keyLabelId"
class="scan__key-label"
>{{ TWO_FACTOR_ENABLE_TEXT.keyLabel }}</span>
<div class="scan__key-row">
@@ -120,7 +132,7 @@ async function copyKey() {
type="button"
class="scan__copy"
:aria-label="TWO_FACTOR_ENABLE_TEXT.copyLabel"
aria-describedby="two-factor-key-label"
:aria-describedby="keyLabelId"
:aria-disabled="enrollment ? undefined : 'true'"
data-enable-copy
@click="copyKey"
@@ -133,7 +145,7 @@ async function copyKey() {
</button>
</div>
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.savedAs(email) }}</span>
<span class="scan__key-note">{{ TWO_FACTOR_ENABLE_TEXT.discardNote }}</span>
<span class="scan__key-note">{{ discardNote }}</span>
</div>
</div>
</div>
@@ -0,0 +1,589 @@
<script setup lang="ts">
// 2단계 인증 휴대폰 바꾸기 창 — 모달 620(wide · 켜기 창과 같은 폭) · 창 하나에서 두 걸음 (16단계 — 디자인 원본 Settings16 5~8 · 계약 5절 "2단계 인증")
// ① 새 휴대폰 등록: QR · 키 · [복사](TwoFactorEnrollmentScan — 켜기 창과 같은 부품) + 새 휴대폰의 코드 → [다음] [취소]
// 키는 창을 열 때마다 새로 만든다(POST /two-factor/enrollments — 켜져 있을 때도). [다음]은 새 휴대폰의 코드를 먼저 확인한다(…/enrollments/{id}/verification)
// ② 지금 휴대폰으로 확인: 지금 휴대폰의 코드 · 지금 비밀번호 + 문의 줄(LoginSupportLine) → [이전] · [바꾸기] [취소] (POST /two-factor/replacements)
// 마지막 [바꾸기] 한 번에 바뀐다 — 그때까지 2단계 인증은 지금 휴대폰으로 그대로 켜져 있다
// - 제목 아래 차례(TwoFactorReplaceSteps)는 글로만. 처음 초점은 ①의 코드 칸, ②로 넘어가면 지금 휴대폰의 코드 칸,
// [이전]은 ①로(QR · 키는 그대로, 새 휴대폰의 코드 칸은 비운다 — 같은 코드는 2분 동안 다시 받지 않는다)
// - 칸 아래 오류(알림 없이 · 그 칸에 초점): ① 새 휴대폰의 코드 AUTH_006(칸을 비운다) / ② 지금 휴대폰의 코드 AUTH_006(칸을 비운다) · 지금 비밀번호 AUTH_005.
// 보내기 전에: 6자리가 아니면 AUTH_007 문구, 비밀번호가 비었으면 VAL_001 문구 — 칸 차례(코드 → 비밀번호)로 본다
// - AUTH_008(등록이 없음 · 10분 지남 · 더 새 등록 · 그사이 다른 곳에서 꺼짐) — 상태를 다시 받아 꺼졌으면 창을 닫고 사전 문구로 알리고(화면은 「꺼짐」),
// 아니면 ①로 돌아와 창 위에 사전 문구를 보이고 새 키로 다시 그린다(코드는 비운다)
// - 결과를 모르는 실패(503 MAIL_001 · SYS_001 — 시간 초과 · 연결 끊김 · 앞단 5xx)는 바뀌었다고도 아니라고도 하지 않는다 — 창 위 상자(unconfirmed),
// 창은 ② 그대로 · 지금 휴대폰의 코드는 비운다. 창을 닫은 뒤에 왔으면 알림(unconfirmedClosed)
// - 바꾸면 알림 "2단계 인증 휴대폰을 바꿨습니다. …"(창을 닫았거나 화면을 떠난 뒤 왔어도 — 지금 휴대폰의 코드가 막혔다), 상태는 스토어에(apply — 켠 때는 그대로)
// - 비밀(키 · otpauth 주소 · 코드 · 지금 비밀번호)은 이 창의 화면 메모리에만 두고 닫으면 지운다. 요청은 이 창이 보낸다 — Pinia 액션의 인자로 개발 도구에 남지 않게
// - 확인하는 동안 [다음]은 「확인하는 중…」, 바꾸는 동안 [바꾸기]는 「바꾸는 중…」 — 둘 다 aria-disabled(초점을 지킨다). [취소] · Esc 는 늘 누를 수 있다
// - 몇 번째로 연 창인가(openSeq)를 보낼 때 잡아 둔다 — 닫았다 다시 연 창에 앞 요청의 거절을 넣지 않는다.
// 세션이 바뀌면(로그아웃 · 만료 · 다른 기기의 [다른 곳 모두 로그아웃]) 키 · 칸을 지우고 닫는다
// 고정 id — #two-factor-replace-title · #two-factor-replace-message · #two-factor-replace-key-label ·
// #two-factor-replace-new-code · #two-factor-replace-current-code · #two-factor-replace-password
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LoginSupportLine from '@/components/auth/LoginSupportLine.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import OtpCodeField from '@/components/settings/OtpCodeField.vue'
import PasswordField from '@/components/settings/PasswordField.vue'
import TwoFactorEnrollmentScan from '@/components/settings/TwoFactorEnrollmentScan.vue'
import TwoFactorReplaceSteps, { type TwoFactorReplaceStep } from '@/components/settings/TwoFactorReplaceSteps.vue'
import { useAccountSecurity } from '@/composables/useAccountSecurity'
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_REPLACE_TEXT } from '@/shared/constants/account-security'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { TwoFactorEnrollment } from '@/shared/types/account-security'
import { isCompleteOtp } from '@/shared/utils/account-security'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { useAuthStore } from '@/stores/auth.store'
import { useSupportContactStore } from '@/stores/support-contact.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
interface Props {
open: boolean
/** 직원 주소 — "앱에는 ‘ARA SYSTEM (주소)’로 저장됩니다." */
email: string
}
interface Emits {
(e: 'close'): void
/** 바꿨다 — 상태는 스토어에 넣었고 알림도 띄웠다 */
(e: 'replaced'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** ① 새 휴대폰의 코드 확인 — 칸 아래 · 창 위에 보이는 거절 */
const VERIFY_INLINE_CODES: readonly ErrorCode[] = ['AUTH_006', 'AUTH_008']
/** ② 바꾸기 — 칸 아래 · 창 위에 보이는 거절 */
const REPLACE_INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'AUTH_006', 'AUTH_008']
/** 결과를 모르는 실패 — 엔진이 바꿨는지 모른다(503 MAIL_001 · 시간 초과 · 연결 끊김 · 앞단 5xx 는 SYS_001) */
const UNCONFIRMED_CODES: readonly ErrorCode[] = ['MAIL_001', 'SYS_001']
const api = useAccountSecurity()
const authStore = useAuthStore()
const twoFactorStore = useTwoFactorStore()
const supportContactStore = useSupportContactStore()
const toast = useToast()
const newCodeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const currentCodeRef = ref<InstanceType<typeof OtpCodeField> | null>(null)
const passwordRef = ref<InstanceType<typeof PasswordField> | null>(null)
const step = ref<TwoFactorReplaceStep>('new')
/** 등록 — 비밀이 들어 있다. 반응형 깊이 없이 둔다 */
const enrollment = shallowRef<TwoFactorEnrollment | null>(null)
const enrollStatus = ref<'loading' | 'ready' | 'error'>('loading')
const enrollError = ref<ApiError | null>(null)
/** 창 위 상자 — 처음부터 다시(AUTH_008) · 결과를 모름 */
const notice = ref<string | null>(null)
const newCode = ref<string>('')
const newCodeError = ref<string | null>(null)
const currentCode = ref<string>('')
const currentCodeError = ref<string | null>(null)
const password = ref<string>('')
const showPassword = ref<boolean>(false)
const passwordError = ref<string | null>(null)
/** 보내는 중 — ① 새 휴대폰의 코드 확인 · ② 바꾸기 */
const busy = ref<'verifying' | 'replacing' | null>(null)
/** 창을 연 차례 — 열 때 · 세션이 바뀔 때 오른다. 보낼 때 잡아 두고 응답이 오면 견준다 */
let openSeq = 0
/** 등록을 받는 차례 — 창을 닫거나 다시 받으면 앞의 응답은 버린다 */
let enrollSeq = 0
let unmounted = false
/** [다음]을 누를 수 없다 — 키가 아직 없거나 보내는 중 */
const nextUnavailable = computed(() => enrollStatus.value !== 'ready' || busy.value !== null)
const message = computed(() =>
step.value === 'new' ? TWO_FACTOR_REPLACE_TEXT.newMessage : TWO_FACTOR_REPLACE_TEXT.currentMessage,
)
function clearSecrets() {
enrollSeq += 1
enrollment.value = null
newCode.value = ''
currentCode.value = ''
password.value = ''
showPassword.value = false
}
function clearErrors() {
newCodeError.value = null
currentCodeError.value = null
passwordError.value = null
}
function resetForm() {
clearSecrets()
clearErrors()
step.value = 'new'
enrollStatus.value = 'loading'
enrollError.value = null
notice.value = null
busy.value = null
}
/** 보낸 그 창이 아직 열려 있고 세션도 그대로인가 — 늦게 온 응답을 이 창에 쓸지 */
function stillHere(session: number, opened: number): boolean {
return !unmounted && props.open && opened === openSeq && authStore.generation === session
}
async function focusLater(target: () => { focus: () => void } | null, session: number, opened: number) {
await nextTick()
if (stillHere(session, opened)) target()?.focus()
}
async function startEnrollment() {
enrollSeq += 1
const current = enrollSeq
const opened = openSeq
const session = authStore.generation
enrollment.value = null
enrollStatus.value = 'loading'
enrollError.value = null
try {
const result = await api.startTwoFactorEnrollment({ silent: true })
if (current !== enrollSeq || !stillHere(session, opened)) return
enrollment.value = result
enrollStatus.value = 'ready'
} catch (caught) {
if (current !== enrollSeq || !stillHere(session, opened)) return
enrollStatus.value = 'error'
enrollError.value = toApiError(caught)
}
}
/**
* 처음부터 다시(AUTH_008) — 2단계 인증이 그사이 다른 곳에서 꺼졌으면 창을 닫고 사전 문구로 알린다(화면은 「꺼짐」).
* 아니면(켜져 있거나 상태를 못 받음) ①로 돌아와 창 위에 사전 문구를 보이고 새 키로 다시 그린다 — 코드는 비운다
*/
async function restart(error: ApiError, session: number, opened: number) {
const status = await twoFactorStore.refresh()
if (!stillHere(session, opened)) return
if (status && !status.isEnabled) {
notifyApiError(error)
emit('close')
return
}
step.value = 'new'
notice.value = error.message
newCode.value = ''
currentCode.value = ''
clearErrors()
await startEnrollment()
await focusLater(() => newCodeRef.value, session, opened)
}
function setNewCode(value: string) {
newCode.value = value
newCodeError.value = null
}
function setCurrentCode(value: string) {
currentCode.value = value
currentCodeError.value = null
}
function setPassword(value: string) {
password.value = value
passwordError.value = null
}
/** ① [다음] — 새 휴대폰의 코드를 확인하고 ②로 */
async function verify() {
const current = enrollment.value
if (nextUnavailable.value || !current) return
if (!isCompleteOtp(newCode.value)) {
newCodeError.value = getErrorMessage('AUTH_007')
newCodeRef.value?.focus()
return
}
const opened = openSeq
const session = authStore.generation
busy.value = 'verifying'
try {
await api.verifyTwoFactorEnrollment(current.enrollmentId, { otpCode: newCode.value }, { silent: VERIFY_INLINE_CODES })
if (!stillHere(session, opened)) return
// 맞다 — 쓴 코드는 비우고 ②로 (QR · 키는 [이전]을 위해 둔다)
newCode.value = ''
notice.value = null
clearErrors()
step.value = 'current'
void focusLater(() => currentCodeRef.value, session, opened)
} catch (caught) {
const apiError = toApiError(caught)
// 확인은 엔진을 바꾸지 않는다 — 창을 닫았거나 다시 열었으면 알리지 않고 버린다
if (!stillHere(session, opened)) return
if (apiError.code === 'AUTH_006') {
newCodeError.value = apiError.message
newCode.value = ''
newCodeRef.value?.focus()
} else if (apiError.code === 'AUTH_008') {
await restart(apiError, session, opened)
}
// 그 밖의 실패(시도 과다 REQ_001 등)는 인터셉터가 알렸다
} finally {
if (opened === openSeq) busy.value = null
}
}
/** ② [바꾸기] — 지금 휴대폰의 코드 · 지금 비밀번호로 바꾼다 */
async function submit() {
const current = enrollment.value
if (busy.value !== null || !current) return
const codeProblem = isCompleteOtp(currentCode.value) ? null : getErrorMessage('AUTH_007')
const passwordProblem = password.value === '' ? getErrorMessage('VAL_001') : null
currentCodeError.value = codeProblem
passwordError.value = passwordProblem
if (codeProblem) {
currentCodeRef.value?.focus()
return
}
if (passwordProblem) {
passwordRef.value?.focus()
return
}
const opened = openSeq
const session = authStore.generation
busy.value = 'replacing'
try {
const status = await api.replaceTwoFactor(
{ enrollmentId: current.enrollmentId, currentOtpCode: currentCode.value, currentPassword: password.value },
{ silent: [...REPLACE_INLINE_CODES, ...UNCONFIRMED_CODES] },
)
if (authStore.generation !== session) return
// 서버가 바꿨다 — 창을 닫았거나 화면을 떠났어도 상태를 맞추고 알린다 (지금 휴대폰의 코드는 이제 막혔다)
twoFactorStore.apply(status)
toast.success(TWO_FACTOR_REPLACE_TEXT.done)
if (unmounted) return
if (opened === openSeq) clearSecrets()
emit('replaced')
} catch (caught) {
const apiError = toApiError(caught)
if (authStore.generation !== session) return
const unconfirmed = UNCONFIRMED_CODES.includes(apiError.code)
if (!stillHere(session, opened)) {
// 보낸 창이 없다 — 결과를 모르면 늘 알리고, 칸 아래 글은 닫힌 채일 때만 알림으로 (다시 연 창에는 넣지 않는다 · 처음부터 다시는 다음에 열 때 새 키로)
if (unconfirmed) toast.error(TWO_FACTOR_REPLACE_TEXT.unconfirmedClosed)
else if (opened === openSeq && apiError.code !== 'AUTH_008' && REPLACE_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
return
}
if (unconfirmed) {
notice.value = TWO_FACTOR_REPLACE_TEXT.unconfirmed
currentCode.value = ''
currentCodeRef.value?.focus()
} else if (apiError.code === 'AUTH_005') {
passwordError.value = apiError.message
passwordRef.value?.focus()
} else if (apiError.code === 'AUTH_006') {
currentCodeError.value = apiError.message
currentCode.value = ''
currentCodeRef.value?.focus()
} else if (apiError.code === 'AUTH_008') {
await restart(apiError, session, opened)
}
// 그 밖의 실패(시도 과다 REQ_001 · 처리 중 등)는 인터셉터가 알렸다
} finally {
if (opened === openSeq) busy.value = null
}
}
/** ② [이전] — ①로. QR · 키는 그대로, 새 휴대폰의 코드는 다시 넣는다 (지금 휴대폰의 코드도 시간이 지나 비운다) */
async function back() {
if (busy.value !== null) return
const opened = openSeq
const session = authStore.generation
step.value = 'new'
newCode.value = ''
currentCode.value = ''
notice.value = null
clearErrors()
await focusLater(() => newCodeRef.value, session, opened)
}
/** 칸에서 Enter — ①은 [다음], ②는 [바꾸기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다) */
function onFieldKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || isImeComposing(event)) return
if (!(event.target instanceof HTMLInputElement)) return
event.preventDefault()
void (step.value === 'new' ? verify() : submit())
}
function cancel() {
emit('close')
}
// 열 때마다 새 키 — 닫으면 키 · 칸을 버린다. 문의 연락처는 한 번만 받는다(못 받으면 글만)
watch(
() => props.open,
(open) => {
if (open) openSeq += 1
resetForm()
if (!open) return
void startEnrollment()
void supportContactStore.ensureLoaded()
},
{ immediate: true },
)
// 세션이 바뀌면 키 · 칸을 지우고 닫는다 — 앞 요청의 결과도 버린다
watch(
() => authStore.generation,
() => {
openSeq += 1
resetForm()
if (props.open) emit('close')
},
)
onBeforeUnmount(() => {
unmounted = true
clearSecrets()
})
</script>
<template>
<ModalDialog
:open="open"
wide
:title="TWO_FACTOR_REPLACE_TEXT.title"
title-id="two-factor-replace-title"
described-by="two-factor-replace-message"
@cancel="cancel"
>
<TwoFactorReplaceSteps :step="step" />
<p
id="two-factor-replace-message"
class="replace__message"
data-replace-message
>
{{ message }}
</p>
<p
v-if="notice"
class="replace__notice"
role="alert"
data-replace-notice
>
<AppIcon
name="alert-circle"
class="replace__notice-icon"
/>
<span>{{ notice }}</span>
</p>
<ol
v-if="step === 'new'"
class="replace__steps"
data-replace-step="new"
@keydown="onFieldKeydown"
>
<li class="replace__step">
<span
class="replace__number"
aria-hidden="true"
>1</span>
<div class="replace__step-body">
<TwoFactorEnrollmentScan
:enrollment="enrollment"
:status="enrollStatus"
:error="enrollError"
:email="email"
:title="TWO_FACTOR_REPLACE_TEXT.scanTitle"
:discard-note="TWO_FACTOR_REPLACE_TEXT.discardNote"
key-label-id="two-factor-replace-key-label"
@retry="startEnrollment"
/>
</div>
</li>
<li class="replace__step">
<span
class="replace__number"
aria-hidden="true"
>2</span>
<div class="replace__step-body">
<OtpCodeField
id="two-factor-replace-new-code"
ref="newCodeRef"
:label="TWO_FACTOR_REPLACE_TEXT.newCodeLabel"
:model-value="newCode"
:error="newCodeError"
:readonly="busy === 'verifying'"
autofocus
@update:model-value="setNewCode"
/>
</div>
</li>
</ol>
<template v-else>
<div
class="replace__fields"
data-replace-step="current"
@keydown="onFieldKeydown"
>
<OtpCodeField
id="two-factor-replace-current-code"
ref="currentCodeRef"
:label="TWO_FACTOR_REPLACE_TEXT.currentCodeLabel"
:model-value="currentCode"
:error="currentCodeError"
:readonly="busy === 'replacing'"
@update:model-value="setCurrentCode"
/>
<div class="replace__password">
<PasswordField
id="two-factor-replace-password"
ref="passwordRef"
:label="TWO_FACTOR_REPLACE_TEXT.passwordLabel"
:model-value="password"
autocomplete="current-password"
:shown="showPassword"
:toggle-label="TWO_FACTOR_REPLACE_TEXT.showPassword"
:error="passwordError"
:readonly="busy === 'replacing'"
@update:model-value="setPassword"
@toggle="showPassword = !showPassword"
/>
</div>
</div>
<div class="replace__support">
<LoginSupportLine
:question="TWO_FACTOR_REPLACE_TEXT.support"
:fallback="TWO_FACTOR_REPLACE_TEXT.support"
:contact="supportContactStore.text"
/>
</div>
</template>
<template #actions>
<template v-if="step === 'new'">
<button
type="button"
class="modal-dialog__action"
:aria-disabled="nextUnavailable ? 'true' : undefined"
:aria-busy="busy === 'verifying' ? 'true' : undefined"
data-replace-next
@click="verify"
>
{{ busy === 'verifying' ? TWO_FACTOR_REPLACE_TEXT.verifying : TWO_FACTOR_REPLACE_TEXT.next }}
</button>
</template>
<template v-else>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary replace__back"
:aria-disabled="busy !== null ? 'true' : undefined"
data-replace-back
@click="back"
>
<AppIcon
name="chevron-left"
:stroke-width="2"
/>
<span>{{ TWO_FACTOR_REPLACE_TEXT.back }}</span>
</button>
<button
type="button"
class="modal-dialog__action"
:aria-disabled="busy !== null ? 'true' : undefined"
:aria-busy="busy === 'replacing' ? 'true' : undefined"
data-replace-submit
@click="submit"
>
{{ busy === 'replacing' ? TWO_FACTOR_REPLACE_TEXT.submitting : TWO_FACTOR_REPLACE_TEXT.submit }}
</button>
</template>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
data-replace-cancel
@click="cancel"
>
{{ TWO_FACTOR_REPLACE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.replace__message {
margin: 0;
color: var(--color-text-secondary);
word-break: keep-all;
overflow-wrap: anywhere;
}
/* 창 위 상자 — 처음부터 다시 · 결과를 모름 (켜기 창의 상자와 같은 빨간 상자) */
.replace__notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: var(--space-4) 0 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.replace__notice-icon {
margin-top: var(--space-1);
}
/* ① 차례 1 · 2 — 켜기 창과 같은 청록 굵은 숫자(동그라미 없음) · 숫자 칸 22 · 사이 18 */
.replace__steps {
display: flex;
flex-direction: column;
gap: var(--space-18);
margin: var(--space-8) 0 0;
padding: 0;
list-style: none;
}
.replace__step {
display: grid;
grid-template-columns: var(--space-22) minmax(0, 1fr);
gap: var(--space-6);
}
.replace__number {
font-weight: 700;
color: var(--color-brand-strong);
font-variant-numeric: tabular-nums;
}
.replace__step-body {
min-width: 0;
}
/* ② 칸 — 사이 14 */
.replace__fields {
display: flex;
flex-direction: column;
gap: var(--space-14);
margin-top: var(--space-8);
}
/* 지금 비밀번호 칸 — 최대 320 */
.replace__password {
max-width: 20rem;
}
/* 문의 줄 — 위에 선 하나 */
.replace__support {
margin-top: var(--space-6);
padding-top: var(--space-12);
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* [이전] — 단추 줄 왼쪽 끝 · 왼쪽 화살표 (틀의 단추 모양보다 앞서게 두 클래스로) */
.modal-dialog__action.replace__back {
gap: var(--space-4);
margin-right: auto;
padding: 0 var(--space-14) 0 var(--space-10);
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
// 휴대폰 바꾸기 창의 차례 — "새 휴대폰 등록 › 지금 휴대폰으로 확인" (16단계 — 디자인 원본 Settings16 6 · 7)
// 글로만 보인다(뱃지 · 동그라미 숫자 없음): 지금 걸음은 청록 굵게(aria-current="step"), 마친 걸음은 앞에 초록 체크, 남은 걸음은 회색. 사이의 › 는 읽지 않는다
import AppIcon from '@/components/AppIcon.vue'
import { TWO_FACTOR_REPLACE_TEXT } from '@/shared/constants/account-security'
/** 바꾸기 창의 걸음 — ① 새 휴대폰 등록 · ② 지금 휴대폰으로 확인 */
export type TwoFactorReplaceStep = 'new' | 'current'
interface Props {
step: TwoFactorReplaceStep
}
defineProps<Props>()
</script>
<template>
<ol
class="replace-steps"
:aria-label="TWO_FACTOR_REPLACE_TEXT.stepsLabel"
data-replace-steps
>
<li
:class="['replace-steps__item', { 'is-current': step === 'new' }]"
:aria-current="step === 'new' ? 'step' : undefined"
>
<AppIcon
v-if="step !== 'new'"
name="check"
:size="14"
:stroke-width="2.2"
class="replace-steps__done"
/>
<span>{{ TWO_FACTOR_REPLACE_TEXT.stepNew }}</span>
</li>
<li
class="replace-steps__separator"
aria-hidden="true"
>
<AppIcon
name="chevron-right"
:size="14"
:stroke-width="2"
/>
</li>
<li
:class="['replace-steps__item', { 'is-current': step === 'current' }]"
:aria-current="step === 'current' ? 'step' : undefined"
>
<span>{{ TWO_FACTOR_REPLACE_TEXT.stepCurrent }}</span>
</li>
</ol>
</template>
<style scoped>
/* 12.5px · 사이 4 8 — 좁으면 줄을 바꾼다 */
.replace-steps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--font-size-label);
}
.replace-steps__item {
display: inline-flex;
align-items: center;
gap: var(--space-4);
color: var(--color-text-muted);
font-weight: 500;
}
.replace-steps__item.is-current {
color: var(--color-brand-strong);
font-weight: 700;
}
.replace-steps__done {
color: var(--color-success-text);
}
.replace-steps__separator {
display: inline-flex;
align-items: center;
color: var(--color-icon-muted);
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
// 부재중 자동 응답 — 회사 밖에서 온 메일 (16단계 — 디자인 원본 Settings16 1 · 2 · 4, 계약 2절 "부재중 자동 응답")
// 내용 칸과 [저장] 사이. "보낸 사람 주소가 회사 메일 주소(@도메인)가 아닌 메일입니다."(내 주소의 도메인) + 고르기 셋(라디오 18 · 이름 굵게 + 아래 설명 12.5 —
// 보내기 · 읽기 설정의 외부 이미지 칸과 같은 모양): 사내와 같은 응답(처음 값) · 보내지 않기 · 따로 쓰기 — 칸이 열리는 따로 쓰기가 맨 끝.
// 따로 쓰기를 고르면 바로 아래(고르기 글자 선 28 에 맞춰 들여 씀)에 회사 밖에 보낼 제목 · 내용 칸이 열린다 — 그 칸은 쓰는 쪽이 기본 슬롯으로 넣는다.
// 다른 고르기로 바꾸면 칸을 닫지만(v-show) 써 둔 글은 그대로 남아 함께 저장한다. 끈 동안은 묶음째 흐리게
// 고정 id — #vacation-external-same · #vacation-external-none · #vacation-external-separate · #vacation-external-hint
import { VACATION_EXTERNAL_MODES, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacationExternalMode } from '@/shared/types/mail'
interface Props {
mode: MailVacationExternalMode
/** 회사 메일 도메인 — 내 주소의 것 */
domain: string
disabled: boolean
}
interface Emits {
(e: 'update:mode', mode: MailVacationExternalMode): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<fieldset
class="vacation-external"
:disabled="disabled"
aria-describedby="vacation-external-hint"
data-vacation-external
>
<legend class="vacation-external__legend">
{{ VACATION_TEXT.externalLegend }}
</legend>
<p
id="vacation-external-hint"
class="vacation-external__hint"
>
{{ VACATION_TEXT.externalHint(domain) }}
</p>
<label
v-for="option in VACATION_EXTERNAL_MODES"
:key="option"
class="vacation-external__option"
>
<input
:id="`vacation-external-${option}`"
type="radio"
name="vacation-external"
class="vacation-external__radio"
:value="option"
:checked="mode === option"
:aria-describedby="`vacation-external-${option}-hint`"
@change="emit('update:mode', option)"
>
<span class="vacation-external__option-text">
<span class="vacation-external__option-label">{{ VACATION_TEXT.externalOptions[option].label }}</span>
<span
:id="`vacation-external-${option}-hint`"
class="vacation-external__option-hint"
>{{ VACATION_TEXT.externalOptions[option].hint }}</span>
</span>
</label>
<!-- 따로 쓰기의 두 칸 — 다른 고르기면 닫지만 글은 남긴다 -->
<div
v-show="mode === 'separate'"
class="vacation-external__separate"
data-vacation-external-separate
>
<slot />
</div>
</fieldset>
</template>
<style scoped>
.vacation-external {
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
/* 끈 동안 — 기간 묶음과 같이 글을 흐리게 (안의 제목 칸 · 편집기는 스스로 흐려진다) */
.vacation-external:disabled .vacation-external__option-text {
color: var(--color-icon-muted);
}
.vacation-external:disabled .vacation-external__option-hint {
color: inherit;
}
.vacation-external__legend {
margin: 0;
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.vacation-external__hint {
margin: var(--space-2) 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 줄마다 라디오 18 · 이름(굵게) + 아래 설명 — 줄 전체가 44 (보내기 · 읽기 설정의 외부 이미지 칸과 같다) */
.vacation-external__option {
display: flex;
align-items: flex-start;
gap: var(--space-10);
min-height: var(--size-touch);
padding: var(--space-8) 0;
cursor: pointer;
}
.vacation-external:disabled .vacation-external__option {
cursor: default;
}
.vacation-external__radio {
flex: 0 0 auto;
width: var(--size-option-input);
height: var(--size-option-input);
margin: var(--space-1) 0 0;
accent-color: var(--color-brand);
}
.vacation-external__option-text {
display: flex;
flex-direction: column;
}
.vacation-external__option-label {
font-weight: 600;
}
.vacation-external__option-hint {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 따로 쓰기의 두 칸 — 고르기 글자 선(라디오 18 + 사이 10 = 28)에 맞춰 들여 쓴다 · 사이 18 */
.vacation-external__separate {
display: flex;
flex-direction: column;
gap: var(--space-18);
padding: var(--space-6) 0 0 var(--space-28);
}
</style>
+183 -37
View File
@@ -1,35 +1,53 @@
<script setup lang="ts">
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields) → [저장] + 상태 글 → 미리 보기(VacationPreview).
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 16단계 Settings16 1~4 · 계약 2절 "부재중 자동 응답")
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields — 내용은 서명 칸과 같은 서식 편집기, 그림 없음) →
// 회사 밖에서 온 메일(VacationExternalFields — 사내와 같은 응답 · 보내지 않기 · 따로 쓰기, 따로 쓰기면 회사 밖에 보낼 제목 · 내용) →
// [저장] + 상태 글 → 미리 보기(VacationPreview — [사내 | 회사 밖]).
// 편집기(TipTap)가 든 청크라 설정 화면(MailVacationPage)이 이 양식째 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도".
// - 끄면 아래 칸을 흐리게(쓴 글은 남긴다). 켤 때만 제목 · 내용이 필요하다 — 꺼 두면 빈 칸도 저장된다
// - 저장하기 전에 막는 것(켜 둘 때): 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 — 그 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점.
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError)
// - 저장하기 전에 막는 것(켜 둘 때 — 보는 차례 기간 · 제목 · 내용 · 회사 밖에 보낼 제목 · 회사 밖에 보낼 내용, 첫 칸으로 초점):
// 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 · 서식 본문 10,000자 넘음 — 그 칸 아래에 사전 문구(VAL_001),
// 내용이 600자(서식을 뺀 보이는 글)를 넘으면 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수. 회사 밖 두 칸은 따로 쓰기일 때만 본다. 알림은 띄우지 않는다.
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError).
// MAIL_013(엔진 규칙이 한도를 넘음 — 부재중은 서식 본문이 원본과 응답에 두 번 들어가 필터가 없어도 넘을 수 있다)은 같은 자리에 부재중 글(saveTooLong)로
// - 저장: PUT /mail-vacation 일곱 칸 — 내용은 편집기의 보이는 글(textBody) · 서식 본문(htmlBody — 비었으면 null),
// 회사 밖 응답(external)은 고르기와 상관없이 따로 쓴 제목 · 내용을 함께(다른 고르기로 바꿔도 글을 남긴다 — 보드 결정).
// 서식 본문은 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크)을 쓴 칸만 — 글만 쓴 칸은 null (서버가 새 칸을 쓴 직원만 새 원본 판으로 저장한다 — 16단계 리뷰)
// - 저장하면 "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." 등 — 무엇이든 다시 바꾸면 내린다
// - 16단계 전에 저장한 글만 있는 응답은 줄마다 문단으로 연다(textToParagraphHtml)
// - 나가기 확인: 불러온(저장한 뒤에는 저장한) 값과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창 (서명 설정과 같다).
// 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
// 내용은 편집기가 잰다(isChanged — 저장한 뒤에는 저장한 내용과). 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
// 고정 id — #vacation-enabled · #vacation-save · #vacation-save-status ·
// 나가기 확인 #vacation-leave-dialog · #vacation-leave-stay · #vacation-leave-confirm (칸의 id 는 각 칸 컴포넌트에)
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import VacationExternalFields from '@/components/settings/VacationExternalFields.vue'
import VacationMessageFields from '@/components/settings/VacationMessageFields.vue'
import VacationPeriodFields from '@/components/settings/VacationPeriodFields.vue'
import VacationPreview from '@/components/settings/VacationPreview.vue'
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { VACATION_BODY_MAX_LENGTH, VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationExternalMode } from '@/shared/types/mail'
import { formatCount, formatDate } from '@/shared/utils/format'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import {
emailDomainOf,
firstVacationProblem,
sameVacationForm,
vacationExternalOf,
vacationFormOf,
vacationPayloadOf,
vacationProblems,
vacationSavedText,
type VacationForm,
type VacationPeriod,
type VacationProblem,
type VacationProblemField,
type VacationProblems,
} from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
@@ -41,29 +59,52 @@ interface Props {
const props = defineProps<Props>()
/** [저장] 옆에 보이는 서버 거절 — 알림을 끈다 (MAIL_013 은 부재중 글로 바꿔 보인다) */
const SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013']
const authStore = useAuthStore()
const vacationStore = useMailVacationStore()
const periodRef = ref<InstanceType<typeof VacationPeriodFields> | null>(null)
const messageRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
const externalRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
/**
* 편집기에 처음 넣을 내용 — 서식 본문이 있으면 그것, 없으면 지금 글을 줄마다 문단으로(16단계 전에 저장한 글만 있는 응답), 둘 다 없으면 빈 칸
*/
function bodyHtmlOf(htmlBody: string | null | undefined, textBody: string | null | undefined): string {
if (htmlBody) return htmlBody
return textBody ? textToParagraphHtml(textBody) : ''
}
const external = vacationExternalOf(props.vacation.external)
const insideHtml = bodyHtmlOf(props.vacation.htmlBody, props.vacation.textBody)
const outsideHtml = bodyHtmlOf(external.htmlBody, external.textBody)
const form = reactive<VacationForm>(vacationFormOf(props.vacation))
/** 불러온(저장한 뒤에는 저장한) 값 — 이것과 다르면 바꾼 것이다 */
/** 불러온(저장한 뒤에는 저장한) 칸 — 이것과 다르면 바꾼 것이다 (내용은 편집기가 잰다) */
const saved = shallowRef<VacationForm>(vacationFormOf(props.vacation))
/** 불러온(저장한) 뒤 내용 칸을 바꿨다 — 편집기가 잰다 */
const bodyChanged = ref<boolean>(false)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
const savedText = ref<string>('')
const problems = ref<VacationProblem[]>([])
const problems = ref<VacationProblems>({})
/** 서버가 거절한 까닭 (사전 문구) */
const saveError = ref<string | null>(null)
/** 미리 보기가 그릴 문서 — 편집기가 바뀔 때마다 */
const insideDoc = shallowRef<ProseMirrorNode | null>(null)
const outsideDoc = shallowRef<ProseMirrorNode | null>(null)
/** 화면이 사라졌다 — 저장 단추 옆 글을 볼 곳이 없다 */
let unmounted = false
const changed = computed(() => !sameVacationForm(form, saved.value))
const problemText = getErrorMessage('VAL_001')
const changed = computed(() => !sameVacationForm(form, saved.value) || bodyChanged.value)
const domain = computed(() => emailDomainOf(authStore.user?.email ?? ''))
/** 그 칸에 걸린 문제의 문구 — 없으면 null */
function problemOf(problem: VacationProblem): string | null {
return problems.value.includes(problem) ? problemText : null
/** 그 칸에 걸린 문제의 문구 — 없으면 null. 내용이 600자를 넘으면 전용 글, 그 밖은 사전 문구(VAL_001) */
function problemOf(field: VacationProblemField): string | null {
const kind = problems.value[field]
if (!kind) return null
return kind === 'tooLong' ? VACATION_TEXT.bodyTooLong(formatCount(VACATION_BODY_MAX_LENGTH)) : getErrorMessage('VAL_001')
}
// 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다
@@ -72,17 +113,35 @@ const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useL
unloadActive: () => changed.value && authStore.canUseApp,
})
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblem[]) {
Object.assign(form, patch)
problems.value = problems.value.filter((problem) => !clears.includes(problem))
function clearProblems(clears: readonly VacationProblemField[]) {
if (clears.every((field) => problems.value[field] === undefined)) return
const next = { ...problems.value }
for (const field of clears) delete next[field]
problems.value = next
}
/** 무엇이든 바꾸면 "저장했습니다" · 서버 거절을 내린다 */
function markChanged() {
saveError.value = null
if (saveState.value === 'saved') saveState.value = 'idle'
}
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 내린다 */
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblemField[]) {
Object.assign(form, patch)
clearProblems(clears)
markChanged()
}
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 모두 내린다 */
function setEnabled(event: Event) {
edit({ isEnabled: (event.target as HTMLInputElement).checked }, ['period', 'subject', 'textBody'])
edit({ isEnabled: (event.target as HTMLInputElement).checked }, [
'period',
'subject',
'textBody',
'externalSubject',
'externalTextBody',
])
}
/** 기간 정하기로 바꾸는데 날짜가 비었으면 시작을 오늘로 채운다 (끝은 사용자가 고른다) */
@@ -91,38 +150,85 @@ function setPeriod(period: VacationPeriod) {
edit({ period, ...prefill }, ['period'])
}
function focusFirst(found: readonly VacationProblem[]) {
if (found.includes('period')) periodRef.value?.focus()
else if (found.includes('subject')) messageRef.value?.focusSubject()
else if (found.includes('textBody')) messageRef.value?.focusBody()
/** 회사 밖 응답 고르기 — 다른 고르기면 회사 밖 두 칸을 보지 않으니 그 문제를 내린다 (쓴 글은 남긴다) */
function setExternalMode(mode: MailVacationExternalMode) {
edit({ externalMode: mode }, ['externalSubject', 'externalTextBody'])
}
function measureBodies() {
bodyChanged.value = (messageRef.value?.isChanged() ?? false) || (externalRef.value?.isChanged() ?? false)
}
function onInsideReady(doc: ProseMirrorNode) {
insideDoc.value = doc
}
function onOutsideReady(doc: ProseMirrorNode) {
outsideDoc.value = doc
}
/** 내용 칸을 고쳤다 — 그 칸의 문제를 내리고 미리 보기를 다시 그린다 */
function onInsideChange(doc: ProseMirrorNode) {
insideDoc.value = doc
measureBodies()
clearProblems(['textBody'])
markChanged()
}
function onOutsideChange(doc: ProseMirrorNode) {
outsideDoc.value = doc
measureBodies()
clearProblems(['externalTextBody'])
markChanged()
}
function focusProblem(field: VacationProblemField) {
if (field === 'period') periodRef.value?.focus()
else if (field === 'subject') messageRef.value?.focusSubject()
else if (field === 'textBody') messageRef.value?.focusBody()
else if (field === 'externalSubject') externalRef.value?.focusSubject()
else externalRef.value?.focusBody()
}
async function save() {
if (saveState.value === 'saving') return
const insideFields = messageRef.value
const outsideFields = externalRef.value
if (saveState.value === 'saving' || !insideFields || !outsideFields) return
saveError.value = null
const found = vacationProblems(form)
const inside = insideFields.getContent()
const outside = outsideFields.getContent()
const found = vacationProblems(form, inside, outside)
problems.value = found
if (found.length > 0) {
focusFirst(found)
const first = firstVacationProblem(found)
if (first) {
focusProblem(first)
return
}
const sent: VacationForm = { ...form }
// 저장할 때의 내용 — 저장이 끝나면 이것을 "바꾸지 않은" 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다)
const insidePoint = insideFields.captureSavePoint()
const outsidePoint = outsideFields.captureSavePoint()
saveState.value = 'saving'
try {
const result = await vacationStore.save(vacationPayloadOf(sent), { silent: ['VAL_001'] })
const result = await vacationStore.save(vacationPayloadOf(sent, inside, outside), { silent: SAVE_INLINE_CODES })
const savedForm = vacationFormOf(result)
// 저장하는 동안 더 고치지 않았으면 서버가 저장한 값으로 맞춘다 (흐린 칸의 맞지 않는 기간은 비워 저장했다)
if (sameVacationForm(form, sent)) Object.assign(form, savedForm)
saved.value = savedForm
messageRef.value?.markSaved(insidePoint)
externalRef.value?.markSaved(outsidePoint)
measureBodies()
savedText.value = vacationSavedText(result)
saveState.value = 'saved'
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (apiError.code !== 'VAL_001') return
if (!SAVE_INLINE_CODES.includes(apiError.code)) return
// 엔진 규칙이 한도를 넘음 — 사전 문구(필터 · 차단 목록) 대신 부재중 글로 (서식 본문이 두 번 들어가 필터가 없어도 넘을 수 있다)
const message = apiError.code === 'MAIL_013' ? VACATION_TEXT.saveTooLong : apiError.message
// 알림을 끈 거절 — 화면을 떠났으면 알림으로 알린다 (보내는 사이 떠나도 묻지 않는다)
if (unmounted) notifyApiError(apiError)
else saveError.value = apiError.message
if (unmounted) notifyApiError({ ...apiError, message })
else saveError.value = message
}
}
@@ -158,15 +264,47 @@ onBeforeUnmount(() => {
<VacationMessageFields
ref="messageRef"
id-prefix="vacation"
:subject-label="VACATION_TEXT.subject"
:body-label="VACATION_TEXT.body"
:toolbar-label="VACATION_TEXT.toolbarLabel"
:hint="VACATION_TEXT.bodyHint"
:subject="form.subject"
:text-body="form.textBody"
:initial-html="insideHtml"
:disabled="!form.isEnabled"
:limit-active="form.isEnabled"
:subject-error="problemOf('subject')"
:body-error="problemOf('textBody')"
@update:subject="edit({ subject: $event }, ['subject'])"
@update:text-body="edit({ textBody: $event }, ['textBody'])"
@ready="onInsideReady"
@change="onInsideChange"
/>
<VacationExternalFields
:mode="form.externalMode"
:domain="domain"
:disabled="!form.isEnabled"
@update:mode="setExternalMode"
>
<VacationMessageFields
ref="externalRef"
class="vacation-form__external-message"
id-prefix="vacation-external"
:subject-label="VACATION_TEXT.externalSubject"
:body-label="VACATION_TEXT.externalBody"
:toolbar-label="VACATION_TEXT.externalToolbarLabel"
:subject="form.externalSubject"
:initial-html="outsideHtml"
:disabled="!form.isEnabled"
:limit-active="form.isEnabled && form.externalMode === 'separate'"
:subject-error="problemOf('externalSubject')"
:body-error="problemOf('externalTextBody')"
@update:subject="edit({ externalSubject: $event }, ['externalSubject'])"
@ready="onOutsideReady"
@change="onOutsideChange"
/>
</VacationExternalFields>
<div class="vacation-form__actions">
<button
id="vacation-save"
@@ -207,8 +345,11 @@ onBeforeUnmount(() => {
:enabled="form.isEnabled"
:sender-name="authStore.user?.name ?? ''"
:sender-email="authStore.user?.email ?? ''"
:mode="form.externalMode"
:subject="form.subject"
:text-body="form.textBody"
:external-subject="form.externalSubject"
:inside-doc="insideDoc"
:outside-doc="outsideDoc"
/>
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
@@ -246,6 +387,11 @@ onBeforeUnmount(() => {
cursor: pointer;
}
/* 따로 쓰기의 두 칸 — 사이 18 (디자인 원본 Settings16 2 — 칸 묶음의 22 보다 앞서게 한 단계 더 좁혀 고른다) */
.vacation-form .vacation-form__external-message {
gap: var(--space-18);
}
.vacation-form__actions {
display: flex;
flex-wrap: wrap;
@@ -1,16 +1,37 @@
<script setup lang="ts">
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 150자) · 내용(여러 줄 · 600자 · 글자만) (디자인 원본 SettingsVacation — 한도는 계약 2절)
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
import { ref } from 'vue'
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 150자) · 내용(서식 편집기 — 16단계, 디자인 원본 Settings16 1 · 2 · 4 — 한도는 계약 2절)
// 사내 답장 칸(제목 · 내용)과 따로 쓰기의 회사 밖 칸(회사 밖에 보낼 제목 · 회사 밖에 보낼 내용)이 함께 쓴다 — id 는 idPrefix 로 가른다.
// - 내용 칸은 서명 칸과 같은 편집기(SignatureEditor — 그림 끔: 굵게 · 기울임 · 밑줄 | 글자 색 · 링크, 툴바 52 · 단추 40×44 · 맑은 고딕 14 · 줄 간격 1.75 · 높이 150).
// TipTap 이 든 청크라 이 부품은 지연 로딩한 부재중 양식(VacationForm) 안에서만 쓴다
// - 칸 이름 오른쪽에 글자 수 "132 / 600"(서식을 뺀 보이는 글 — 편집기의 getText, 주소록 메모와 같은 자리). 켜 둔 채 600자를 넘으면 빨갛게
// - 끈 동안은 흐리게(쓴 글은 남긴다 — 편집기는 읽기만). 문제가 있으면 그 칸 아래에 글(사전 문구 · 600자 글) — 검사는 부모(VacationForm)가 한다
// - 보낼 내용(getContent)의 서식 본문은 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크)이 하나라도 있을 때만 — 글만 쓴 응답은 null(hasVacationFormatting, 16단계 리뷰)
// 고정 id — #{idPrefix}-subject · #{idPrefix}-subject-error · #{idPrefix}-body(편집 영역) · #{idPrefix}-body-label · #{idPrefix}-body-error · #{idPrefix}-body-hint
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SignatureEditor from '@/components/mail/editor/signature/SignatureEditor.vue'
import type { EditorSavePoint } from '@/components/mail/editor/mail-editor-handle'
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import { formatCount } from '@/shared/utils/format'
import { hasVacationFormatting, type VacationContent } from '@/shared/utils/mail-vacation'
interface Props {
/** id 앞부분 — 사내 답장 "vacation" · 회사 밖 "vacation-external" */
idPrefix: string
subjectLabel: string
bodyLabel: string
/** 툴바의 화면 낭독 이름 */
toolbarLabel: string
/** 내용 칸 아래 안내 — 없으면 두지 않는다 (회사 밖 칸) */
hint?: string
subject: string
textBody: string
/** 편집기에 처음 넣을 내용 */
initialHtml: string
disabled: boolean
/** 글자 수 한도를 지금 보는가 — 켜 둘 때만 넘은 수를 빨갛게 */
limitActive: boolean
/** 칸마다의 문제 문구 — 없으면 null */
subjectError?: string | null
bodyError?: string | null
@@ -18,25 +39,80 @@ interface Props {
interface Emits {
(e: 'update:subject', value: string): void
(e: 'update:textBody', value: string): void
/** 편집기를 다 만들었다 — 미리 보기가 그릴 문서 */
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
hint: '',
subjectError: null,
bodyError: null,
})
const emit = defineEmits<Emits>()
const subjectRef = ref<HTMLInputElement | null>(null)
const bodyRef = ref<HTMLTextAreaElement | null>(null)
const editorRef = ref<InstanceType<typeof SignatureEditor> | null>(null)
/** 보이는 글의 길이 — 편집기가 바뀔 때마다 다시 센다 */
const bodyLength = ref<number>(0)
/** 편집 중인 문서 — 편집기가 다 만들어지거나 바뀔 때마다 받는다 (서식이 있는지 본다) */
let currentDoc: ProseMirrorNode | null = null
const subjectId = computed(() => `${props.idPrefix}-subject`)
const subjectErrorId = computed(() => `${props.idPrefix}-subject-error`)
const bodyId = computed(() => `${props.idPrefix}-body`)
const bodyLabelId = computed(() => `${props.idPrefix}-body-label`)
const bodyErrorId = computed(() => `${props.idPrefix}-body-error`)
const bodyHintId = computed(() => `${props.idPrefix}-body-hint`)
/** 편집 영역의 설명 — 오류 → 안내 차례 (디자인 원본 Settings16 4) */
const bodyDescribedBy = computed(() => {
const ids = [props.bodyError ? bodyErrorId.value : '', props.hint ? bodyHintId.value : ''].filter(Boolean)
return ids.length > 0 ? ids.join(' ') : null
})
const count = computed(() => VACATION_TEXT.bodyCount(formatCount(bodyLength.value), formatCount(VACATION_BODY_MAX_LENGTH)))
const overLimit = computed(() => props.limitActive && bodyLength.value > VACATION_BODY_MAX_LENGTH)
function countBody() {
bodyLength.value = editorRef.value?.getText().length ?? 0
}
function onReady(doc: ProseMirrorNode) {
currentDoc = doc
countBody()
emit('ready', doc)
}
function onChange(doc: ProseMirrorNode) {
currentDoc = doc
countBody()
emit('change', doc)
}
function valueOf(event: Event): string {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
return (event.target as HTMLInputElement).value
}
/**
* 보낼 내용 — 보이는 글 · 서식 본문. 서식 본문은 서식이 하나라도 있을 때만(글만 쓴 응답 · 빈 칸은 null). 편집기가 아직 없으면 빈 내용
*/
function getContent(): VacationContent {
const content = editorRef.value?.getContent()
const formatted = currentDoc !== null && hasVacationFormatting(currentDoc)
return { text: content?.text ?? '', html: formatted ? (content?.html ?? null) : null }
}
defineExpose({
focusSubject: () => subjectRef.value?.focus(),
focusBody: () => bodyRef.value?.focus(),
focusBody: () => editorRef.value?.focus(),
getContent,
/** 불러온(저장한 뒤에는 저장한) 내용과 다른가 */
isChanged: (): boolean => editorRef.value?.isChanged() ?? false,
/** 저장할 때의 내용 — 저장이 끝나면 markSaved 로 돌려준다 */
captureSavePoint: (): EditorSavePoint | null => editorRef.value?.captureSavePoint() ?? null,
markSaved: (point: EditorSavePoint | null) => {
if (point) editorRef.value?.markSaved(point)
},
})
</script>
@@ -44,11 +120,11 @@ defineExpose({
<div class="vacation-message">
<div class="vacation-message__field">
<label
for="vacation-subject"
:for="subjectId"
class="vacation-message__label"
>{{ VACATION_TEXT.subject }}</label>
>{{ subjectLabel }}</label>
<input
id="vacation-subject"
:id="subjectId"
ref="subjectRef"
class="form-input vacation-message__subject"
type="text"
@@ -56,12 +132,12 @@ defineExpose({
:maxlength="VACATION_SUBJECT_MAX_LENGTH"
:disabled="disabled"
:aria-invalid="subjectError ? 'true' : 'false'"
:aria-describedby="subjectError ? 'vacation-subject-error' : undefined"
:aria-describedby="subjectError ? subjectErrorId : undefined"
@input="emit('update:subject', valueOf($event))"
>
<p
v-if="subjectError"
id="vacation-subject-error"
:id="subjectErrorId"
class="form-message form-message--error"
>
<AppIcon
@@ -73,26 +149,33 @@ defineExpose({
</p>
</div>
<div class="vacation-message__field">
<label
for="vacation-body"
class="vacation-message__label"
>{{ VACATION_TEXT.body }}</label>
<textarea
id="vacation-body"
ref="bodyRef"
class="form-input vacation-message__body"
rows="6"
:value="textBody"
:maxlength="VACATION_BODY_MAX_LENGTH"
<div class="vacation-message__field vacation-message__body">
<div class="vacation-message__label-row">
<span
:id="bodyLabelId"
class="vacation-message__label"
>{{ bodyLabel }}</span>
<span
:class="['vacation-message__count', { 'is-over': overLimit }]"
:data-vacation-count="idPrefix"
>{{ count }}</span>
</div>
<SignatureEditor
ref="editorRef"
:body-id="bodyId"
:toolbar-label="toolbarLabel"
:allow-images="false"
:labelled-by="bodyLabelId"
:described-by="bodyDescribedBy"
:invalid="bodyError !== null"
:disabled="disabled"
:aria-invalid="bodyError ? 'true' : 'false'"
:aria-describedby="bodyError ? 'vacation-body-hint vacation-body-error' : 'vacation-body-hint'"
@input="emit('update:textBody', valueOf($event))"
:initial-html="initialHtml"
@ready="onReady"
@change="onChange"
/>
<p
v-if="bodyError"
id="vacation-body-error"
:id="bodyErrorId"
class="form-message form-message--error"
>
<AppIcon
@@ -103,10 +186,11 @@ defineExpose({
<span>{{ bodyError }}</span>
</p>
<p
id="vacation-body-hint"
v-if="hint"
:id="bodyHintId"
class="form-message"
>
{{ VACATION_TEXT.bodyHint }}
{{ hint }}
</p>
</div>
</div>
@@ -134,10 +218,28 @@ defineExpose({
max-width: var(--form-wide-max-width);
}
/* 내용 칸 이름 줄 — 오른쪽에 글자 수 (주소록 메모와 같은 자리 · 12px) */
.vacation-message__label-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-8);
}
.vacation-message__count {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 켜 둔 채 600자를 넘음 — 빨갛게 굵게 (디자인 원본 Settings16 4) */
.vacation-message__count.is-over {
font-weight: 600;
color: var(--color-danger);
}
/* 편집 영역 높이 150 (디자인 원본 Settings16 — 서명 칸은 132) */
.vacation-message__body {
width: 100%;
padding: var(--space-10) var(--space-12);
line-height: var(--line-height-body);
resize: vertical;
--signature-editor-min-height: 9.375rem;
}
</style>
@@ -1,26 +1,69 @@
<script setup lang="ts">
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation)
// 보낸 사람(나) · 제목 · 내용(글자 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄
// (켜짐: "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." · 꺼짐: "꺼져 있습니다 — 자동으로 답하지 않습니다.")
import { computed } from 'vue'
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation · 16단계 Settings16 3)
// 머리 오른쪽 전환 [사내 | 회사 밖](36 — 서명 미리 보기와 같은 .segmented, 밀도 예외 재사용) — 처음은 사내 쪽. 사내와 같은 응답일 때도 둔다.
// 보낸 사람(나) · 제목 · 내용(받는 사람이 보는 서식 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄(vacationPreviewNote).
// - 내용은 편집 중인 문서를 편집기 스키마로 그린다(DOMSerializer — 스키마가 그리는 태그 · 속성뿐이라 남의 HTML 을 넣지 않는다, 서명 미리 보기와 같다).
// 칸을 고친 값을 바로 그린다. 따로 쓰기의 회사 밖 쪽은 회사 밖에 보낼 제목 · 내용, 그 밖은 위의 제목 · 내용
// - 보내지 않기의 회사 밖 쪽은 답장 대신 회색 상자 "회사 밖에서 온 메일에는 자동으로 답하지 않습니다." — 아래 글은 없다(꺼져 있으면 꺼짐 글)
import { DOMSerializer, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, watch } from 'vue'
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacationExternalMode } from '@/shared/types/mail'
import {
vacationPreviewNote,
vacationPreviewSendsNothing,
vacationPreviewUsesExternal,
type VacationPreviewSide,
} from '@/shared/utils/mail-vacation'
interface Props {
enabled: boolean
senderName: string
senderEmail: string
/** 회사 밖에서 온 메일에 보낼 응답 */
mode: MailVacationExternalMode
subject: string
textBody: string
externalSubject: string
/** 편집 중인 문서 — 사내(위 내용) · 회사 밖(따로 쓴 내용). 아직 없으면 null */
insideDoc: ProseMirrorNode | null
outsideDoc: ProseMirrorNode | null
}
const props = defineProps<Props>()
const SIDES: readonly { side: VacationPreviewSide; label: string }[] = [
{ side: 'inside', label: VACATION_TEXT.previewInside },
{ side: 'outside', label: VACATION_TEXT.previewOutside },
]
const side = ref<VacationPreviewSide>('inside')
const bodyRef = ref<HTMLElement | null>(null)
const sender = computed(() =>
props.senderName ? `${props.senderName} <${props.senderEmail}>` : props.senderEmail,
)
const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
const usesExternal = computed(() => vacationPreviewUsesExternal(props.mode, side.value))
const sendsNothing = computed(() => vacationPreviewSendsNothing(props.mode, side.value))
const shownSubject = computed(
() => (usesExternal.value ? props.externalSubject : props.subject).trim() || MAIL_NO_SUBJECT,
)
const shownDoc = computed(() => (usesExternal.value ? props.outsideDoc : props.insideDoc))
const note = computed(() => vacationPreviewNote(props.enabled, props.mode, side.value))
/** 문서를 그린다 — 스키마의 toDOM 결과만 붙인다 */
function render() {
const target = bodyRef.value
if (!target) return
target.replaceChildren()
const doc = shownDoc.value
if (!doc) return
const fragment = DOMSerializer.fromSchema(doc.type.schema).serializeFragment(doc.content, { document })
target.append(fragment)
}
watch([shownDoc, bodyRef], render, { flush: 'post', immediate: true })
</script>
<template>
@@ -29,13 +72,42 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
aria-labelledby="vacation-preview-title"
data-vacation-preview
>
<h2
id="vacation-preview-title"
class="vacation-preview__title"
<div class="vacation-preview__head">
<h2
id="vacation-preview-title"
class="vacation-preview__title"
>
{{ VACATION_TEXT.previewTitle }}
</h2>
<div
role="group"
:aria-label="VACATION_TEXT.previewSwitchLabel"
class="segmented"
>
<button
v-for="option in SIDES"
:key="option.side"
type="button"
class="segmented__item vacation-preview__side"
:data-preview-side="option.side"
:aria-pressed="side === option.side"
@click="side = option.side"
>
{{ option.label }}
</button>
</div>
</div>
<div
v-if="sendsNothing"
class="vacation-preview__none"
data-vacation-preview-none
>
{{ VACATION_TEXT.previewNone }}
</div>
<div
v-else
class="vacation-preview__card"
>
{{ VACATION_TEXT.previewTitle }}
</h2>
<div class="vacation-preview__card">
<dl class="vacation-preview__meta">
<dt class="vacation-preview__label">
{{ VACATION_TEXT.previewFrom }}
@@ -53,18 +125,18 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
{{ shownSubject }}
</dd>
</dl>
<!-- 글자 그대로 — 템플릿의 들여쓰기 공백이 끼지 않게 v-text 로 넣는다 -->
<div
ref="bodyRef"
class="vacation-preview__body"
data-vacation-preview-body
v-text="textBody"
/>
</div>
<p
v-if="note"
class="vacation-preview__note"
data-vacation-preview-note
>
{{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
{{ note }}
</p>
</section>
</template>
@@ -76,12 +148,24 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
gap: var(--space-12);
}
/* 머리 — 제목 + 전환, 좁으면 줄을 바꾼다 (사이 8 14) */
.vacation-preview__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-14);
}
.vacation-preview__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.vacation-preview__side {
padding: 0 var(--space-14);
}
.vacation-preview__card {
display: flex;
flex-direction: column;
@@ -110,7 +194,7 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
overflow-wrap: anywhere;
}
/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
/* 받는 쪽이 보는 모양 — 메일 본문 글꼴 · 문단 사이 없음 · 여러 칸 띄어쓰기는 그대로(보내는 본문이 nbsp 로 바꾼다) */
.vacation-preview__body {
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -120,6 +204,28 @@ const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
color: var(--color-text-body);
}
.vacation-preview__body :deep(p) {
margin: 0;
}
.vacation-preview__body :deep(a) {
color: var(--color-brand);
}
/* 보내지 않기의 회사 밖 쪽 — 답장 자리에 회색 상자 (최소 높이 120) */
.vacation-preview__none {
display: flex;
align-items: center;
justify-content: center;
min-height: 7.5rem;
padding: var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-panel);
color: var(--color-text-secondary);
text-align: center;
}
.vacation-preview__note {
margin: 0;
font-size: var(--font-size-label);
+10 -5
View File
@@ -5,7 +5,8 @@ import { defineComponent, h } from 'vue'
import MailSignaturePage from '@/pages/settings/MailSignaturePage.vue'
import SettingsLayout from '@/pages/settings/SettingsLayout.vue'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import type { SignatureImage } from '@/shared/types/mail'
import type { MailSignature } from '@/shared/types/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
@@ -21,10 +22,10 @@ const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const SIGNATURE: MailSignature = {
id: 'sig-1',
name: '기본 서명',
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0">김아라 · 인프라팀</p></div>',
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [],
updatedAt: '2026-10-27T05:00:00Z',
}
@@ -44,7 +45,11 @@ const UPLOADED: SignatureImage = {
const PasswordScreen = defineComponent({ name: 'PasswordScreen', render: () => h('h1', '비밀번호') })
const server: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/mail-signature') return ok(SIGNATURE)
// 서명 설정 (16단계 — 여러 서명): 목록 · 기본 둘
if (method === 'GET' && url === '/mail-signatures') {
return ok({ items: [SIGNATURE], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
if (method === 'GET' && url === '/mail-signature-defaults') return ok({ newMessageId: SIGNATURE.id, replyId: SIGNATURE.id })
if (method === 'POST' && url === '/mail-signature/images') return ok(UPLOADED, 201)
return ok(null)
}
@@ -55,7 +60,7 @@ describe('AppShell — 잘못 놓은 파일 (15단계)', () => {
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
await import('@/components/mail/editor/signature/SignatureSettings.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
+38 -2
View File
@@ -12,7 +12,10 @@ import type {
TwoFactorActivationPayload,
TwoFactorDeactivationPayload,
TwoFactorEnrollment,
TwoFactorReplacementPayload,
TwoFactorStatus,
TwoFactorVerification,
TwoFactorVerificationPayload,
} from '@/shared/types/account-security'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -35,13 +38,44 @@ export function useAccountSecurity() {
}
/**
* 등록 시작 (꺼져 있을 때만) — 새 키 · otpauth 주소 · 이때까지 켜야 하는 시각(10분).
* 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다. 이미 켜져 있으면 409 AUTH_008
* 등록 시작 — 새 키 · otpauth 주소 · 이때까지 켜거나 바꿔야 하는 시각(10분). 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용이다(16단계).
* 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다
*/
function startTwoFactorEnrollment(options?: AccountSecurityRequestOptions): Promise<TwoFactorEnrollment> {
return api.post<TwoFactorEnrollment, TwoFactorEnrollment>('/two-factor/enrollments', null, options)
}
/**
* 휴대폰 바꾸기 ① (16단계) — 새 휴대폰의 코드를 그 등록의 키로 먼저 확인한다(엔진은 등록 때 코드를 보지 않는다). 맞으면 서버가 등록에 "확인됨"을 둔다.
* 코드 틀림 · 다시 쓴 코드 400 AUTH_006 · 등록이 없음 · 10분 지남 · 더 새 등록 409 AUTH_008
*/
function verifyTwoFactorEnrollment(
enrollmentId: string,
payload: TwoFactorVerificationPayload,
options?: AccountSecurityRequestOptions,
): Promise<TwoFactorVerification> {
return api.post<TwoFactorVerification, TwoFactorVerification>(
`/two-factor/enrollments/${segment(enrollmentId)}/verification`,
payload,
options,
)
}
/**
* 휴대폰 바꾸기 ② (16단계) — 지금 비밀번호를 먼저 보고(AUTH_005) 지금 휴대폰의 코드로(AUTH_006) 새 키로 바꾼다. 2단계 인증이 꺼지는 틈이 없다.
* 등록이 확인되지 않음 · 10분 지남 · 그사이 꺼짐 409 AUTH_008. 결과를 모르면 503 MAIL_001 — 다시 읽어도 가를 수 없다.
* 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다. 응답은 GET 과 같은 모양(켠 때는 그대로)
*/
function replaceTwoFactor(
payload: TwoFactorReplacementPayload,
options?: AccountSecurityRequestOptions,
): Promise<TwoFactorStatus> {
return api.post<TwoFactorStatus, TwoFactorStatus>('/two-factor/replacements', payload, {
timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
...options,
})
}
/**
* 켜기 — 코드를 등록의 키로 먼저 확인한 뒤 엔진에 쓴다. 코드 틀림 400 AUTH_006 · 지금 비밀번호 틀림 400 AUTH_005 ·
* 등록이 없음 · 10분 지남 · 그사이 바뀜 409 AUTH_008. 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다
@@ -116,6 +150,8 @@ export function useAccountSecurity() {
return {
getTwoFactor,
startTwoFactorEnrollment,
verifyTwoFactorEnrollment,
replaceTwoFactor,
activateTwoFactor,
deactivateTwoFactor,
listAppPasswords,
@@ -0,0 +1,430 @@
import { notifyApiError, toApiError, type ApiError } from './useApi'
import { useContacts } from './useContacts'
import { useToast } from './useToast'
import {
CONTACT_IMPORT_BATCH_MAX_BYTES,
CONTACT_IMPORT_BATCH_MAX_VALUES,
CONTACT_IMPORT_BATCH_SIZE,
CONTACT_IMPORT_TOAST_TEXT,
CONTACT_IMPORT_UNDO_BATCH_SIZE,
} from '@/shared/constants/contacts'
import type { ContactImportChecked, ContactImportCheckStatus, ContactImported, ContactImportItem } from '@/shared/types/contacts'
import { envelopeOf, requestBatchSize, type RequestBatchLimits } from '@/shared/utils/contact-import/batches'
import { readContactFile } from '@/shared/utils/contact-import/read-file'
import {
checkItemOf,
contactGroupKey,
prepareImport,
previewOf,
type ContactImportPlan,
type ContactImportPlanEntry,
type ImportPreview,
} from '@/shared/utils/contact-import/records'
import type { ContactFileProblem, ContactFileReadResult, ImportedFileKind } from '@/shared/utils/contact-import/types'
import { formatCount } from '@/shared/utils/format'
import { createUuid } from '@/shared/utils/uuid'
import { useAuthStore } from '@/stores/auth.store'
import { useContactImportStore } from '@/stores/contact-import.store'
import { useContactsStore } from '@/stores/contacts.store'
import type { ToastAction } from '@/stores/toast.store'
// 주소록 가져오기 — 파일 읽기 · 미리 보기 확인 · 넣기 · [되돌리기] (16단계 — 디자인 원본 Contacts16 "2" ~ "4" · 계약 3절 "가져오기")
// 읽기(read): 파일은 이 브라우저에서 읽고(shared/utils/contact-import), 넣을 수 있는 연락처만 묶음(아래)으로 서버에 이미 있는지 묻는다
// (POST /contact-import-checks — 만들지 않는다). 남은 자리(remaining)를 다 채우면 더 묻지 않는다 — 넘는 연락처는 어차피 넣지 않는다
// (요청 수 한도 — 경로 · IP 마다 1분 100회 — 를 아끼려고. 그 연락처 가운데 이미 있는 것은 미리 보기의 수에 "넣을 연락처"로 세진다).
// 실패는 인터셉터가 사전 문구로 알린다 — 창은 파일 고르기로 돌아간다
// 넣기(start): 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명" — 새 그룹을 먼저 만들고(같은 이름이 생겼으면 그 그룹, 100개면 그 그룹 없이.
// 만들기 응답만 잃은 뒤 [다시 시도]에서 같은 이름이 돌아오면 그 그룹도 이번에 만든 것으로 센다)
// 묶음마다 POST /contact-imports. 끝나면 같은 자리에서 결과 "연락처 330명을 넣었습니다. …" + [되돌리기] + ✕(✕로 닫을 때까지 남는다).
// 멈추면 오류 알림 + [다시 시도](남은 것부터 — 같은 uid 라 두 번 들어가지 않는다, 이 알림도 ✕까지 남는다). 멈춘 알림에는 [되돌리기]가 없다.
// - 409 CONTACT_002: 그사이 주소록이 찼다 — 남은 자리를 다시 물어 그만큼만 넣고 나머지는 "넣지 못함"(크기 한도면 멈춘다)
// - 404 RES_001: 넣을 그룹이 그사이 지워졌다 — 그룹을 다시 받아 없는 그룹은 빼고 한 번 더
// [되돌리기]: "가져온 연락처를 지우는 중 · 100 / 330명" — 이번에 넣은 연락처를 묶음마다 지우고(POST /contact-deletions) 이번에 새로 만든 그룹을 지운다
// 확인 · 넣기 · 되돌리기 묶음은 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 끊는다(batches.ts — 서버 본문 한도 값 10,000 · 1MB 를 넘지 않게,
// 칸을 한도까지 채운 연락처가 몰리면 100명보다 적게 보낸다)
// 넣기 · 되돌리기 · [다시 시도]는 이 탭에서 하나씩 돈다(contact-import.store 의 enqueue — 도는 동안 누른 것은 끝난 뒤에, 그사이 세션이 바뀌었으면 하지 않는다).
// 다른 화면으로 가도 이어 한다. 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 다음 요청을 보내지 않고 알림을 닫는다(진행 · 단추 알림은 toast.store 가 닫는다).
// 끝나거나 멈추면 연락처 수 · 그룹 · 보는 목록을 다시 받는다 (contacts.store 의 afterBulkChange)
/** 파일을 읽은 결과 — ready 면 미리 보기, failed 면 칸 아래 글의 까닭, error 면 서버 확인이 실패(알림은 인터셉터가 띄웠다) */
export type ContactImportReadOutcome =
| { status: 'ready'; preview: ImportPreview }
| { status: 'failed'; problem: ContactFileProblem; kind: ImportedFileKind | null }
| { status: 'error' }
| { status: 'canceled' }
/** 넣는 일의 자리 — [다시 시도]가 이어 간다 */
interface ImportRun {
plan: ContactImportPlan
/** 그룹 열쇠 → id (null 이면 만들지 못함 — 그 그룹 없이 넣는다) */
groupIds: Map<string, string | null>
/** 이번에 새로 만든 그룹 — [되돌리기]가 지운다 */
createdGroupIds: string[]
/**
* 만들기를 보냈지만 결과를 모르는 그룹(열쇠 — 응답 없음 · 5xx). 서버에서는 만들어졌을 수 있다 —
* [다시 시도]에서 같은 이름(CONTACT_001)이 돌아오면 이번에 만든 그룹으로 센다(되돌리면 지운다)
*/
unsureGroupKeys: Set<string>
/** 다음에 보낼 자리 · 끝(한도에 걸리면 줄어든다) */
next: number
end: number
insertedIds: string[]
/** 메일 서버가 받지 않은 연락처 수 */
missed: number
/** 그룹에 넣지 못한 연락처(uid) — 그룹 100개 · 구성원 500명 */
groupLess: Set<string>
overLimit: number
}
interface UndoRun {
ids: string[]
groupIds: string[]
next: number
groupNext: number
groupsDeleted: number
}
type RunOutcome = 'done' | 'failed' | 'canceled'
/** 확인 · 넣기 묶음 — 100명 · 값 8,000개 · 700KB 가운데 먼저 닿는 데서 (batches.ts) */
const IMPORT_BATCH_LIMITS: RequestBatchLimits = {
maxItems: CONTACT_IMPORT_BATCH_SIZE,
maxValues: CONTACT_IMPORT_BATCH_MAX_VALUES,
maxBytes: CONTACT_IMPORT_BATCH_MAX_BYTES,
}
/** 되돌리기 묶음 — 100명 · 값 · 바이트 (id 뿐이라 보통 사람 수에서 끊긴다) */
const UNDO_BATCH_LIMITS: RequestBatchLimits = { ...IMPORT_BATCH_LIMITS, maxItems: CONTACT_IMPORT_UNDO_BATCH_SIZE }
/** 본문의 겉 — `{"contacts":[…]}` · `{"ids":[…]}` */
const CONTACTS_ENVELOPE = envelopeOf('contacts')
const IDS_ENVELOPE = envelopeOf('ids')
/** 서버가 받았는지 모르는 실패 — 응답이 없거나(시간 초과 · 연결 끊김) 5xx. 4xx 는 서버가 거절한 것이다 */
function outcomeUnknown(error: ApiError): boolean {
return error.status === undefined || error.status >= 500
}
/** 화면이 만드는 uid — urn:uuid: + 소문자 UUID (계약 3절) */
export function newContactUid(): string {
return `urn:uuid:${createUuid().toLowerCase()}`
}
export function useContactImport() {
const contactsApi = useContacts()
const contactsStore = useContactsStore()
const importStore = useContactImportStore()
const authStore = useAuthStore()
const toast = useToast()
// ── 읽기 ─────────────────────────────────────────
/** 파일을 읽고 이미 있는 연락처를 서버에 묻는다 — signal 을 끊으면(창을 닫음 · 다른 파일) canceled */
async function read(file: File, signal: AbortSignal): Promise<ContactImportReadOutcome> {
let result: ContactFileReadResult
try {
result = await readContactFile(file)
} catch {
// 읽다가 뜻밖에 멈췄다(깨진 파일 등) — 창이 "읽는 중"에 머물지 않게 열지 못한 파일로 알린다
result = { ok: false, problem: 'unreadable', kind: null }
}
if (signal.aborted) return { status: 'canceled' }
if (!result.ok) return { status: 'failed', problem: result.problem, kind: result.kind }
const prepared = prepareImport(result.kind, result.records, newContactUid)
const items = prepared.candidates.map(checkItemOf)
const statuses = new Map<string, ContactImportCheckStatus>()
let remaining: number | null = null
let fresh = 0
for (let start = 0; start < items.length; ) {
if (remaining !== null && fresh >= remaining) break
const window = items.slice(start, start + CONTACT_IMPORT_BATCH_SIZE)
const batch = window.slice(0, requestBatchSize(window, IMPORT_BATCH_LIMITS, CONTACTS_ENVELOPE))
start += batch.length
let checked: ContactImportChecked
try {
checked = await contactsApi.checkContactImport(batch, { signal })
} catch (caught) {
return signal.aborted || toApiError(caught).canceled ? { status: 'canceled' } : { status: 'error' }
}
if (signal.aborted) return { status: 'canceled' }
remaining = checked.remaining
for (const item of checked.items) {
statuses.set(item.uid, item.status)
if (item.status === 'new') fresh += 1
}
}
return { status: 'ready', preview: previewOf(prepared, statuses, remaining) }
}
// ── 넣기 ─────────────────────────────────────────
function count(value: number): string {
return formatCount(value)
}
function progressText(run: ImportRun): string {
return CONTACT_IMPORT_TOAST_TEXT.progress(count(run.insertedIds.length), count(run.end))
}
/** 끝 알림 — 넣은 수 + 건너뛴 수 · 한도에 걸린 수를 한 문장씩 */
function resultText(run: ImportRun): string {
const parts: string[] = [CONTACT_IMPORT_TOAST_TEXT.done(count(run.insertedIds.length))]
if (run.plan.skipped > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.skipped(count(run.plan.skipped)))
if (run.overLimit > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.full(count(run.overLimit)))
if (run.groupLess.size > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.groupLimit(count(run.groupLess.size)))
if (run.missed > 0) parts.push(CONTACT_IMPORT_TOAST_TEXT.missed(count(run.missed)))
return parts.join(' ')
}
/** 그룹 열쇠들 → 이번 줄의 groupIds (만들지 못한 그룹은 뺀다) */
function itemOf(entry: ContactImportPlanEntry, run: ImportRun): ContactImportItem {
const ids = entry.groupKeys
.map((key) => run.groupIds.get(key) ?? null)
.filter((id): id is string => id !== null)
return { ...entry.item, groupIds: [...new Set(ids)] }
}
/** 같은 이름의 그룹이 그사이 생겼다 — 그룹을 다시 받아 그 그룹의 id (없으면 null) */
async function existingGroupId(key: string): Promise<string | null> {
await contactsStore.loadGroups()
return contactsStore.groups.find((group) => contactGroupKey(group.name) === key)?.id ?? null
}
/**
* 새 그룹을 만든다(이미 만든 것은 건너뛴다 — [다시 시도]). 다 됐으면 null, 멈춰야 하면 그 결과
* (같은 이름 CONTACT_001 은 그 그룹, 100개 CONTACT_002 는 그 그룹 없이 — 둘은 알리지 않는다. 그 밖의 실패는 인터셉터가 알렸다)
*/
async function resolveGroups(run: ImportRun, alive: () => boolean): Promise<RunOutcome | null> {
for (const group of run.plan.groups) {
if (run.groupIds.has(group.key)) continue
if (group.id !== null) {
run.groupIds.set(group.key, group.id)
continue
}
try {
const created = await contactsApi.createContactGroup(
{ name: group.name },
{ silent: ['CONTACT_001', 'CONTACT_002'] },
)
if (!alive()) return 'canceled'
run.groupIds.set(group.key, created.id)
run.createdGroupIds.push(created.id)
} catch (caught) {
if (!alive()) return 'canceled'
const error = toApiError(caught)
if (error.code === 'CONTACT_001') {
const id = await existingGroupId(group.key)
run.groupIds.set(group.key, id)
// 앞선 만들기의 응답만 잃었다 — 그 그룹은 이번에 만든 것이다(되돌리면 지운다)
if (id !== null && run.unsureGroupKeys.has(group.key) && !run.createdGroupIds.includes(id)) {
run.createdGroupIds.push(id)
}
} else if (error.code === 'CONTACT_002') {
run.groupIds.set(group.key, null)
} else {
if (outcomeUnknown(error)) run.unsureGroupKeys.add(group.key)
return 'failed'
}
if (!alive()) return 'canceled'
}
}
return null
}
/** 남은 자리 — 409 CONTACT_002 뒤에 다시 묻는다. 모르면 null */
async function roomFor(items: readonly ContactImportItem[]): Promise<number | null> {
try {
const checked = await contactsApi.checkContactImport(items, { silent: true })
return Math.max(0, checked.remaining)
} catch {
return null
}
}
/** 그룹이 그사이 지워졌다 — 그룹을 다시 받아 없는 그룹은 뺀다 */
async function forgetRemovedGroups(run: ImportRun): Promise<void> {
await contactsStore.loadGroups()
const present = new Set(contactsStore.groups.map((group) => group.id))
for (const [key, id] of run.groupIds) {
if (id !== null && !present.has(id)) run.groupIds.set(key, null)
}
}
function noteResult(run: ImportRun, slice: readonly ContactImportPlanEntry[], result: ContactImported) {
const byUid = new Map(result.items.map((item) => [item.uid, item]))
for (const entry of slice) {
const created = byUid.get(entry.item.uid)
if (!created) {
run.missed += 1
continue
}
run.insertedIds.push(created.id)
const groupLess = entry.groupKeys.some((key) => (run.groupIds.get(key) ?? null) === null)
if (groupLess || created.skippedGroupIds.length > 0) run.groupLess.add(entry.item.uid)
}
}
async function runImport(run: ImportRun, alive: () => boolean, onProgress: () => void): Promise<RunOutcome> {
const grouped = await resolveGroups(run, alive)
if (grouped !== null) return grouped
let retriedGroups = false
while (run.next < run.end) {
const window = run.plan.entries.slice(run.next, Math.min(run.end, run.next + CONTACT_IMPORT_BATCH_SIZE))
const windowItems = window.map((entry) => itemOf(entry, run))
const size = requestBatchSize(windowItems, IMPORT_BATCH_LIMITS, CONTACTS_ENVELOPE)
const slice = window.slice(0, size)
const items = windowItems.slice(0, size)
let result: ContactImported
try {
result = await contactsApi.importContacts(items, { silent: ['CONTACT_002', 'RES_001'] })
} catch (caught) {
if (!alive()) return 'canceled'
const error = toApiError(caught)
if (error.code === 'CONTACT_002') {
const room = await roomFor(items)
if (!alive()) return 'canceled'
if (room !== null && room < items.length) {
const end = run.next + room
run.overLimit += run.end - end
run.end = end
continue
}
} else if (error.code === 'RES_001' && !retriedGroups) {
retriedGroups = true
await forgetRemovedGroups(run)
if (!alive()) return 'canceled'
continue
}
if (error.code === 'CONTACT_002' || error.code === 'RES_001') notifyApiError(error)
return 'failed'
}
if (!alive()) return 'canceled'
retriedGroups = false
noteResult(run, slice, result)
run.next += slice.length
onProgress()
}
return 'done'
}
function undoAction(run: ImportRun): ToastAction | undefined {
if (run.insertedIds.length === 0 && run.createdGroupIds.length === 0) return undefined
const undo: UndoRun = {
ids: [...run.insertedIds],
groupIds: [...run.createdGroupIds],
next: 0,
groupNext: 0,
groupsDeleted: 0,
}
return {
label: CONTACT_IMPORT_TOAST_TEXT.undo,
run: () => void importStore.enqueue('undo', () => executeUndo(undo)),
}
}
/** 넣는다 — 차례(contact-import.store 의 enqueue) 안에서 부른다 */
async function execute(run: ImportRun): Promise<void> {
const session = authStore.generation
const alive = () => authStore.generation === session
const progress = toast.progress(progressText(run))
const outcome = await runImport(run, alive, () => progress.update(progressText(run)))
if (outcome === 'canceled' || !alive()) {
progress.dismiss()
return
}
contactsStore.afterBulkChange()
if (outcome === 'done') {
progress.succeed(resultText(run), undoAction(run), { sticky: true })
return
}
const retry: ToastAction = {
label: CONTACT_IMPORT_TOAST_TEXT.retry,
run: () => void importStore.enqueue('import', () => execute(run)),
}
const stopped =
run.insertedIds.length > 0
? CONTACT_IMPORT_TOAST_TEXT.stopped(count(run.end), count(run.insertedIds.length))
: CONTACT_IMPORT_TOAST_TEXT.notStarted
progress.fail(stopped, retry, { sticky: true })
}
/**
* [연락처 N명 넣기] — 창이 만든 계획대로 넣는다(창은 이미 닫혔다). 이 탭에서 가져오기 · 되돌리기가 도는 중이면 끝난 뒤에 한다
* ([가져오기]가 그동안 열리지 않으니 보통은 바로 시작한다)
*/
function start(plan: ContactImportPlan): Promise<void> {
const run: ImportRun = {
plan,
groupIds: new Map(),
createdGroupIds: [],
unsureGroupKeys: new Set(),
next: 0,
end: plan.entries.length,
insertedIds: [],
missed: 0,
groupLess: new Set(),
overLimit: plan.overLimit,
}
return importStore.enqueue('import', () => execute(run))
}
// ── 되돌리기 ────────────────────────────────────────
async function runUndo(undo: UndoRun, alive: () => boolean, onProgress: () => void): Promise<RunOutcome> {
while (undo.next < undo.ids.length) {
const window = undo.ids.slice(undo.next, undo.next + CONTACT_IMPORT_UNDO_BATCH_SIZE)
const batch = window.slice(0, requestBatchSize(window, UNDO_BATCH_LIMITS, IDS_ENVELOPE))
try {
await contactsApi.deleteContacts(batch)
} catch {
return alive() ? 'failed' : 'canceled'
}
if (!alive()) return 'canceled'
undo.next += batch.length
onProgress()
}
while (undo.groupNext < undo.groupIds.length) {
const id = undo.groupIds[undo.groupNext] ?? ''
try {
await contactsApi.deleteContactGroup(id, { silent: ['RES_001'] })
undo.groupsDeleted += 1
} catch (caught) {
if (!alive()) return 'canceled'
// 그사이 다른 곳에서 지운 그룹은 지운 것으로 본다(세지는 않는다)
if (toApiError(caught).code !== 'RES_001') return 'failed'
}
if (!alive()) return 'canceled'
undo.groupNext += 1
}
return 'done'
}
/** 되돌린다 — 차례(contact-import.store 의 enqueue) 안에서 부른다 */
async function executeUndo(undo: UndoRun): Promise<void> {
const session = authStore.generation
const alive = () => authStore.generation === session
const total = count(undo.ids.length)
const progress = toast.progress(CONTACT_IMPORT_TOAST_TEXT.undoProgress(count(undo.next), total))
const outcome = await runUndo(undo, alive, () =>
progress.update(CONTACT_IMPORT_TOAST_TEXT.undoProgress(count(undo.next), total)),
)
if (outcome === 'canceled' || !alive()) {
progress.dismiss()
return
}
contactsStore.afterBulkChange(undo.ids.slice(0, undo.next))
if (outcome === 'done') {
progress.succeed(
undo.groupsDeleted > 0
? CONTACT_IMPORT_TOAST_TEXT.undoneWithGroups(total, count(undo.groupsDeleted))
: CONTACT_IMPORT_TOAST_TEXT.undone(total),
)
return
}
const retry: ToastAction = {
label: CONTACT_IMPORT_TOAST_TEXT.retry,
run: () => void importStore.enqueue('undo', () => executeUndo(undo)),
}
progress.fail(CONTACT_IMPORT_TOAST_TEXT.undoStopped(total, count(undo.next)), retry, { sticky: true })
}
return { read, start }
}
+63 -1
View File
@@ -1,10 +1,14 @@
import type { AxiosRequestConfig } from 'axios'
import { useApi } from './useApi'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import { startBrowserDownload } from './useMail'
import { CONTACT_GROUP_MEMBER_PAGES_MAX, CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type {
Contact,
ContactDeleted,
ContactDeletions,
ContactDeletionsPayload,
ContactExportQuery,
ContactGroup,
ContactGroupCreatePayload,
ContactGroupDeleted,
@@ -12,6 +16,10 @@ import type {
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactGroupRenamePayload,
ContactImportChecked,
ContactImported,
ContactImportItem,
ContactImportPayload,
ContactInput,
ContactListQuery,
ContactPatch,
@@ -21,6 +29,15 @@ import type {
} from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
const CONTACT_EXPORTS_PATH = '/contact-exports'
/** 내보내기 파일 주소 — axios baseURL 과 같은 규칙으로 만들어야 쿠키(Path=/api)가 함께 간다 */
export function contactExportUrl(query: ContactExportQuery): string {
const params = new URLSearchParams({ format: query.format })
if (query.groupId) params.set('groupId', query.groupId)
return `${buildApiUrl(CONTACT_EXPORTS_PATH)}?${params.toString()}`
}
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type ContactsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
@@ -141,6 +158,47 @@ export function useContacts() {
return members
}
// ── 가져오기 · 내보내기 (16단계 — 계약 3절 "가져오기 · 내보내기") ─────────────
/**
* 가져올 연락처가 이미 있는가 — 만들지 않는다. 1~100명. remaining 은 더 둘 수 있는 연락처 수(3,000 − 지금 수).
* 같은 파일 안의 겹침은 화면이 본다(같은 규칙)
*/
function checkContactImport(
contacts: readonly ContactImportItem[],
options?: ContactsRequestOptions,
): Promise<ContactImportChecked> {
const payload: ContactImportPayload = { contacts: [...contacts] }
return api.post<ContactImportChecked, ContactImportChecked>('/contact-import-checks', payload, options)
}
/**
* 가져온 연락처를 만든다 — 1~100명, 엔진 요청 한 번. 같은 uid 를 다시 보내면 두 번 만들지 않는다.
* 한도(연락처 3,000 · 크기)를 넘는 묶음은 409 CONTACT_002(하나도 만들지 않음), 없는 그룹은 404 RES_001, 검사에 걸리면 400 VAL_001.
* 엔진이 일부만 받으면 받은 것만 온다
*/
function importContacts(contacts: readonly ContactImportItem[], options?: ContactsRequestOptions): Promise<ContactImported> {
const payload: ContactImportPayload = { contacts: [...contacts] }
return api.post<ContactImported, ContactImported>('/contact-imports', payload, options)
}
/** 여러 연락처를 지운다 — 1~500개(겹치지 않게). 되돌리기 표는 남기지 않는다 (가져오기의 [되돌리기]) */
function deleteContacts(ids: readonly string[], options?: ContactsRequestOptions): Promise<ContactDeletions> {
const payload: ContactDeletionsPayload = { ids: [...ids] }
return api.post<ContactDeletions, ContactDeletions>('/contact-deletions', payload, options)
}
/**
* 내 주소록(또는 그룹 하나)을 파일로 내려받는다 — 첨부처럼 이동하기 전에 세션을 확인한다 (confirmSession).
* <a href> 이동은 axios 의 401 갱신 규칙을 타지 않아, 액세스 토큰이 만료된 채 이동하면 오류 JSON 이 파일로 받아진다.
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
*/
async function downloadContactExport(query: ContactExportQuery): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(contactExportUrl(query))
return true
}
return {
listContacts,
getContact,
@@ -156,5 +214,9 @@ export function useContacts() {
listDirectoryEntries,
getDirectoryEntry,
listGroupMembers,
checkContactImport,
importContacts,
deleteContacts,
downloadContactExport,
}
}
@@ -14,6 +14,9 @@ export type ContactsDialogRequest =
| { kind: 'group-name'; group: ContactGroup | null }
/** 구성원 넣기 */
| { kind: 'members'; group: ContactGroup }
/** 연락처 가져오기 · 내보내기 (16단계 — 「전체 연락처」 머리 · 빈 주소록) */
| { kind: 'import' }
| { kind: 'export' }
export interface ContactsDialogsHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
@@ -21,6 +24,8 @@ export interface ContactsDialogsHandle {
openContact: (contact: Contact | null, prefill?: ContactPrefill | null) => void
openGroupName: (group: ContactGroup | null) => void
openMembers: (group: ContactGroup) => void
openImport: () => void
openExport: () => void
close: () => void
}
@@ -39,6 +44,12 @@ export function createContactsDialogs(): ContactsDialogsHandle {
openMembers: (group) => {
request.value = { kind: 'members', group }
},
openImport: () => {
request.value = { kind: 'import' }
},
openExport: () => {
request.value = { kind: 'export' }
},
close: () => {
request.value = null
},
+23 -29
View File
@@ -27,7 +27,6 @@ import type {
MailFilterSet,
MailFilterSetPayload,
MailQuota,
MailSignature,
MailState,
MailVacation,
MailVacationState,
@@ -48,18 +47,17 @@ import type {
MessagesPatched,
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
ScheduledMessageCancel,
ReleasedSend,
SearchIndexState,
SendMessagePayload,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
import { vacationExternalOf } from '@/shared/utils/mail-vacation'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -103,15 +101,28 @@ type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'>
*/
type ScheduledReleaseReply = { id?: unknown; rejectedRecipients?: unknown; pendingSend?: unknown } | null
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
/**
* GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가, 16단계 앞의 서버는 htmlBody · external 이 없다
* (vacationStateOf 가 맞춘다)
*/
type MailVacationReply = Omit<MailVacation, 'htmlBody' | 'external'> & {
isActive?: unknown
htmlBody?: unknown
external?: unknown
}
/**
* 부재중 응답 → 화면의 값. isActive 가 없거나 불리언이 아니면(옛 서버 · 응답을 바꾼 게이트웨이) 동작하는 것으로 본다 —
* "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState)
* "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState).
* 서식 본문 · 회사 밖 응답(16단계)이 없거나 모양이 틀리면 글만 · 사내와 같은 응답으로 — 옛 화면이 저장한 것과 같게 읽는다
*/
function vacationStateOf(reply: MailVacationReply): MailVacationState {
return { ...reply, isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true }
return {
...reply,
htmlBody: typeof reply.htmlBody === 'string' ? reply.htmlBody : null,
external: vacationExternalOf(reply.external),
isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true,
}
}
export interface UploadOptions {
@@ -217,7 +228,8 @@ export function useMail() {
/**
* 전체 교체 — 꺼도 제목 · 내용은 남긴다. 응답은 저장한 값 + isActive (저장하면 엔진에서 다시 켜진다).
* 본문은 다섯 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001)
* 본문은 일곱 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001).
* 서식 본문 · 회사 밖 응답(16단계)은 늘 보낸다 — 주지 않았으면 글만 · 사내와 같은 응답 (서버가 새 칸이 없는 옛 요청을 그렇게 저장하는 것과 같다)
*/
async function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacationState> {
const body: MailVacation = {
@@ -226,6 +238,9 @@ export function useMail() {
toDate: payload.toDate,
subject: payload.subject,
textBody: payload.textBody,
htmlBody: payload.htmlBody ?? null,
// 네 칸만 새로 만든다 (모르는 칸이 실리지 않게)
external: vacationExternalOf(payload.external),
}
return vacationStateOf(await api.put<MailVacationReply, MailVacationReply>('/mail-vacation', body, options))
}
@@ -409,24 +424,6 @@ export function useMail() {
})
}
// ── 서명 (계약 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절 "받는 사람 추천") ──────────────
/**
@@ -600,9 +597,6 @@ export function useMail() {
getDraft,
createDraft,
updateDraft,
getSignature,
saveSignature,
uploadSignatureImage,
listRecipientSuggestions,
}
}
@@ -0,0 +1,133 @@
import type { AxiosRequestConfig } from 'axios'
import { getErrorMessage, notifyApiError, useApi, type ApiError } from './useApi'
import type { SignatureImage } from '@/shared/types/mail'
import type { MailSignature, MailSignatureDefaults, MailSignaturePayload } from '@/shared/types/mail-signature'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type MailSignatureRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 경로에 넣는 값은 늘 인코딩한다 */
const segment = (value: string) => encodeURIComponent(value)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function textOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
/** 서명 하나 — id · 이름이 없으면 null (응답을 바꾼 게이트웨이 등 — 그런 줄은 쓰지 않는다) */
function signatureOf(value: unknown): MailSignature | null {
if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') return null
return {
id: value.id,
name: value.name,
bodyHtml: textOrNull(value.bodyHtml),
body: textOrNull(value.body),
images: Array.isArray(value.images) ? (value.images as SignatureImage[]) : [],
updatedAt: textOrNull(value.updatedAt),
}
}
/** 목록 응답 { items, pageInfo } 의 서명 — 모양이 틀린 줄은 뺀다 */
function signatureListOf(reply: unknown): MailSignature[] {
const items = isRecord(reply) && Array.isArray(reply.items) ? reply.items : []
return items.flatMap((item: unknown) => {
const signature = signatureOf(item)
return signature ? [signature] : []
})
}
/** 기본 둘 — 글이 아닌 값은 넣지 않음(null)으로 본다 */
function defaultsOf(reply: unknown): MailSignatureDefaults {
const value = isRecord(reply) ? reply : {}
return { newMessageId: textOrNull(value.newMessageId), replyId: textOrNull(value.replyId) }
}
/**
* 만들기 · 고치기 응답 — 모양이 틀리면 보낸 값으로 메운다. 만든 서명의 id 를 모르면 실패로 알린다(SYS_001 — 인터셉터는 성공으로 보고 알리지 않았다).
* 그 서명은 서버에 있을 수 있다 — 화면에 다시 들어오면 목록에 보인다
*/
function savedSignatureOf(reply: unknown, sent: MailSignaturePayload, id?: string): MailSignature {
const signature = signatureOf(reply)
if (signature) return signature
if (!id) {
const error: ApiError = { code: 'SYS_001', message: getErrorMessage('SYS_001') }
notifyApiError(error)
throw error
}
return { id, ...sent, images: [], updatedAt: null }
}
// 여러 서명 API Composable — docs/mail/api-contract.md 2절 "서명" (16단계, 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑한다. 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-signature.store)를 거친다 —
// 서명 그림 올리기만 서명 화면(SignatureForm)이 부른다 (올린 그림은 서명을 저장할 때 붙는다)
export function useMailSignatures() {
const api = useApi()
/** 내 서명 — 만든 차례, 10개까지 (pageInfo.hasNext 는 늘 false) */
async function listSignatures(options?: MailSignatureRequestOptions): Promise<MailSignature[]> {
return signatureListOf(await api.get<unknown, unknown>('/mail-signatures', options))
}
/**
* 새 서명 — 201. 서명이 하나도 없던 직원이면 서버가 기본 둘을 이 서명으로 정한다.
* 이름이 비었거나 30자 초과 · 같은 이름 · 11번째 · 본문 한도(서식 10,000 · 글 2,000 · 그림 3장)는 400 VAL_001
*/
async function createSignature(
payload: MailSignaturePayload,
options?: MailSignatureRequestOptions,
): Promise<MailSignature> {
return savedSignatureOf(await api.post<unknown, unknown>('/mail-signatures', payload, options), payload)
}
/** 서명 고치기 — 이름 · 본문 전체를 바꾼다. 없는 서명은 404 RES_001, 검사는 만들기와 같다 */
async function updateSignature(
id: string,
payload: MailSignaturePayload,
options?: MailSignatureRequestOptions,
): Promise<MailSignature> {
return savedSignatureOf(
await api.put<unknown, unknown>(`/mail-signatures/${segment(id)}`, payload, options),
payload,
id,
)
}
/** 서명 지우기 — 204. 이 서명을 기본으로 넣던 곳은 넣지 않음이 되고, 이 서명에만 쓰던 그림은 서버가 지운다 */
async function deleteSignature(id: string, options?: MailSignatureRequestOptions): Promise<void> {
await api.delete(`/mail-signatures/${segment(id)}`, options)
}
/** 새 메일 · 답장과 전달에 넣을 서명 */
async function getDefaults(options?: MailSignatureRequestOptions): Promise<MailSignatureDefaults> {
return defaultsOf(await api.get<unknown, unknown>('/mail-signature-defaults', options))
}
/** 기본 둘을 바꾼다 — 둘 다 보낸다. 없는 서명 id 는 404 RES_001 */
async function saveDefaults(
defaults: MailSignatureDefaults,
options?: MailSignatureRequestOptions,
): Promise<MailSignatureDefaults> {
return defaultsOf(await api.put<unknown, unknown>('/mail-signature-defaults', defaults, options))
}
/** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하. 한 사람이 보관하는 그림은 20장까지(넘으면 400 VAL_001) */
function uploadSignatureImage(file: File, options?: MailSignatureRequestOptions): Promise<SignatureImage> {
const form = new FormData()
form.append('file', file)
return api.post<SignatureImage, SignatureImage>('/mail-signature/images', form, options)
}
return {
listSignatures,
createSignature,
updateSignature,
deleteSignature,
getDefaults,
saveDefaults,
uploadSignatureImage,
}
}
+18 -6
View File
@@ -9,9 +9,9 @@ export interface ProgressToast {
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
update: (message: string) => void
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [거래처 보기] */
succeed: (message: string, action?: ToastAction) => void
succeed: (message: string, action?: ToastAction, options?: ToastResultOptions) => void
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [다시 시도] */
fail: (message: string, action?: ToastAction) => void
fail: (message: string, action?: ToastAction, options?: ToastResultOptions) => void
/** 알리지 않고 닫는다 */
dismiss: () => void
}
@@ -19,6 +19,13 @@ export interface ProgressToast {
/** 알림 옵션 — expiresAt(ms — Date.now)에 닫는다. 붙잡아도 멈추지 않고 남은 시간 막대가 보인다 (9단계 [보내기 취소]) */
export interface ToastOptions {
expiresAt?: number
/** ✕로 닫을 때까지 남긴다 (16단계 주소록 가져오기의 결과 — toast.store 머리말) */
sticky?: boolean
}
/** 진행 알림의 결과 옵션 — ✕로 닫을 때까지 남긴다 */
export interface ToastResultOptions {
sticky?: boolean
}
/**
@@ -49,6 +56,7 @@ export function useToast() {
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
sticky: options.sticky === true,
})
}
@@ -63,6 +71,7 @@ export function useToast() {
message,
action: action ? sessionBound(action) : undefined,
expiresAt: options.expiresAt ?? null,
sticky: options.sticky === true,
})
}
@@ -72,18 +81,21 @@ export function useToast() {
let id = store.show({ kind: 'progress', message })
const sameSession = () => authStore.generation === session
function settle(kind: TimedToastKind, text: string, action?: ToastAction) {
function settle(kind: TimedToastKind, text: string, action?: ToastAction, options: ToastResultOptions = {}) {
if (!sameSession()) return
const bound = action ? sessionBound(action) : null
if (!store.update(id, { kind, message: text, action: bound })) id = store.show({ kind, message: text, action: bound })
const sticky = options.sticky === true
if (!store.update(id, { kind, message: text, action: bound, sticky })) {
id = store.show({ kind, message: text, action: bound, sticky })
}
}
return {
update: (text) => {
if (sameSession()) store.update(id, { message: text })
},
succeed: (text, action) => settle('success', text, action),
fail: (text, action) => settle('error', text, action),
succeed: (text, action, options) => settle('success', text, action, options),
fail: (text, action, options) => settle('error', text, action, options),
dismiss: () => store.dismiss(id),
}
}
@@ -0,0 +1,780 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import ToastHost from '@/components/feedback/ToastHost.vue'
import { CLOCK } from '@/composables/useClock'
import { CONTACT_IMPORT_ERROR_TEXT, CONTACT_IMPORT_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
import type { Contact, ContactGroup, ContactImportItem } from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
import { formatDate } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useContactImportStore } from '@/stores/contact-import.store'
import { useToastStore } from '@/stores/toast.store'
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
import { createManualClock } from '@/test-utils/manual-clock'
import { buildXlsx } from '@/test-utils/zip'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import ContactsLayout from './ContactsLayout.vue'
import ContactsPage from './ContactsPage.vue'
// 주소록 가져오기 · 내보내기 (16단계 — 디자인 원본 Contacts16 · 계약 3절 "가져오기 · 내보내기")
// 서버는 흉내다 — 확인(이메일이 주소록에 있으면 exists) · 넣기(uid 마다 하나, 같은 uid 는 다시 만들지 않는다) · 여러 명 지우기 · 그룹
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const Empty: FunctionalComponent = () => h('div')
Empty.displayName = 'Empty'
/** 손 시계의 지금 — 2026-11-10 (이 PC 의 날짜 — 내보낼 파일 이름) */
const NOW = new Date(2026, 10, 10, 9, 30).getTime()
function contact(id: string, name: string, email: string | null, patch: Partial<Contact> = {}): Contact {
return {
id,
name,
emails: email ? [{ address: email, label: 'work' }] : [],
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: [],
...patch,
}
}
interface ServerState {
contacts: Contact[]
groups: ContactGroup[]
/** GET /contacts?limit=1 의 total — 한도 시험 */
total?: number
/** 확인이 주는 남은 자리 — 주지 않으면 3,000 − 연락처 수 */
remaining?: number
/** 넣기 요청마다 — 주면 그 결과를 쓴다(undefined 면 보통대로) */
importReply?: (request: MockRequest, index: number) => MockResult | undefined
/** 그룹을 만든 뒤 — 주면 그 결과를 쓴다(만들고 응답만 잃은 경우) */
groupCreateReply?: (created: ContactGroup) => MockResult | undefined
}
function pageOf<T>(items: T[], total: number = items.length): PaginatedResult<T> {
return { items, pageInfo: { hasNext: false, nextCursor: null, total } }
}
function groupsOf(state: ServerState): ContactGroup[] {
return state.groups.map((group) => ({
...group,
memberCount: state.contacts.filter((item) => item.groupIds.includes(group.id)).length,
}))
}
function serve(state: ServerState, request: MockRequest, imports: { count: number }, uids: Map<string, string>): MockResult {
const { method, url, params } = request
const id = decodeURIComponent(url.split('/')[2] ?? '')
if (url === '/auth/me') return ok({ user: USER, restriction: null, twoFactorDeadline: null })
if (url === '/contact-groups' && method === 'GET') return ok(pageOf(groupsOf(state)))
if (url === '/contact-groups' && method === 'POST') {
const body = request.data as { name: string }
if (state.groups.some((group) => group.name.toLowerCase() === body.name.toLowerCase())) return fail(409, 'CONTACT_001')
const created: ContactGroup = { id: `ng${state.groups.length + 1}`, name: body.name, memberCount: 0 }
state.groups.push(created)
return state.groupCreateReply?.(created) ?? ok(created, 201)
}
if (url.startsWith('/contact-groups/') && method === 'DELETE') {
const group = state.groups.find((item) => item.id === id)
if (!group) return fail(404, 'RES_001')
state.groups = state.groups.filter((item) => item.id !== id)
return ok({ id, name: group.name, memberIds: [], restoreToken: null })
}
if (url === '/contact-import-checks') {
const items = (request.data as { contacts: ContactImportItem[] }).contacts
const known = new Set(state.contacts.flatMap((item) => item.emails.map((email) => email.address.toLowerCase())))
return ok({
items: items.map((item) => ({
uid: item.uid,
status: item.emails.some((email) => known.has(email.address.toLowerCase())) ? 'exists' : 'new',
})),
remaining: state.remaining ?? 3000 - state.contacts.length,
})
}
if (url === '/contact-imports') {
imports.count += 1
const custom = state.importReply?.(request, imports.count)
if (custom) return custom
const items = (request.data as { contacts: ContactImportItem[] }).contacts
const created = items.map((item) => {
const existing = uids.get(item.uid)
if (existing) return { uid: item.uid, id: existing, skippedGroupIds: [] }
const newId = `c${state.contacts.length + 100}`
uids.set(item.uid, newId)
state.contacts.push({
id: newId,
name: item.name,
emails: item.emails,
phones: item.phones,
company: item.company,
department: item.department,
title: item.title,
note: item.note,
groupIds: item.groupIds,
})
return { uid: item.uid, id: newId, skippedGroupIds: [] }
})
return ok({ items: created }, 201)
}
if (url === '/contact-deletions') {
const ids = (request.data as { ids: string[] }).ids
const deletedIds = ids.filter((item) => state.contacts.some((existing) => existing.id === item))
state.contacts = state.contacts.filter((item) => !ids.includes(item.id))
return ok({ deletedIds, notFoundIds: ids.filter((item) => !deletedIds.includes(item)) })
}
if (url === '/contacts' && method === 'GET') {
if (params.limit === 1 && params.groupId === undefined && params.q === undefined) {
return ok(pageOf([], state.total ?? state.contacts.length))
}
const items = state.contacts.filter((item) => params.groupId === undefined || item.groupIds.includes(String(params.groupId)))
return ok(pageOf(items, state.total ?? items.length))
}
if (url === '/directory-entries') return ok(pageOf([]))
return ok(null)
}
function testRouter(): Router {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Empty },
{ path: '/login', name: 'login', component: Empty },
{ path: '/mail', name: 'mail', component: Empty },
{ path: '/mail/compose', name: 'mail-compose', component: Empty },
{ path: '/settings', name: 'settings', component: Empty },
{
path: '/contacts',
component: ContactsLayout,
children: [
{ path: '', name: 'contacts', component: ContactsPage },
{ path: 'all/:contactId', name: 'contacts-contact', component: ContactsPage },
{ path: 'groups/:groupId', name: 'contacts-group', component: ContactsPage },
{ path: 'groups/:groupId/:contactId', name: 'contacts-group-contact', component: ContactsPage },
{ path: 'directory/:kind(people|shared)', name: 'contacts-directory', component: ContactsPage },
{ path: 'directory/:kind(people|shared)/:entryId', name: 'contacts-directory-entry', component: ContactsPage },
],
},
],
})
}
/** 네이버 꼴 CSV — 값마다 앞에 ' , UTF-8 BOM */
function naverCsv(rows: string[][]): File {
const header = ['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명', '직책', '회사주소', '메모']
const lines = [header, ...rows].map((cells, index) => (index === 0 ? cells : cells.map((cell) => `'${cell}`)).join(','))
return new File([`\uFEFF${lines.join('\n')}\n`], 'NAVER_Contacts.csv', { type: 'text/csv' })
}
describe('주소록 가져오기 · 내보내기 (16단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
let state: ServerState
let imports: { count: number }
let uids: Map<string, string>
let downloads: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
imports = { count: 0 }
uids = new Map()
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
state = {
contacts: [
contact('c1', '박지훈', 'jihoon.park@example.com', { groupIds: ['g1'] }),
contact('c2', '오세훈', 'sehun.oh@example.net'),
],
groups: [
{ id: 'g1', name: '거래처', memberCount: 0 },
{ id: 'g2', name: '세무', memberCount: 0 },
{ id: 'g3', name: '협력사 담당', memberCount: 0 },
],
}
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountApp(path = '/contacts') {
requests = installMockApi((request) => serve(state, request, imports, uids))
const router = testRouter()
await router.push(path)
await router.isReady()
const clock = createManualClock(NOW)
const app = mount(
{ render: () => [h(RouterView), h(ToastHost)] },
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
)
mounted.push(app)
await flushPromises()
return { app, router }
}
/** 조건이 될 때까지 기다린다 — 파일 읽기 · 압축 풀기 · 요청이 여러 번 양보한다 */
async function until(condition: () => boolean, tries = 60) {
for (let index = 0; index < tries && !condition(); index += 1) await flushPromises()
if (!condition()) throw new Error('기다린 조건이 되지 않았다')
}
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
;(target.element as HTMLElement).focus()
await target.trigger('click')
await flushPromises()
}
async function chooseFile(app: VueWrapper, file: File) {
const input = app.get('[data-import-file]')
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
}
const toasts = () => useToastStore().toasts
const toastMessages = () => toasts().map((toast) => toast.message)
const isOpen = (app: VueWrapper, selector: string) => app.find(`${selector}[open]`).exists()
async function openImport(app: VueWrapper) {
await click(app.get('[data-contacts-import]'))
expect(isOpen(app, '[data-contact-import]')).toBe(true)
}
async function readPreview(app: VueWrapper, file: File) {
await chooseFile(app, file)
await until(() => app.find('[data-import-found]').exists())
}
describe('「전체 연락처」 머리 · 빈 주소록', () => {
it('전체 연락처 머리에만 [가져오기] · [내보내기] — 그룹 머리는 지금 그대로', async () => {
const { app, router } = await mountApp()
expect(app.get('[data-contacts-import]').text()).toBe('가져오기')
expect(app.get('[data-contacts-export]').text()).toBe('내보내기')
expect(app.get('[data-contacts-export]').attributes('aria-disabled')).toBeUndefined()
await router.push('/contacts/groups/g1')
await flushPromises()
expect(app.find('[data-contacts-import]').exists()).toBe(false)
expect(app.find('[data-contacts-add-members]').exists()).toBe(true)
})
it('연락처가 하나도 없으면 [내보내기]를 끄고 까닭, 빈 칸 안내에 테두리 [가져오기]와 안내 한 줄', async () => {
state.contacts = []
const { app } = await mountApp()
const exportButton = app.get('[data-contacts-export]')
expect(exportButton.attributes('aria-disabled')).toBe('true')
expect(exportButton.attributes('title')).toBe('내보낼 연락처가 없습니다.')
expect(app.get(`#${exportButton.attributes('aria-describedby')}`).text()).toBe('내보낼 연락처가 없습니다.')
await click(exportButton)
expect(isOpen(app, '[data-contact-export]')).toBe(false)
const empty = app.get('[data-contacts-empty="first"]')
expect(empty.get('[data-contacts-first-import]').text()).toBe('가져오기')
expect(empty.findAll('.contacts-list__first-note').map((note) => note.text())).toEqual([
'다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다.',
'메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.',
])
await click(empty.get('[data-contacts-first-import]'))
expect(isOpen(app, '[data-contact-import]')).toBe(true)
})
it('연락처가 3,000개면 [가져오기]도 끄고 패널의 3,000개 안내를 까닭으로, 가져오는 동안은 바쁜 표시', async () => {
state.total = 3000
const { app } = await mountApp()
const importButton = app.get('[data-contacts-import]')
expect(importButton.attributes('aria-disabled')).toBe('true')
expect(importButton.attributes('aria-describedby')).toBe('contacts-full-note')
expect(importButton.attributes('title')).toBe(CONTACTS_PANEL_TEXT.fullNote)
await click(importButton)
expect(isOpen(app, '[data-contact-import]')).toBe(false)
state.total = 10
useContactImportStore().busy = 'import'
const busy = await mountApp()
expect(busy.app.get('[data-contacts-import]').attributes('aria-busy')).toBe('true')
await click(busy.app.get('[data-contacts-import]'))
expect(isOpen(busy.app, '[data-contact-import]')).toBe(false)
})
})
describe('가져오기 창 ① 파일 고르기', () => {
it('처음 — 글 · [파일 고르기](처음 초점) · 형식 안내 · [취소]만', async () => {
const { app } = await mountApp()
await openImport(app)
const dialog = app.get('[data-contact-import]')
expect(dialog.get('#contact-import-title').text()).toBe('연락처 가져오기')
expect(dialog.get('#contact-import-message').text()).toBe(
'네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다.',
)
expect(document.activeElement).toBe(dialog.get('[data-import-pick]').element)
expect(dialog.get('.import-picker__formats').text()).toBe(
'vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다.',
)
expect(dialog.get('[data-import-file]').attributes('accept')).toBe('.vcf,.csv,.xlsx')
expect(dialog.find('[data-import-submit]').exists()).toBe(false)
})
it('읽는 동안의 상태 영역은 늘 있고 글만 바뀐다 — 영역이 글과 함께 생기면 화면 낭독기가 읽지 않기도 한다', async () => {
const { app } = await mountApp()
await openImport(app)
const status = app.get('[data-import-status]')
expect(status.attributes('role')).toBe('status')
expect(status.text()).toBe('')
await chooseFile(app, naverCsv([['강', '민지', '', 'minji.kang@example.com', '', '', '', '', '', '']]))
expect(app.get('[data-import-status]').element).toBe(status.element)
expect(status.text()).toBe(CONTACT_IMPORT_TEXT.reading)
expect(app.get('.import-picker__reading').attributes('aria-hidden')).toBe('true')
await until(() => app.find('[data-import-found]').exists())
})
it('읽다가 뜻밖에 멈추면 "읽는 중"에 머물지 않고 열지 못한 파일로 칸 아래에 알린다', async () => {
const { app } = await mountApp()
await openImport(app)
const file = new File(['이름,이메일'], '연락처.csv')
Object.defineProperty(file, 'size', {
get: () => {
throw new Error('읽기 실패')
},
})
await chooseFile(app, file)
await until(() => app.find('[data-import-error]').exists())
expect(app.get('[data-import-error]').text()).toBe(CONTACT_IMPORT_ERROR_TEXT.unreadable)
expect(app.find('[data-import-pick]').exists()).toBe(true)
})
it('끌어 오는 동안 놓는 자리가 바뀌고, 여러 파일을 놓으면 칸 아래 글(알림 없음)', async () => {
const { app } = await mountApp()
await openImport(app)
const zone = app.get('[data-import-zone]')
const transfer = fakeDataTransfer([new File(['a'], 'a.csv'), new File(['b'], 'b.csv')])
zone.element.dispatchEvent(dragEvent('dragenter', transfer))
await flushPromises()
expect(zone.classes()).toContain('is-dragging')
expect(zone.text()).toBe('놓으면 이 파일에서 연락처를 찾습니다')
const drop = dragEvent('drop', transfer)
zone.element.dispatchEvent(drop)
await flushPromises()
expect(drop.defaultPrevented).toBe(true)
expect(app.get('[data-import-error]').text()).toBe('파일은 한 번에 하나만 넣을 수 있습니다.')
expect(app.get('[data-import-error]').attributes('role')).toBe('alert')
expect(zone.classes()).not.toContain('is-dragging')
expect(toasts()).toEqual([])
})
it.each([
['list.xls', 'xls 내용', '옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요.'],
['photo.png', '\u0000PNG', '넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요.'],
['empty.csv', '날짜,금액\n2026-11-10,3000\n', '이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요.'],
['empty.vcf', 'VERSION:3.0\n', '이 파일에서 연락처를 찾지 못했습니다.'],
])('읽지 못하면 칸 아래 빨간 글 — %s', async (name, content, message) => {
const { app } = await mountApp()
await openImport(app)
await chooseFile(app, new File([content], name))
await until(() => app.find('[data-import-error]').exists())
expect(app.get('[data-import-error]').text()).toBe(message)
expect(document.activeElement).toBe(app.get('[data-import-pick]').element)
expect(requests.some((request) => request.url === '/contact-import-checks')).toBe(false)
})
})
describe('가져오기 창 ② 미리 보기 · 넣기 · 되돌리기', () => {
const rows = [
['강', '민지', '01041203387', 'minji.kang@example.com', '거래처', '한빛상사', '구매팀', '과장', '서울 중구 세종대로 110', '견적 문의는 월말 전에 보내기.'],
['박', '지훈', '', 'jihoon.park@example.com', '', '한빛상사', '영업팀', '', '', ''],
['김', '도현', '01055210934', 'dohyun.kim@example.net', '거래처', '그린클라우드', '', '', '', ''],
['', '', '01090346621', '', '', '', '', '', '', ''],
['문', '가은', '', 'gaeun.moon@example.net', '동호회', '', '', '', '', ''],
['강', '민지', '', 'MINJI.KANG@example.com', '', '', '', '', '', ''],
]
it('세 묶음 · 그룹 글 · 메모 견본 — 처음 초점은 [연락처 N명 넣기]', async () => {
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv(rows))
const dialog = app.get('[data-contact-import]')
expect(dialog.get('[data-import-file-name]').text()).toBe('NAVER_Contacts.csv')
expect(dialog.get('[data-import-found]').text()).toBe('연락처 6명을 찾았습니다.')
expect(dialog.get('[data-import-insert-count]').text()).toBe('3명')
expect(dialog.findAll('[data-import-row]').map((row) => row.findAll('[role="cell"]').map((cell) => cell.text()))).toEqual([
['강민지', 'minji.kang@example.com', '010-4120-3387', '한빛상사', '거래처'],
['김도현', 'dohyun.kim@example.net', '010-5521-0934', '그린클라우드', '거래처'],
['문가은', 'gaeun.moon@example.net', '', '', '동호회'],
])
expect(document.activeElement).toBe(dialog.get('[data-import-submit]').element)
expect(dialog.get('[data-import-submit]').text()).toBe('연락처 3명 넣기')
const toggle = dialog.get('[data-import-duplicates-toggle]')
expect(toggle.text()).toContain('이미 있는 연락처는 건너뜁니다')
expect(toggle.text()).toContain('2명')
expect(toggle.attributes('aria-expanded')).toBe('false')
await click(toggle)
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(
dialog.findAll('[data-import-duplicates] li').map((item) => item.findAll('span').map((span) => span.text())),
).toEqual([
['3번째 줄', '박지훈jihoon.park@example.com', '박지훈', '주소록에 있음'],
['7번째 줄', '강민지MINJI.KANG@example.com', '강민지', '2번째 줄과 같음'],
])
await click(dialog.get('[data-import-problems-toggle]'))
expect(dialog.get('[data-import-problems-toggle]').text()).toContain('넣지 못하는 줄')
expect(dialog.get('[data-import-problems] li').text()).toContain('5번째 줄')
expect(dialog.get('[data-import-problems] li').text()).toContain('010-9034-6621')
expect(dialog.get('[data-import-problems] li').text()).toContain('이름과 이메일이 모두 없습니다.')
expect(dialog.get('[data-import-groups-text]').text()).toBe(
'파일에 적힌 그룹 2개 가운데 거래처는 이미 있는 그룹에 넣고 동호회는 새로 만듭니다.',
)
expect(dialog.get('[data-import-info]').element).toHaveProperty('checked', true)
expect(dialog.get('[data-import-info-sample]').text()).toContain('예: 강민지의 메모')
expect(dialog.get('[data-import-info-sample]').text()).toContain('회사 주소: 서울 중구 세종대로 110')
expect(requests.filter((request) => request.url === '/contact-import-checks')).toHaveLength(1)
})
it('[연락처 N명 넣기] — 새 그룹을 먼저 만들고 100명씩 넣는다, 결과는 ✕까지 남고 [되돌리기]가 넣은 연락처 · 새 그룹을 지운다', async () => {
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv(rows))
const dialog = app.get('[data-contact-import]')
await dialog.get('[data-import-group-mode="new"]').setValue(true)
await click(dialog.get('[data-import-submit]'))
expect(dialog.get('[data-import-group-name]').attributes('aria-invalid')).toBe('true')
expect(dialog.get('.import-groups .form-message--error').text()).toBe('그룹 이름을 넣어 주세요.')
expect(document.activeElement).toBe(dialog.get('[data-import-group-name]').element)
expect(isOpen(app, '[data-contact-import]')).toBe(true)
await dialog.get('[data-import-group-name]').setValue('네이버 주소록')
await click(dialog.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
expect(isOpen(app, '[data-contact-import]')).toBe(false)
const groupCreates = requests.filter((request) => request.url === '/contact-groups' && request.method === 'POST')
expect(groupCreates.map((request) => (request.data as { name: string }).name)).toEqual(['네이버 주소록', '동호회'])
const importRequests = requests.filter((request) => request.url === '/contact-imports')
expect(importRequests).toHaveLength(1)
expect(requests.indexOf(importRequests[0]!)).toBeGreaterThan(requests.indexOf(groupCreates[1]!))
const sent = (importRequests[0]?.data as { contacts: ContactImportItem[] }).contacts
expect(sent.map((item) => [item.name, item.groupIds])).toEqual([
['강민지', ['g1', 'ng4']],
['김도현', ['g1', 'ng4']],
['문가은', ['ng5', 'ng4']],
])
expect(sent[0]?.uid).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
expect(sent[0]?.note).toBe('견적 문의는 월말 전에 보내기.\n\n가져온 정보\n회사 주소: 서울 중구 세종대로 110')
expect(sent[0]?.phones).toEqual([{ number: '010-4120-3387', label: 'mobile' }])
const [result] = toasts()
expect(result?.message).toBe('연락처 3명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다.')
expect(result?.sticky).toBe(true)
expect(app.get('[data-toast-close]').attributes('aria-label')).toBe('알림 닫기')
// 끝나면 연락처 수 · 그룹 · 보는 목록을 다시 받는다
await until(() => app.get('[data-contacts-count]').text() === '5명')
await click(app.get('.toast__action'))
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 3명')))
expect(toastMessages()).toEqual(['가져온 연락처 3명과 새로 만든 그룹 2개를 지웠습니다.'])
const deletions = requests.filter((request) => request.url === '/contact-deletions')
expect(deletions.map((request) => (request.data as { ids: string[] }).ids)).toEqual([['c102', 'c103', 'c104']])
expect(
requests.filter((request) => request.method === 'DELETE').map((request) => request.url),
).toEqual(['/contact-groups/ng4', '/contact-groups/ng5'])
expect(state.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
})
it('주소록이 차면 남은 자리만큼 — 한도 안내 · 단추의 수, 결과에 넣지 못한 수', async () => {
state.remaining = 1
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv(rows))
const dialog = app.get('[data-contact-import]')
expect(dialog.get('[data-import-limit]').text()).toBe(
'연락처는 3,000개까지 둘 수 있어 앞에서부터 1명만 넣습니다. 나머지 2명은 넣지 않습니다.',
)
expect(dialog.get('[data-import-submit]').text()).toBe('연락처 1명 넣기')
await click(dialog.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
expect(toastMessages()).toEqual([
'연락처 1명을 넣었습니다. 이미 있던 2명은 건너뛰었습니다. 연락처는 3,000개까지 둘 수 있어 나머지 2명은 넣지 못했습니다.',
])
})
it('남은 자리가 하나도 없으면(그사이 주소록이 참) 한도 안내는 패널의 3,000개 안내 · [연락처 넣기]는 꺼진다', async () => {
state.remaining = 0
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv(rows))
expect(app.get('[data-import-limit]').text()).toBe(CONTACTS_PANEL_TEXT.fullNote)
expect(app.get('[data-import-submit]').attributes('aria-disabled')).toBe('true')
expect(app.find('[data-import-insert-count]').exists()).toBe(false)
})
it('넣을 연락처가 없으면 [연락처 넣기]를 끄고(aria-disabled) 까닭을 단추 옆에', async () => {
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv([rows[1]!]))
const submit = app.get('[data-import-submit]')
expect(submit.text()).toBe('연락처 넣기')
expect(submit.attributes('aria-disabled')).toBe('true')
expect(app.get(`#${submit.attributes('aria-describedby')}`).text()).toBe('넣을 연락처가 없습니다.')
await click(submit)
expect(isOpen(app, '[data-contact-import]')).toBe(true)
expect(requests.some((request) => request.url === '/contact-imports')).toBe(false)
})
it('도중에 멈추면 오류 알림 + [다시 시도](✕까지 남는다) — 남은 연락처부터 같은 uid 로 이어 넣는다', async () => {
const many = Array.from({ length: 150 }, (_, index) => ['이', `사람${index}`, '', `p${index}@example.org`, '', '', '', '', '', ''])
state.importReply = (_request, index) => (index === 2 ? 'network' : undefined)
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv(many))
await click(app.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.kind === 'error' && toast.action !== null))
const stopped = toasts().find((toast) => toast.action !== null)
expect(stopped?.message).toBe('연락처 150명 가운데 100명을 넣고 멈췄습니다. 다시 시도해 주세요.')
expect(stopped?.sticky).toBe(true)
expect(stopped?.action?.label).toBe('다시 시도')
const firstBatches = requests.filter((request) => request.url === '/contact-imports')
const secondUids = (firstBatches[1]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)
expect(secondUids).toHaveLength(50)
state.importReply = undefined
const retry = app.findAll('.toast').find((toast) => toast.text().includes('멈췄습니다'))
await click(retry!.get('.toast__action'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
const batches = requests.filter((request) => request.url === '/contact-imports')
expect(batches).toHaveLength(3)
expect((batches[2]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.uid)).toEqual(secondUids)
expect(toastMessages()).toContain('연락처 150명을 넣었습니다.')
expect(state.contacts).toHaveLength(152)
})
it('칸을 한도까지 채운 연락처 100명 — 확인 · 넣기 묶음이 값 8,000개에서 나뉜다 (서버 본문 한도 값 10,000 · 1MB 를 넘지 않게)', async () => {
/** 서버(json-shape.ts)와 같은 셈 — 맨 바깥 1 + 객체 · 배열마다 항목 수 */
const serverValueCount = (body: unknown): number => {
let values = 1
const pending: unknown[] = [body]
while (pending.length > 0) {
const next = pending.pop()
if (typeof next !== 'object' || next === null) continue
const children = Array.isArray(next) ? next : Object.values(next)
values += children.length
pending.push(...children)
}
return values
}
const lines = ['이름,이메일,휴대폰']
for (let index = 0; index < 100; index += 1) {
const emails = Array.from({ length: 20 }, (_, k) => `p${index}.${k}@example.org`).join('; ')
const phones = Array.from({ length: 20 }, (_, k) => `010-${1000 + index}-${2000 + k}`).join('; ')
lines.push(`사람${index},"${emails}","${phones}"`)
}
const { app } = await mountApp()
await openImport(app)
await readPreview(app, new File([lines.join('\n')], '큰 연락처.csv'))
expect(app.get('[data-import-submit]').text()).toBe('연락처 100명 넣기')
await click(app.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
for (const url of ['/contact-import-checks', '/contact-imports']) {
const sent = requests.filter((request) => request.url === url)
expect(sent.map((request) => (request.data as { contacts: unknown[] }).contacts.length)).toEqual([61, 39])
for (const request of sent) {
expect(serverValueCount(request.data)).toBeLessThanOrEqual(8000)
expect(new TextEncoder().encode(JSON.stringify(request.data)).length).toBeLessThanOrEqual(700 * 1024)
}
}
expect(toastMessages()).toContain('연락처 100명을 넣었습니다.')
expect(state.contacts).toHaveLength(102)
})
it('새 그룹 만들기의 응답만 잃은 뒤 [다시 시도] — 같은 이름(CONTACT_001)이 돌아오면 이번에 만든 그룹으로 보고 [되돌리기]가 지운다', async () => {
state.groupCreateReply = () => 'network'
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv([rows[4]!]))
await click(app.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.action !== null))
expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
// 서버에는 만들어졌다
expect(state.groups.map((group) => group.name)).toContain('동호회')
state.groupCreateReply = undefined
const retry = app.findAll('.toast').find((toast) => toast.text().includes('넣지 못했습니다'))
await click(retry!.get('.toast__action'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
const sent = requests.filter((request) => request.url === '/contact-imports')
expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['ng4']])
const result = app.findAll('.toast').find((toast) => toast.text().includes('넣었습니다'))
await click(result!.get('.toast__action'))
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
expect(toastMessages()).toContain('가져온 연락처 1명과 새로 만든 그룹 1개를 지웠습니다.')
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/contact-groups/ng4',
])
expect(state.groups.map((group) => group.name)).toEqual(['거래처', '세무', '협력사 담당'])
})
it('그사이 다른 곳에서 같은 이름의 그룹을 만들었으면(CONTACT_001) 그 그룹에 넣고 [되돌리기]는 그 그룹을 지우지 않는다', async () => {
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv([rows[4]!]))
state.groups.push({ id: 'other', name: '동호회', memberCount: 0 })
await click(app.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.kind === 'success'))
const sent = requests.filter((request) => request.url === '/contact-imports')
expect((sent[0]?.data as { contacts: ContactImportItem[] }).contacts.map((item) => item.groupIds)).toEqual([['other']])
await click(app.get('.toast__action'))
await until(() => toastMessages().some((message) => message.startsWith('가져온 연락처 1명')))
expect(toastMessages()).toEqual(['가져온 연락처 1명을 지웠습니다.'])
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
expect(state.groups.map((group) => group.id)).toContain('other')
})
it('처음부터 넣지 못하면 "연락처를 넣지 못했습니다." + [다시 시도]', async () => {
state.importReply = () => fail(503, 'MAIL_001')
const { app } = await mountApp()
await openImport(app)
await readPreview(app, naverCsv([rows[0]!]))
await click(app.get('[data-import-submit]'))
await until(() => toasts().some((toast) => toast.action !== null))
expect(toasts().find((toast) => toast.action !== null)?.message).toBe('연락처를 넣지 못했습니다. 다시 시도해 주세요.')
})
it('엑셀(.xlsx — 네이버 내보내기처럼 칸 안 문자열)도 같은 미리 보기', async () => {
const bytes = await buildXlsx([
['성', '이름', '휴대폰번호', '이메일', '그룹명', '회사·소속명', '부서명'],
['남궁', '서연', '01077142093', 'seoyeon.namgung@example.com', '거래처', '서린디자인', ''],
['류', '지환', '', 'jihwan.ryu@example.com', '세무', '바른회계법인', ''],
])
const { app } = await mountApp()
await openImport(app)
await readPreview(app, new File([bytes.slice()], 'NAVER_Contacts.xlsx'))
expect(app.get('[data-import-found]').text()).toBe('연락처 2명을 찾았습니다.')
expect(app.findAll('[data-import-row]').map((row) => row.get('[role="cell"]').text())).toEqual(['남궁서연', '류지환'])
expect(app.get('[data-import-groups-text]').text()).toBe('파일에 적힌 그룹 거래처, 세무는 이미 있는 그룹에 넣습니다.')
// 칸 없는 정보가 없으면 메모 체크 칸을 두지 않는다
expect(app.find('[data-import-info]').exists()).toBe(false)
})
it('vCard — 줄 대신 "N번째 연락처", 묶음 이름은 "넣지 못하는 연락처"', async () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:신예린',
'EMAIL;type=INTERNET;type=HOME:yerin.shin@example.com',
'END:VCARD',
'BEGIN:VCARD',
'VERSION:3.0',
'TEL;type=CELL:010-3192-5540',
'END:VCARD',
].join('\r\n')
const { app } = await mountApp()
await openImport(app)
await readPreview(app, new File([vcf], '연락처.vcf'))
const toggle = app.get('[data-import-problems-toggle]')
expect(toggle.text()).toContain('넣지 못하는 연락처')
expect(toggle.text()).toContain('1명')
await click(toggle)
expect(app.get('[data-import-problems] li').text()).toContain('2번째 연락처')
})
it('[취소] · Esc 는 묻지 않고 닫고, 읽는 중이면 읽기를 멈춘다', async () => {
const { app } = await mountApp()
await openImport(app)
await chooseFile(app, naverCsv(rows))
await click(app.get('[data-import-cancel]'))
expect(isOpen(app, '[data-contact-import]')).toBe(false)
await flushPromises()
await flushPromises()
await openImport(app)
expect(app.find('[data-import-found]').exists()).toBe(false)
expect(app.find('[data-import-pick]').exists()).toBe(true)
})
})
describe('내보내기 창', () => {
it('처음 — 내 주소록 전체 · vCard, 파일 이름은 주소록-날짜. [내보내기]는 세션을 확인한 뒤 바로 내려받는다', async () => {
const { app } = await mountApp()
await click(app.get('[data-contacts-export]'))
const dialog = app.get('[data-contact-export]')
expect(isOpen(app, '[data-contact-export]')).toBe(true)
expect(dialog.get('#contact-export-title').text()).toBe('연락처 내보내기')
expect(dialog.get('[data-export-scope="all"]').element).toHaveProperty('checked', true)
expect(dialog.get('[data-export-format="vcard"]').element).toHaveProperty('checked', true)
expect(dialog.text()).toContain('연락처 2명')
expect(dialog.text()).toContain('내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다.')
expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-${formatDate(new Date(NOW))}.vcf`)
await click(dialog.get('[data-export-submit]'))
expect(isOpen(app, '[data-contact-export]')).toBe(false)
await until(() => downloads.length > 0)
expect(requests.some((request) => request.url === '/auth/me')).toBe(true)
expect(downloads).toEqual(['/api/contact-exports?format=vcard'])
})
it('그룹 하나를 CSV 로 — 첫 그룹이 골라지고 파일 이름에 그룹 이름', async () => {
const { app } = await mountApp()
await click(app.get('[data-contacts-export]'))
const dialog = app.get('[data-contact-export]')
await dialog.get('[data-export-scope="group"]').setValue(true)
await dialog.get('[data-export-format="csv"]').setValue(true)
expect((dialog.get('[data-export-group]').element as HTMLSelectElement).value).toBe('g1')
expect(dialog.get('[data-export-group]').findAll('option').map((option) => option.text())).toEqual([
'거래처 · 1명',
'세무 · 0명',
'협력사 담당 · 0명',
])
expect(dialog.get('[data-export-file-name]').text()).toBe(`주소록-거래처-${formatDate(new Date(NOW))}.csv`)
await click(dialog.get('[data-export-submit]'))
await until(() => downloads.length > 0)
expect(downloads).toEqual(['/api/contact-exports?format=csv&groupId=g1'])
})
it('구성원이 없는 그룹이면 [내보내기]를 끄고 까닭을 그룹 칸 아래에 — 다른 그룹을 고르면 돌아온다', async () => {
const { app } = await mountApp()
await click(app.get('[data-contacts-export]'))
const dialog = app.get('[data-contact-export]')
await dialog.get('[data-export-scope="group"]').setValue(true)
await dialog.get('[data-export-group]').setValue('g3')
const submit = dialog.get('[data-export-submit]')
expect(submit.attributes('aria-disabled')).toBe('true')
expect(dialog.get('#contact-export-empty').text()).toBe('‘협력사 담당’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다.')
expect(dialog.find('[data-export-file-name]').exists()).toBe(false)
await click(submit)
expect(downloads).toEqual([])
await dialog.get('[data-export-group]').setValue('g1')
expect(submit.attributes('aria-disabled')).toBeUndefined()
})
})
})
@@ -7,7 +7,8 @@ import ToastHost from '@/components/feedback/ToastHost.vue'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_MAX_LENGTH } from '@/shared/constants/mail'
import type { Draft, MailSignature, Mailbox, MessageDetail } from '@/shared/types/mail'
import type { Draft, Mailbox, MessageDetail } from '@/shared/types/mail'
import type { MailSignature, MailSignatureDefaults } from '@/shared/types/mail-signature'
import { formatDraftSavedTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -68,32 +69,30 @@ const MAILBOXES: Mailbox[] = [
{ id: 'trash', name: '휴지통', role: 'trash', parentId: null, sortOrder: 5, totalCount: 0, unreadCount: 0 },
]
const NO_SIGNATURE: MailSignature = {
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
}
/** 서명 — 새 메일 · 답장과 전달 기본 (16단계 — 여러 서명, 계약 2절 "서명") */
const SIGNATURE: MailSignature = {
id: 'sig-out',
name: '대외',
bodyHtml:
'<div style="font-family: \'Malgun Gothic\'; font-size: 14px; line-height: 1.75">' +
'<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<p style="margin: 0"><img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`,
body: '김아라 · 인프라팀',
isForNew: true,
isForReply: true,
images: [{ id: IMAGE_ID, type: 'image/png', size: 1000, width: 480, height: 120 }],
updatedAt: SAVED_AT,
}
/** 서명이 없는 직원의 기본 둘 */
const NO_DEFAULTS: MailSignatureDefaults = { newMessageId: null, replyId: null }
/** 먼저 보는 응답 — undefined 면 기본 서버가 답한다 */
type ExtraHandler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
interface ServerOptions {
signature?: MailSignature
/** 내 서명 — 비면 서명 없음 */
signatures?: MailSignature[]
/** 기본 둘 — 비면 첫 서명을 두 곳 모두에, 서명이 없으면 넣지 않음 */
defaults?: MailSignatureDefaults
/** POST · PUT /drafts 의 응답 — 비면 d1, d2 … 로 성공 */
draftResults?: MockResult[]
/** GET /drafts/{id} */
@@ -122,7 +121,14 @@ function server(options: ServerOptions = {}): MockHandler {
if (method === 'GET' && url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
}
if (method === 'GET' && url === '/mail-signature') return ok(options.signature ?? NO_SIGNATURE)
if (method === 'GET' && url === '/mail-signatures') {
const signatures = options.signatures ?? []
return ok({ items: signatures, pageInfo: { hasNext: false, nextCursor: null, total: signatures.length } })
}
if (method === 'GET' && url === '/mail-signature-defaults') {
const first = options.signatures?.[0]?.id ?? null
return ok(options.defaults ?? (first ? { newMessageId: first, replyId: first } : NO_DEFAULTS))
}
if (method === 'GET' && url.startsWith('/drafts/')) {
const draft = options.draft ?? drafts.get(url.slice('/drafts/'.length))
if (!draft) return fail(404, 'RES_001')
@@ -1045,7 +1051,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
it('저장본으로 채우고 답장으로 연다 — 서명은 다시 넣지 않고, 고치면 그 id 로 PUT (inReplyTo 그대로)', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?draft=d7',
server({ draft: DRAFT, signature: SIGNATURE }),
server({ draft: DRAFT, signatures: [SIGNATURE] }),
)
expect(wrapper.get('h1').text()).toBe('답장')
@@ -1143,23 +1149,63 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
})
})
describe('서명 (계약 2절 "서명")', () => {
it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
const paragraphs = wrapper.findAll('#compose-body p')
describe('서명 (계약 2절 "서명" — 16단계 여러 서명)', () => {
/** 둘째 서명 — 사내 (그림 없음) */
const INSIDE: MailSignature = {
id: 'sig-in',
name: '사내',
bodyHtml: '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>',
body: '김아라 · 인프라팀\n내선 1234',
images: [],
updatedAt: SAVED_AT,
}
expect(paragraphs.map((paragraph) => paragraph.text())).toEqual(['', '', '김아라 · 인프라팀', ''])
/** 맨 위 단계 블록 — 서명 자리는 [서명:글], 원문 인용은 [인용] */
function blocks(wrapper: VueWrapper): string[] {
const out: string[] = []
editorOf(wrapper.element).state.doc.forEach((node) => {
if (node.type.name === 'mailSignature') out.push(`[서명:${node.textContent}]`)
else out.push(node.type.name === 'blockquote' ? '[인용]' : node.textContent)
})
return out
}
function menuItems(wrapper: VueWrapper) {
return wrapper.findAll('.compose-signature__menu [role^="menuitem"]')
}
/** [서명 ▾]를 열고 이름이 같은 항목을 누른다 */
async function pickSignature(wrapper: VueWrapper, label: string) {
await wrapper.get('#compose-signature').trigger('click')
await flushPromises()
const item = menuItems(wrapper).find((entry) => entry.text() === label)
if (!item) throw new Error(`메뉴에 ${label} 이 없다`)
await item.trigger('click')
await flushPromises()
await flushPromises()
}
function signatureStatus(wrapper: VueWrapper): string {
return wrapper.get('[data-signature-status]').text()
}
it('새 메일은 본문 끝 — 쓸 줄 · 빈 줄 하나 뒤에 새 메일 기본 서명(그림 포함). 넣어 둔 서명은 "바꿈"이 아니다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
// 끝의 빈 줄은 편집기가 첫 거래 때 스스로 붙인다 — 연 직후에는 서명 자리가 끝이다
expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]'])
expect(wrapper.get('#compose-body img[src]').attributes()).toMatchObject({
src: `/api/mail-signature/images/${IMAGE_ID}`,
width: '144',
})
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
await wait(30_000)
expect(draftCalls(requests)).toEqual([])
expect(blocksUnload()).toBe(false)
})
it('보내면 서명 그림이 서식 본문에 남는다 — 텍스트 대체 본문에는 그림이 없다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signature: SIGNATURE }))
it('보내면 서명 자리는 빈 문단 뒤의 속성 없는 div(앱과 같은 경계) — 그림은 서식 본문에만', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server({ signatures: [SIGNATURE] }))
const input = wrapper.get('#compose-to')
await input.setValue('doyun.lee@arasystem.it.kr')
await input.trigger('blur')
@@ -1171,33 +1217,219 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
const send = requests.find((request) => request.method === 'POST' && request.url === '/messages')
const data = send?.data as { body: string; bodyHtml: string }
expect(data.body).toBe('\n\n김아라 · 인프라팀')
expect(data.bodyHtml).toContain(`<img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt="">`)
})
it('답장 · 전달은 인용 위 — 쓸 줄 · 빈 줄 · 서명 · 인용', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server({ signature: SIGNATURE }))
const doc = editorOf(wrapper.element).state.doc
const blocks: string[] = []
doc.forEach((node) => blocks.push(node.type.name === 'blockquote' ? '인용' : node.textContent))
expect(blocks.slice(0, 5)).toEqual(['', '', '김아라 · 인프라팀', '', '인용'])
})
it('답장 · 전달에 넣지 않기로 했으면 넣지 않는다', async () => {
const { wrapper } = await mountCompose(
'/mail/compose?replyTo=m1',
server({ signature: { ...SIGNATURE, isForReply: false } }),
expect(data.bodyHtml).toContain(
'<p style="margin: 0"><br></p><div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p>' +
`<p style="margin: 0"><img src="/api/mail-signature/images/${IMAGE_ID}" width="144" alt=""></p></div>`,
)
expect(wrapper.find('#compose-body img[src]').exists()).toBe(false)
expect(wrapper.get('#compose-body').text()).not.toContain('김아라 · 인프라팀')
})
it('서명을 못 받아도 서명 없이 연다 — 알리지 않는다', async () => {
const { wrapper } = await mountCompose(
it('답장 · 전달은 인용 위에 답장과 전달 기본 서명', async () => {
const reply = await mountCompose(
'/mail/compose?replyTo=m1',
server({ signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }),
)
expect(blocks(reply.wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
expect(reply.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
})
it('답장과 전달 기본이 넣지 않음이면 넣지 않는다', async () => {
const none = await mountCompose(
'/mail/compose?replyTo=m1',
server({ signatures: [SIGNATURE], defaults: { newMessageId: SIGNATURE.id, replyId: null } }),
)
expect(none.wrapper.find('#compose-body img[src]').exists()).toBe(false)
expect(blocks(none.wrapper).slice(0, 2)).toEqual(['', '[인용]'])
expect(none.wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
})
it('서명을 못 받아도 서명 없이 연다 — 알리지 않고, [서명 ▾]를 열 때 다시 받아 본다', async () => {
let failing = true
const { wrapper, requests } = await mountCompose(
'/mail/compose',
server({ extra: (request) => (request.url === '/mail-signature' ? fail(503, 'MAIL_001') : undefined) }),
server({
signatures: [SIGNATURE],
extra: (request) => (failing && request.url === '/mail-signatures' ? fail(503, 'MAIL_001') : undefined),
}),
)
expect(wrapper.get('#compose-body').text()).toBe('')
expect(useToastStore().toasts).toEqual([])
failing = false
await wrapper.get('#compose-signature').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/mail-signatures')).toHaveLength(2)
expect(menuItems(wrapper).map((item) => item.text())).toEqual(['대외', '넣지 않음', '서명 관리'])
})
describe('[서명 ▾] — 쓰기 화면에서 서명 바꾸기 (디자인 원본 Signature16 6)', () => {
const BOTH = { signatures: [SIGNATURE, INSIDE], defaults: { newMessageId: SIGNATURE.id, replyId: INSIDE.id } }
it('메뉴 — 서명(만든 차례) · 넣지 않음 · 가는 선 · 서명 관리, 지금 든 서명에 ✓. 키보드로 열면 첫 항목, Esc 는 단추로', async () => {
const { wrapper } = await mountCompose('/mail/compose', server(BOTH), [], true)
const button = wrapper.get('#compose-signature')
expect(button.attributes('aria-haspopup')).toBe('menu')
expect(button.attributes('aria-expanded')).toBe('false')
;(button.element as HTMLButtonElement).focus()
await button.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
expect(button.attributes('aria-expanded')).toBe('true')
expect(wrapper.get('.compose-signature__menu').attributes('aria-label')).toBe('서명')
expect(
menuItems(wrapper).map((item) => [item.text(), item.attributes('role'), item.attributes('aria-checked')]),
).toEqual([
['대외', 'menuitemradio', 'true'],
['사내', 'menuitemradio', 'false'],
['넣지 않음', 'menuitemradio', 'false'],
['서명 관리', 'menuitem', undefined],
])
expect(wrapper.find('.compose-signature__menu hr').exists()).toBe(true)
expect(document.activeElement?.textContent?.trim()).toBe('대외')
await wrapper.get('.compose-signature__menu').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('.compose-signature__menu').exists()).toBe(false)
expect(document.activeElement?.id).toBe('compose-signature')
})
it('다른 서명을 고르면 서명 자리만 바뀐다 — 쓴 글은 그대로, 화면 낭독기에 알리고(알림 없음) 실행 취소로 되돌린다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server(BOTH))
editorOf(wrapper.element).commands.insertContentAt(1, '도윤님, 안녕하세요.')
await flushPromises()
await pickSignature(wrapper, '사내')
expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
expect(signatureStatus(wrapper)).toBe('서명을 ‘사내’로 바꿨습니다.')
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
expect(useToastStore().toasts).toEqual([])
// 바꾼 서명도 바꿈이다 — 임시 저장한다
await wait(3_000)
expect(draftCalls(requests)).toHaveLength(1)
editorOf(wrapper.element).commands.undo()
await flushPromises()
expect(blocks(wrapper)).toEqual(['도윤님, 안녕하세요.', '', '[서명:김아라 · 인프라팀]', ''])
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
})
it('넣지 않음 — 서명 자리와 그 앞의 빈 줄을 빼고, 다시 고르면 처음 넣는 자리(본문 끝)에 넣는다', async () => {
const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
editorOf(wrapper.element).commands.insertContentAt(1, '안녕하세요.')
await flushPromises()
await pickSignature(wrapper, '넣지 않음')
expect(blocks(wrapper)).toEqual(['안녕하세요.', ''])
expect(signatureStatus(wrapper)).toBe('서명을 ‘넣지 않음’으로 바꿨습니다.')
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 넣지 않음')
await pickSignature(wrapper, '사내')
expect(blocks(wrapper)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀내선 1234]', ''])
})
it('답장은 인용 위의 서명 자리만 바뀐다', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', server(BOTH))
await pickSignature(wrapper, '대외')
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀]', '[인용]'])
await pickSignature(wrapper, '넣지 않음')
expect(blocks(wrapper).slice(0, 2)).toEqual(['', '[인용]'])
await pickSignature(wrapper, '사내')
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '', '[서명:김아라 · 인프라팀내선 1234]', '[인용]'])
})
it('서명 안을 고치면 어느 서명과도 같지 않다 — ✓ 없이, 단추는 "서명"만. 고르면 고친 것도 새 서명으로 바뀐다', async () => {
const { wrapper } = await mountCompose('/mail/compose', server(BOTH))
const editor = editorOf(wrapper.element)
let inside = -1
editor.state.doc.descendants((node, pos) => {
if (inside < 0 && node.isText && node.text === '김아라') inside = pos
})
editor.commands.insertContentAt(inside, '대표 ')
await flushPromises()
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명')
await wrapper.get('#compose-signature').trigger('click')
await flushPromises()
expect(menuItems(wrapper).map((item) => item.attributes('aria-checked'))).toEqual(['false', 'false', 'false', undefined])
await menuItems(wrapper)[0]?.trigger('click')
await flushPromises()
expect(blocks(wrapper)).toEqual(['', '', '[서명:김아라 · 인프라팀]', ''])
})
it('이어 쓰는 임시 저장본 — 서명 자리를 다시 찾아 같은 서명이면 ✓, 바꾸면 그 자리만 바뀐다 (서명을 다시 넣지 않는다)', async () => {
const saved: Draft = {
id: 'd9',
to: [],
cc: [],
bcc: [],
subject: '견적',
body: '확인했습니다.\n\n김아라 · 인프라팀\n내선 1234',
// 서버가 정화한 저장본 — 감싸개 · 빈 문단 뒤의 속성 없는 div (data-* 없음)
bodyHtml:
'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 14px; line-height: 1.75">' +
'<p style="margin: 0">확인했습니다.</p><p style="margin: 0"><br /></p>' +
'<div><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p></div></div>',
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-26T01:00:00Z',
}
const { wrapper } = await mountCompose('/mail/compose?draft=d9', server({ ...BOTH, draft: saved }))
expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀내선 1234]'])
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 사내')
await pickSignature(wrapper, '대외')
expect(blocks(wrapper)).toEqual(['확인했습니다.', '', '[서명:김아라 · 인프라팀]', ''])
})
it('서명 위 빈 줄에 글을 써도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
// 같은 서버 — 저장한 판을 기억했다가 다시 열 때 내준다
const handler = server(BOTH)
const first = await mountCompose('/mail/compose', handler)
const editor = editorOf(first.wrapper.element)
// 쓸 줄은 비우고 서명 바로 위 빈 줄(둘째 줄)에 쓴다
editor.commands.insertContentAt(editor.state.doc.child(0).nodeSize + 1, '감사합니다.')
await flushPromises()
expect(blocks(first.wrapper)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]', ''])
await wait(3_000)
const saved = draftCalls(first.requests)[0]
expect(saved?.method).toBe('POST')
expect((saved?.data as { bodyHtml: string }).bodyHtml).toContain(
'<p style="margin: 0">감사합니다.</p><p style="margin: 0"><br></p><div>',
)
first.wrapper.unmount()
// 저장한 판을 다시 연다 — 서명 자리를 찾고 그 서명에 ✓
const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
expect(blocks(wrapper).slice(0, 4)).toEqual(['', '감사합니다.', '', '[서명:김아라 · 인프라팀]'])
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
})
it('Backspace 로 서명 위 빈 줄을 지워도 빈 줄을 다시 두어 저장 · 다시 열기에서 서명 자리를 찾는다 (리뷰 M3)', async () => {
const handler = server(BOTH)
const first = await mountCompose('/mail/compose', handler)
const editor = editorOf(first.wrapper.element)
editor.commands.insertContentAt(1, '안녕하세요.')
// 서명 바로 위 빈 줄(둘째 줄)에서 Backspace
const blankLine = editor.state.doc.child(0).nodeSize + 1
editor.commands.setTextSelection(blankLine)
editor.commands.keyboardShortcut('Backspace')
await flushPromises()
expect(blocks(first.wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
await wait(3_000)
expect(draftCalls(first.requests)).toHaveLength(1)
first.wrapper.unmount()
const { wrapper } = await mountCompose('/mail/compose?draft=d1', handler)
expect(blocks(wrapper).slice(0, 3)).toEqual(['안녕하세요.', '', '[서명:김아라 · 인프라팀]'])
expect(wrapper.get('#compose-signature').attributes('aria-label')).toBe('서명: 대외')
})
it('서명 관리 — 설정 › 메일 › 서명으로 간다 (바꾼 것이 없으면 묻지 않는다)', async () => {
const { wrapper, router } = await mountCompose('/mail/compose', server(BOTH))
await pickSignature(wrapper, '서명 관리')
expect(router.currentRoute.value.name).toBe('settings-mail-signature')
})
})
})
})
+108 -13
View File
@@ -6,6 +6,11 @@
// 받는 사람을 채운 새 메일(8단계 — 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]): 주소는 URL 이 아니라 라우터 상태로 온다(compose-prefill.ts).
// 채운 받는 사람은 "바꿈"이 아니다(답장처럼 — 손대지 않고 닫으면 저장하지 않는다). 초점은 제목으로 (받는 사람은 이미 있다)
// 서명: 열 때 설정의 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위 (디자인 README "2단계 › 서명").
// 16단계(여러 서명 — 디자인 원본 Signature16 6): 넣는 서명은 "메일에 넣을 서명"의 새 메일 · 답장과 전달 기본이고, 서명 자리(빈 줄 뒤의 서명 덩어리 —
// 앱과 같은 경계, mail-signature-block.ts)로 넣는다. 아래 단추 줄 [임시 저장] 다음의 [서명 ▾](ComposeSignatureMenu)로 그 자리만 다른 서명 · 넣지 않음으로
// 바꾼다(쓴 글 · 인용 · 첨부는 그대로, 실행 취소로 되돌린다) — 바꾸면 화면 낭독기에 "서명을 ‘사내’로 바꿨습니다."(알림 없음).
// 어느 서명이 들었는지는 서명 자리의 글 · 그림을 저장한 서명과 견준다(이어 쓰는 저장본도 — 고쳤으면 ✓ 없이). 서명 관리는 설정 › 메일 › 서명으로(나가기 규칙대로).
// 받는 사람에 따라 저절로 바꾸지 않는다.
// 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장", useDraftSaving):
// - 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지 — 쉬지 않고 쓰면 처음 바꾼 뒤 30초에) · 「임시 저장」을 누를 때 ·
// 화면을 떠날 때 저장한다. 상태는 아래 단추 줄에.
@@ -71,6 +76,7 @@ import ComposeFooter from '@/components/mail/ComposeFooter.vue'
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
import ComposeScheduleDialog from '@/components/mail/ComposeScheduleDialog.vue'
import ComposeSignatureMenu from '@/components/mail/ComposeSignatureMenu.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
@@ -106,8 +112,11 @@ import {
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS } from '@/shared/constants/mail-send-later'
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import { COMPOSE_SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
import type { ComposeSignatureState } from '@/shared/types/mail-signature'
import {
isSameComposeForm,
snapshotComposeForm,
@@ -131,8 +140,9 @@ import {
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { directionParticle } from '@/shared/utils/korean'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
import { newMailHtmlWithSignature, signatureBlockHtml, signatureHtmlOf } from '@/shared/utils/mail-signature'
import { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
import { undoToastDeadline } from '@/shared/utils/pending-send'
import { rejectedScheduleProblem, sendAtOf, type ScheduleRejection } from '@/shared/utils/schedule-send'
@@ -239,6 +249,12 @@ let focusBodyWhenReady = false
const preparing = ref<boolean>(false)
/** 새 메일에 넣을 서명을 기다리는 중 — 편집기를 서명과 함께 만든다 (받는 사람 · 제목은 그대로 쓸 수 있다) */
const bodyPending = ref<boolean>(false)
/** 본문에 든 서명 — [서명 ▾]의 ✓ · 단추의 화면 낭독 이름 (16단계 — 편집기가 준비되면 · 본문이 바뀔 때마다 다시 잰다) */
const signatureState = ref<ComposeSignatureState>({ kind: 'none' })
/** 서명을 바꾼 뒤 화면 낭독기에 읽어 줄 글 — "서명을 ‘사내’로 바꿨습니다." */
const signatureAnnouncement = ref<string>('')
/** 서명 HTML 마다의 비교 값 — 편집기 스키마로 한 번 읽어 둔다 (스키마는 편집기를 새로 만들어도 같다) */
const signatureFingerprints = new Map<string, string>()
/** 제목에 보이는 쓰기 종류 — 이어 쓰는 저장본은 불러온 뒤 답장 · 전달 · 새 메일 중 하나로 정한다 */
const composeKind = ref<ComposeMode>('new')
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
@@ -520,6 +536,8 @@ const recipientSuggestions = useRecipientSuggestions({
})
provide(RECIPIENT_SUGGESTIONS, recipientSuggestions)
recipientSuggestions.preload()
// [서명 ▾]의 목록 · 이어 쓰는 저장본의 서명 견주기 — 메일 화면이 받아 두었으면 그대로 (새 메일 · 답장은 받은 뒤 서명을 넣는다)
void signatureStore.ensureLoaded()
function commitRecipientInputs() {
toField.value?.commit()
@@ -637,33 +655,32 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
let prepareSeq = 0
/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다) */
/** 새 메일 — 서명을 받은 뒤 편집기를 만든다 (받는 사람 · 제목은 그동안에도 쓸 수 있다). 넣는 서명은 새 메일 기본 (못 받았으면 넣지 않는다) */
async function prepareNew() {
prepareSeq += 1
const seq = prepareSeq
bodyPending.value = true
const signature = signatureHtmlFor(await signatureStore.ensureSignature(), 'new')
await signatureStore.ensureLoaded()
if (seq !== prepareSeq) return
const signature = signatureHtmlOf(signatureStore.defaultSignature('new'))
if (signature) draftHtml.value = newMailHtmlWithSignature(signature)
bodyPending.value = false
}
/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다 */
/** 답장·전달 — 원본 메일과 서명을 불러와 양식을 채운다. 넣는 서명은 답장과 전달 기본 */
async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, 'new'>) {
prepareSeq += 1
const seq = prepareSeq
preparing.value = true
try {
const [original, signature] = await Promise.all([
getMessage(messageId),
signatureStore.ensureSignature(),
])
const [original] = await Promise.all([getMessage(messageId), signatureStore.ensureLoaded()])
if (seq !== prepareSeq) return
const signatureHtml = signatureHtmlFor(signature, 'reply')
const signatureHtml = signatureHtmlOf(signatureStore.defaultSignature('reply'))
const signatureBlock = signatureHtml ? signatureBlockHtml(signatureHtml) : null
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
let filledAttachments: readonly number[] = []
if (sourceMode === 'forward') {
const draft = buildForwardDraft(original, undefined, signatureHtml)
const draft = buildForwardDraft(original, undefined, signatureBlock)
subject.value = draft.subject
draftHtml.value = draft.html
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
@@ -671,7 +688,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
.filter((item) => item.status === 'done')
.map((item) => item.key)
} else {
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureHtml)
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode, undefined, signatureBlock)
to.value = draft.to.map(tokenFromAddress)
cc.value = draft.cc.map(tokenFromAddress)
showCopies.value = cc.value.length > 0
@@ -761,6 +778,7 @@ function isFocusFree(): boolean {
function onEditorReady() {
editorReady.value = true
bodyChanged.value = false
refreshSignatureState()
if (!focusBodyWhenReady) return
focusBodyWhenReady = false
void nextTick(() => {
@@ -1094,13 +1112,72 @@ function clearLengthError(field: 'subject' | 'body') {
if (lengthError.value === field) lengthError.value = null
}
/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다 */
/** 본문이 바뀌었다 — 길이 안내를 내리고, 저장한 때와 다른지 다시 잰 뒤 자동 저장 차례를 잡는다. 든 서명도 다시 잰다 */
function onEditorChange() {
clearLengthError('body')
bodyChanged.value = editorRef.value?.isChanged() ?? false
refreshSignatureState()
drafts.touch()
}
/** 서명 HTML 의 비교 값 — 서명마다 한 번 편집기 스키마로 읽는다 */
function signatureFingerprintOf(editor: MailEditorHandle, html: string): string {
let fingerprint = signatureFingerprints.get(html)
if (fingerprint === undefined) {
fingerprint = editor.signatureFingerprint(html)
signatureFingerprints.set(html, fingerprint)
}
return fingerprint
}
/**
* 본문에 든 서명을 다시 잰다 — 서명 자리가 없으면 넣지 않음, 있으면 글 · 그림이 같은 서명(만든 차례로 먼저 맞는 것),
* 어느 서명과도 같지 않으면(그 메일에서 고쳤거나 지운 서명) ✓ 없이 (디자인 원본 Signature16 "서명 자리")
*/
function refreshSignatureState() {
const editor = editorRef.value
if (!editor || !editorReady.value) return
const place = editor.signaturePlace()
if (place === null) {
signatureState.value = { kind: 'none' }
return
}
const match = signatureStore.signatures.find((signature) => {
const html = signatureHtmlOf(signature)
return html !== null && signatureFingerprintOf(editor, html) === place
})
signatureState.value = match ? { kind: 'signature', id: match.id } : { kind: 'edited' }
}
/** [서명 ▾]를 열었다 — 서명을 받지 못했으면 다시 받아 본다 (받으면 목록이 채워진다) */
function onSignatureMenuOpen() {
void signatureStore.ensureLoaded()
}
/**
* [서명 ▾]에서 골랐다 — 본문의 서명 자리만 그 서명으로(넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다).
* 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 인용 위)에. 바꾼 뒤 화면 낭독기에만 알린다
*/
async function onPickSignature(id: string | null) {
const editor = editorRef.value
if (!editor || !editorReady.value) return
const signature = signatureStore.signatureById(id)
// 그사이 다른 창에서 지운 서명이면 바꾸지 않는다
if (id !== null && !signature) return
editor.replaceSignature(signatureHtmlOf(signature))
refreshSignatureState()
const name = signature?.name ?? COMPOSE_SIGNATURE_TEXT.none
// 같은 글이어도 다시 읽게 비웠다가 넣는다
signatureAnnouncement.value = ''
await nextTick()
signatureAnnouncement.value = COMPOSE_SIGNATURE_TEXT.changed(name, directionParticle(name))
}
/** 서명 관리 — 설정 › 메일 › 서명으로. 쓰던 메일은 나가기 규칙대로 먼저 임시 저장한다 (저장하지 못했을 때만 묻는다) */
function onManageSignatures() {
void router.push({ name: SETTINGS_ROUTES.signature })
}
function pickFiles() {
fileInputRef.value?.click()
}
@@ -1173,6 +1250,12 @@ watch([sendable, () => authStore.generation], ([can], [, previousSession]) => {
watch(hasMissingLarge, (missing) => {
if (!missing) drafts.touch()
})
// 서명을 (다시) 받았다 — [서명 ▾]의 ✓ 를 다시 잰다
watch(
() => signatureStore.signatures,
() => refreshSignatureState(),
)
</script>
<template>
@@ -1349,7 +1432,19 @@ watch(hasMissingLarge, (missing) => {
:sender="sender"
@save-draft="onSaveDraft"
@schedule="openSchedule"
/>
>
<template #signature>
<ComposeSignatureMenu
:signatures="signatureStore.signatures"
:state="signatureState"
:disabled="!editorReady || preparing || bodyPending || sending || discarding"
:announcement="signatureAnnouncement"
@open="onSignatureMenuOpen"
@select="onPickSignature"
@manage="onManageSignatures"
/>
</template>
</ComposeFooter>
</form>
<!-- 예약 발송 창 (9단계) — [예약]이면 보내기와 같은 흐름에 sendAt 을 더한다 -->
+2 -2
View File
@@ -40,8 +40,8 @@ provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
void mailStore.ensureMailboxes()
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
void mailStore.ensureQuota()
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureSignature()
// 쓰기 화면이 열 때 넣을 서명 · [서명 ▾]의 목록 — 서명 목록과 기본 둘(16단계)을 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
void useMailSignatureStore().ensureLoaded()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 패널의 「예약됨」 — 예약한 메일이 있을 때만 그린다
@@ -0,0 +1,618 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { useTwoFactorStore } from '@/stores/two-factor.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import AccountTwoFactorPage from './AccountTwoFactorPage.vue'
// 설정 › 계정 › 2단계 인증 — 휴대폰 바꾸기 (16단계 — 디자인 원본 Settings16 5~8 · 계약 5절 "2단계 인증")
// ① 새 휴대폰 등록(QR · 키 · 새 휴대폰의 코드 → [다음]) ② 지금 휴대폰으로 확인(지금 휴대폰의 코드 · 지금 비밀번호 → [바꾸기])
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const ON: TwoFactorStatus = { isEnabled: true, enabledAt: '2026-10-31T00:12:00.000Z' }
const OFF: TwoFactorStatus = { isEnabled: false, enabledAt: null }
const PASSWORD = 'old-pass-1234'
const DONE = '2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다.'
const UNCONFIRMED =
'휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요.'
const UNCONFIRMED_CLOSED =
'휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요.'
const SUPPORT = '지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요.'
function enrollment(seq: number): TwoFactorEnrollment {
const secret = seq === 1 ? 'R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6' : 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'
return {
enrollmentId: `enroll-${seq}`,
secret,
otpauthUrl: `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=${secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
expiresAt: '2026-11-10T00:22:00.000Z',
}
}
type Handler = (request: MockRequest) => MockResult | Promise<MockResult>
interface Server {
status?: () => MockResult
verify?: Handler
replace?: Handler
supportContact?: () => MockResult
}
/** 응답을 손으로 풀어 주는 요청 — 보내는 동안의 모양을 본다 */
function held() {
const replies: ((result: MockResult) => void)[] = []
const handler: Handler = () =>
new Promise<MockResult>((resolve) => {
replies.push(resolve)
})
return { handler, replies }
}
describe('AccountTwoFactorPage — 휴대폰 바꾸기 (16단계)', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
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()
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
let enrollSeq = 0
requests = installMockApi((request) => {
if (request.url === '/two-factor') return server.status ? server.status() : ok(ON)
if (request.url === '/two-factor/enrollments') {
enrollSeq += 1
return ok(enrollment(enrollSeq), 201)
}
if (/^\/two-factor\/enrollments\/[^/]+\/verification$/.test(request.url)) {
return server.verify ? server.verify(request) : ok({ verified: true })
}
if (request.url === '/two-factor/replacements') return server.replace ? server.replace(request) : ok(ON)
if (request.url === '/support-contact') return server.supportContact ? server.supportContact() : ok({ text: null })
return ok(null)
})
const router = createTestRouter()
await router.push('/settings/account/two-factor')
await router.isReady()
const wrapper = mount(AccountTwoFactorPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return wrapper
}
const calls = (pattern: string | RegExp) =>
requests.filter((request) => (typeof pattern === 'string' ? request.url === pattern : pattern.test(request.url)))
const verifyCalls = () => calls(/\/verification$/)
const toastMessages = () => useToastStore().toasts.map((toast) => toast.message)
const dialog = () => document.querySelector<HTMLDialogElement>('dialog[aria-labelledby="two-factor-replace-title"]')
const inDialog = <T extends Element = HTMLElement>(selector: string) => dialog()?.querySelector<T>(selector) ?? null
const inputValue = (id: string) => (document.getElementById(id) as HTMLInputElement | null)?.value
const stepItems = () =>
Array.from(inDialog('[data-replace-steps]')?.querySelectorAll('li') ?? []).map((item) => ({
text: item.textContent?.trim() ?? '',
current: item.getAttribute('aria-current'),
done: item.querySelector('svg') !== null && !item.hasAttribute('aria-hidden'),
}))
async function openReplace(wrapper: VueWrapper) {
await wrapper.get('[data-two-factor-replace]').trigger('click')
await flushPromises()
}
async function next(wrapper: VueWrapper, code: string) {
await wrapper.get('#two-factor-replace-new-code').setValue(code)
await wrapper.get('[data-replace-next]').trigger('click')
await flushPromises()
}
async function fillCurrent(wrapper: VueWrapper, values: { code?: string; password?: string }) {
if (values.code !== undefined) await wrapper.get('#two-factor-replace-current-code').setValue(values.code)
if (values.password !== undefined) await wrapper.get('#two-factor-replace-password').setValue(values.password)
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('[data-replace-submit]').trigger('click')
await flushPromises()
}
/** ②까지 — 새 휴대폰의 코드를 확인했다 */
async function toCurrentStep(wrapper: VueWrapper) {
await openReplace(wrapper)
await next(wrapper, '482913')
}
describe('① 새 휴대폰 등록', () => {
it('[휴대폰 바꾸기]를 누르면 창 — 켜진 채 새 키를 받고, 차례 · 글 · QR · 키는 바꾸기 글로, 처음 초점은 새 휴대폰의 코드 칸', async () => {
const wrapper = await mountPage()
await openReplace(wrapper)
expect(dialog()?.hasAttribute('open')).toBe(true)
expect(dialog()?.getAttribute('aria-describedby')).toBe('two-factor-replace-message')
expect(calls('/two-factor/enrollments').map((request) => request.method)).toEqual(['POST'])
expect(inDialog('#two-factor-replace-title')?.textContent?.trim()).toBe('2단계 인증 휴대폰 바꾸기')
expect(inDialog('[data-replace-steps]')?.getAttribute('aria-label')).toBe('휴대폰 바꾸기 차례')
expect(stepItems()).toEqual([
{ text: '새 휴대폰 등록', current: 'step', done: false },
{ text: '', current: null, done: false },
{ text: '지금 휴대폰으로 확인', current: null, done: false },
])
expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
'새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다.',
)
const text = dialog()?.textContent ?? ''
expect(text).toContain('새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요')
expect(text).toContain('인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요.')
expect(text).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.')
expect(text).toContain('바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.')
expect(inDialog('[data-qr-code]')?.getAttribute('aria-label')).toBe('인증 앱에 등록할 QR 코드')
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
expect(document.querySelector('label[for="two-factor-replace-new-code"]')?.textContent).toBe('새 휴대폰의 앱에 표시된 6자리 코드')
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
// 단추는 [다음] [취소]
expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
'다음',
'취소',
])
})
it('키 이름 id 가 켜기 창과 겹치지 않는다 — [복사]는 바꾸기 창의 키 이름을 가리킨다', async () => {
const wrapper = await mountPage()
await openReplace(wrapper)
expect(document.querySelectorAll('#two-factor-key-label')).toHaveLength(1)
expect(document.querySelectorAll('#two-factor-replace-key-label')).toHaveLength(1)
expect(inDialog('[data-enable-copy]')?.getAttribute('aria-describedby')).toBe('two-factor-replace-key-label')
})
it('6자리가 아니면 보내지 않고 칸 아래에 AUTH_007 문구 · 그 칸에 초점', async () => {
const wrapper = await mountPage()
await openReplace(wrapper)
await next(wrapper, '12')
expect(verifyCalls()).toEqual([])
expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
})
it('[다음]은 새 휴대폰의 코드를 먼저 확인한다 — 확인하는 동안 「확인하는 중…」(aria-busy · aria-disabled), 다시 눌러도 요청은 하나', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ verify: handler })
await openReplace(wrapper)
await next(wrapper, '482913')
expect(verifyCalls().map((request) => [request.method, request.url, request.data])).toEqual([
['POST', '/two-factor/enrollments/enroll-1/verification', { otpCode: '482913' }],
])
const button = wrapper.get('[data-replace-next]')
expect(button.text()).toBe('확인하는 중…')
expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
await button.trigger('click')
await flushPromises()
expect(verifyCalls()).toHaveLength(1)
replies[0]?.(ok({ verified: true }))
await flushPromises()
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
})
it('새 휴대폰의 코드가 틀리면(AUTH_006) 칸 아래 · 칸을 비우고 초점 — QR · 키는 그대로, 알림 없음', async () => {
const wrapper = await mountPage({ verify: () => fail(400, 'AUTH_006') })
await openReplace(wrapper)
await next(wrapper, '000111')
expect(wrapper.get('#two-factor-replace-new-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect(inputValue('two-factor-replace-new-code')).toBe('')
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
expect(calls('/two-factor/enrollments')).toHaveLength(1)
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
expect(toastMessages()).toEqual([])
})
it('AUTH_008(10분 지남 · 더 새 등록) — 창 위에 사전 문구, 새 키로 다시 그리고 코드는 비운다 · 처음 초점은 코드 칸', async () => {
const wrapper = await mountPage({ verify: () => fail(409, 'AUTH_008') })
await openReplace(wrapper)
await next(wrapper, '482913')
await flushPromises()
expect(calls('/two-factor')).toHaveLength(2)
expect(calls('/two-factor/enrollments')).toHaveLength(2)
const notice = wrapper.get('[data-replace-notice]')
expect(notice.text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
expect(notice.attributes('role')).toBe('alert')
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(inputValue('two-factor-replace-new-code')).toBe('')
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
expect(dialog()?.hasAttribute('open')).toBe(true)
expect(toastMessages()).toEqual([])
})
it('코드 칸에서 Enter 도 [다음]과 같다', async () => {
const wrapper = await mountPage()
await openReplace(wrapper)
await wrapper.get('#two-factor-replace-new-code').setValue('482913')
await wrapper.get('#two-factor-replace-new-code').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(verifyCalls()).toHaveLength(1)
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
})
})
describe('② 지금 휴대폰으로 확인', () => {
it('새 휴대폰을 확인하면 ② — 차례(마친 걸음 체크 · 지금 걸음) · 글 · 칸 둘 · 문의 줄 · [이전] [바꾸기] [취소], 처음 초점은 지금 휴대폰의 코드 칸', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
expect(stepItems()).toEqual([
{ text: '새 휴대폰 등록', current: null, done: true },
{ text: '', current: null, done: false },
{ text: '지금 휴대폰으로 확인', current: 'step', done: false },
])
expect(inDialog('#two-factor-replace-message')?.textContent?.trim()).toBe(
'새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다.',
)
expect(document.querySelector('label[for="two-factor-replace-current-code"]')?.textContent).toBe('지금 휴대폰의 앱에 표시된 6자리 코드')
expect(document.querySelector('label[for="two-factor-replace-password"]')?.textContent).toBe('지금 비밀번호')
expect(wrapper.get('#two-factor-replace-current-code').attributes()).toMatchObject({
inputmode: 'numeric',
autocomplete: 'one-time-code',
})
expect(wrapper.get('#two-factor-replace-password').attributes('autocomplete')).toBe('current-password')
// 연락처가 없으면 글만
expect(inDialog('[data-support-line]')?.textContent?.trim()).toBe(SUPPORT)
expect(inDialog('[data-support-contact]')).toBeNull()
// ①의 칸 · QR 은 없다
expect(inDialog('[data-qr-code]')).toBeNull()
expect(document.getElementById('two-factor-replace-new-code')).toBeNull()
expect(Array.from(dialog()?.querySelectorAll('.modal-dialog__actions button') ?? []).map((button) => button.textContent?.trim())).toEqual([
'이전',
'바꾸기',
'취소',
])
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
})
it('문의 연락처가 있으면 물음 다음 줄에 연락처', async () => {
const wrapper = await mountPage({ supportContact: () => ok({ text: 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it' }) })
await toCurrentStep(wrapper)
expect(inDialog('[data-support-line]')?.textContent).toContain(SUPPORT)
expect(inDialog('[data-support-contact]')?.textContent).toBe('IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it')
expect(calls('/support-contact')).toHaveLength(1)
})
it('보내기 전에 본다 — 칸 차례(지금 코드 → 비밀번호): 코드가 6자리가 아니면 AUTH_007 문구, 비밀번호를 비우면 VAL_001 문구', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
await submit(wrapper)
expect(calls('/two-factor/replacements')).toEqual([])
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_007)
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
await fillCurrent(wrapper, { code: '135 790' })
await submit(wrapper)
expect(calls('/two-factor/replacements')).toEqual([])
expect(wrapper.find('#two-factor-replace-current-code-error').exists()).toBe(false)
expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(document.activeElement?.id).toBe('two-factor-replace-password')
})
it('[바꾸기] — POST /two-factor/replacements { enrollmentId, currentOtpCode, currentPassword } · 30초, 창을 닫고 알림 · 화면은 그대로 · 초점은 [휴대폰 바꾸기]', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
expect(calls('/two-factor/replacements').map((request) => request.data)).toEqual([
{ enrollmentId: 'enroll-1', currentOtpCode: '135790', currentPassword: PASSWORD },
])
expect(calls('/two-factor/replacements')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
expect(dialog()?.hasAttribute('open')).toBe(false)
expect(toastMessages()).toEqual([DONE])
expect(useToastStore().toasts[0]).toMatchObject({ kind: 'success' })
// 켠 때는 그대로
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
expect(document.activeElement?.hasAttribute('data-two-factor-replace')).toBe(true)
// 키 · 칸은 지웠다 (닫힌 창은 ①의 빈 자리로 돌아간다)
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
expect(inDialog('[data-qr-code]')).toBeNull()
expect(inputValue('two-factor-replace-password')).toBeUndefined()
})
it('바꾸는 동안 「바꾸는 중…」 — [바꾸기] · [이전]은 aria-disabled, 다시 눌러도 요청은 하나 · [이전]도 움직이지 않는다', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ replace: handler })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
const button = wrapper.get('[data-replace-submit]')
expect(button.text()).toBe('바꾸는 중…')
expect(button.attributes()).toMatchObject({ 'aria-busy': 'true', 'aria-disabled': 'true' })
expect(wrapper.get('[data-replace-back]').attributes('aria-disabled')).toBe('true')
await submit(wrapper)
await wrapper.get('[data-replace-back]').trigger('click')
await flushPromises()
expect(calls('/two-factor/replacements')).toHaveLength(1)
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
replies[0]?.(ok(ON))
await flushPromises()
expect(toastMessages()).toEqual([DONE])
})
it('지금 비밀번호가 틀리면(AUTH_005) 비밀번호 칸 아래 · 그 칸에 초점 — 코드는 남긴다, 알림 없음', async () => {
const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_005') })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
await submit(wrapper)
expect(wrapper.get('#two-factor-replace-password-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_005)
expect(document.activeElement?.id).toBe('two-factor-replace-password')
expect(inputValue('two-factor-replace-current-code')).toBe('135790')
expect(dialog()?.hasAttribute('open')).toBe(true)
expect(toastMessages()).toEqual([])
})
it('지금 휴대폰의 코드가 틀리면(AUTH_006) 코드 칸 아래 · 칸을 비우고 초점 — 비밀번호는 남긴다, 알림 없음', async () => {
const wrapper = await mountPage({ replace: () => fail(400, 'AUTH_006') })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '000111', password: PASSWORD })
await submit(wrapper)
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect(inputValue('two-factor-replace-current-code')).toBe('')
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
expect(toastMessages()).toEqual([])
})
it('AUTH_008 · 아직 켜져 있음 — ①로 돌아와 창 위에 사전 문구, 새 키 · 새 휴대폰의 코드는 비운다', async () => {
const wrapper = await mountPage({ replace: () => fail(409, 'AUTH_008') })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
await flushPromises()
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
expect(wrapper.get('[data-replace-notice]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_008)
expect(calls('/two-factor/enrollments')).toHaveLength(2)
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(inputValue('two-factor-replace-new-code')).toBe('')
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
expect(toastMessages()).toEqual([])
// 새 키로 다시 확인하면 ②의 창 위 글은 내린다 — 그 등록으로 보낸다
await next(wrapper, '246802')
expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
expect(verifyCalls().at(-1)?.url).toBe('/two-factor/enrollments/enroll-2/verification')
})
it('AUTH_008 · 그사이 다른 곳에서 꺼짐 — 창을 닫고 사전 문구로 알린다, 화면은 「꺼짐」', async () => {
let disabledElsewhere = false
const wrapper = await mountPage({
status: () => ok(disabledElsewhere ? OFF : ON),
replace: () => {
disabledElsewhere = true
return fail(409, 'AUTH_008')
},
})
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
await flushPromises()
expect(dialog()?.hasAttribute('open')).toBe(false)
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_008])
expect(wrapper.get('[data-two-factor-label]').text()).toBe('꺼짐')
expect(calls('/two-factor/enrollments')).toHaveLength(1)
})
it('결과를 모르면(503 MAIL_001) 바뀌었다고도 아니라고도 하지 않는다 — 창 위 상자 · ② 그대로 · 코드는 비우고 초점, 사전 알림 없음', async () => {
const wrapper = await mountPage({ replace: () => fail(503, 'MAIL_001') })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
const notice = wrapper.get('[data-replace-notice]')
expect(notice.text()).toBe(UNCONFIRMED)
expect(notice.attributes('role')).toBe('alert')
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(true)
expect(inputValue('two-factor-replace-current-code')).toBe('')
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
expect(document.activeElement?.id).toBe('two-factor-replace-current-code')
expect(toastMessages()).toEqual([])
// 화면의 상태는 건드리지 않는다
expect(useTwoFactorStore().twoFactor).toEqual(ON)
})
it('결과를 모르는 채 다시 [바꾸기] — 코드가 맞지 않다고 나와도 창 위 상자는 남긴다', async () => {
let replies = 0
const wrapper = await mountPage({
replace: () => {
replies += 1
return replies === 1 ? 'network' : fail(400, 'AUTH_006')
},
})
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
// 응답 없음(SYS_001)도 사전 알림 없이 같은 상자
expect(toastMessages()).toEqual([])
await fillCurrent(wrapper, { code: '975310' })
await submit(wrapper)
expect(wrapper.get('#two-factor-replace-current-code-error').text()).toBe(ERROR_CODE_MESSAGES.AUTH_006)
expect(wrapper.get('[data-replace-notice]').text()).toBe(UNCONFIRMED)
})
it('[이전] — ①로(같은 키 · 새 키를 받지 않음), 새 휴대폰의 코드는 비우고 초점, 다시 [다음]은 같은 등록으로', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await wrapper.get('[data-replace-back]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
expect(stepItems()[0]).toMatchObject({ current: 'step', done: false })
expect(inDialog('[data-enable-key]')?.textContent).toBe('R4WN6HMQT2XBZK7DPL3FJC5YVQ2GNEA6')
expect(calls('/two-factor/enrollments')).toHaveLength(1)
expect(inputValue('two-factor-replace-new-code')).toBe('')
expect(document.activeElement?.id).toBe('two-factor-replace-new-code')
await next(wrapper, '246802')
expect(verifyCalls().map((request) => request.url)).toEqual([
'/two-factor/enrollments/enroll-1/verification',
'/two-factor/enrollments/enroll-1/verification',
])
// 지금 휴대폰의 코드는 시간이 지나 비웠고, 지금 비밀번호는 창을 닫을 때까지 남는다
expect(inputValue('two-factor-replace-current-code')).toBe('')
expect(inputValue('two-factor-replace-password')).toBe(PASSWORD)
})
it('두 칸에서 Enter 도 [바꾸기]와 같다', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await wrapper.get('#two-factor-replace-password').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(calls('/two-factor/replacements')).toHaveLength(1)
expect(toastMessages()).toEqual([DONE])
})
})
describe('닫은 뒤 · 세션', () => {
it('[취소]로 닫아도 서버가 바꿨으면 화면을 맞추고 알린다', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ replace: handler })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
await wrapper.get('[data-replace-cancel]').trigger('click')
await flushPromises()
expect(dialog()?.hasAttribute('open')).toBe(false)
replies[0]?.(ok({ ...ON, enabledAt: '2026-11-01T00:00:00.000Z' }))
await flushPromises()
expect(toastMessages()).toEqual([DONE])
expect(useTwoFactorStore().twoFactor?.enabledAt).toBe('2026-11-01T00:00:00.000Z')
})
it('닫은 뒤 온 결과를 모르는 실패는 알림으로 · 칸 아래 거절은 사전 문구 알림으로', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ replace: handler })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
await wrapper.get('[data-replace-cancel]').trigger('click')
await flushPromises()
replies[0]?.(fail(503, 'MAIL_001'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([['error', UNCONFIRMED_CLOSED]])
useToastStore().clear()
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: 'wrong-pass' })
await submit(wrapper)
await wrapper.get('[data-replace-cancel]').trigger('click')
await flushPromises()
replies[1]?.(fail(400, 'AUTH_005'))
await flushPromises()
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_005])
})
it('닫았다 다시 연 창에는 앞 요청의 거절을 넣지 않는다 — 새 키로 ①부터', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ replace: handler })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
await wrapper.get('[data-replace-cancel]').trigger('click')
await flushPromises()
await openReplace(wrapper)
expect(calls('/two-factor/enrollments')).toHaveLength(2)
expect(wrapper.find('[data-replace-step="new"]').exists()).toBe(true)
expect(wrapper.get('[data-replace-next]').text()).toBe('다음')
replies[0]?.(fail(400, 'AUTH_006'))
await flushPromises()
expect(wrapper.find('#two-factor-replace-new-code-error').exists()).toBe(false)
expect(wrapper.find('[data-replace-notice]').exists()).toBe(false)
expect(toastMessages()).toEqual([])
})
it('닫으면 키 · 칸을 지운다 — 다시 열면 새 키, 닫은 뒤 늦게 온 확인은 ②로 넘기지 않는다', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ verify: handler })
await openReplace(wrapper)
await next(wrapper, '482913')
await wrapper.get('[data-replace-cancel]').trigger('click')
await flushPromises()
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
replies[0]?.(ok({ verified: true }))
await flushPromises()
expect(wrapper.find('[data-replace-step="current"]').exists()).toBe(false)
await openReplace(wrapper)
expect(inDialog('[data-enable-key]')?.textContent).toBe('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS')
expect(inputValue('two-factor-replace-new-code')).toBe('')
})
it('세션이 바뀌면(다른 곳에서 이 세션을 끊음 → 세션 만료) 창을 닫고 키 · 코드 · 비밀번호를 지운다', async () => {
const wrapper = await mountPage()
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
useAuthStore().expireSession()
await flushPromises()
expect(dialog()?.hasAttribute('open')).toBe(false)
expect(inDialog('[data-qr-code]')).toBeNull()
expect(inDialog('[data-enable-key]')?.textContent).toBe('')
expect(document.getElementById('two-factor-replace-password')).toBeNull()
expect(document.getElementById('two-factor-replace-current-code')).toBeNull()
expect(inputValue('two-factor-replace-new-code')).toBe('')
})
it('바꾸는 사이 세션이 바뀌었으면(로그아웃) 늦게 온 결과를 쓰지 않는다 — 알림도 없다', async () => {
const { handler, replies } = held()
const wrapper = await mountPage({ replace: handler })
await toCurrentStep(wrapper)
await fillCurrent(wrapper, { code: '135790', password: PASSWORD })
await submit(wrapper)
useAuthStore().clearAuth()
replies[0]?.(ok(ON))
await flushPromises()
expect(toastMessages()).toEqual([])
expect(useTwoFactorStore().twoFactor).toBeNull()
})
})
})
@@ -110,6 +110,8 @@ describe('AccountTwoFactorPage', () => {
expect(wrapper.get('[data-two-factor-state]').text()).toContain('지금은 로그인할 때 비밀번호만 확인합니다.')
expect(wrapper.get('[data-two-factor-enable]').text()).toBe('2단계 인증 켜기')
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
// 꺼져 있으면 [휴대폰 바꾸기]가 없다 — 켜기 창으로 새 휴대폰에서 켠다
expect(wrapper.find('[data-two-factor-replace]').exists()).toBe(false)
const warning = wrapper.get('[data-two-factor-warning]')
expect(warning.text()).toContain('켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다')
expect(warning.get('a').text()).toBe('앱 비밀번호')
@@ -120,12 +122,18 @@ describe('AccountTwoFactorPage', () => {
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
})
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [휴대폰 바꾸기] [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
// [휴대폰 바꾸기]는 [2단계 인증 끄기] 바로 앞 (16단계)
const actions = wrapper.get('.two-factor__actions').element.children
expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '2단계 인증 끄기'])
expect(wrapper.get('[data-two-factor-notes]').text()).toContain(
'휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.',
)
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
expect(wrapper.get('[data-two-factor-notes]').text()).toContain('메일 프로그램은 앱 비밀번호로 연결합니다.')
@@ -661,14 +669,19 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
})
it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 휴대폰 바꾸기 안내는 관리자에게 해제 요청', async () => {
it('켜짐 — [2단계 인증 끄기] 대신 「회사 정책에 따라 끌 수 없습니다」, 그 앞에 [휴대폰 바꾸기] · 안내 첫 줄은 보통 직원과 같은 글 (16단계)', async () => {
const wrapper = await mountWith({ ...ON, isRequired: true, deadline: null })
expect(wrapper.find('[data-two-factor-disable]').exists()).toBe(false)
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
expect(wrapper.get('[data-two-factor-replace]').text()).toBe('휴대폰 바꾸기')
// [휴대폰 바꾸기]가 끌 수 없다는 글 앞
const actions = wrapper.get('.two-factor__actions').element.children
expect(Array.from(actions).map((child) => child.textContent?.trim())).toEqual(['휴대폰 바꾸기', '회사 정책에 따라 끌 수 없습니다'])
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
const notes = wrapper.get('[data-two-factor-notes]').text()
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.')
expect(notes).toContain('휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.')
expect(notes).not.toContain('관리자에게 2단계 인증을 풀어 달라고 요청해 주세요')
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
})
})
@@ -1,18 +1,22 @@
<script setup lang="ts">
// 설정 › 계정 › 2단계 인증 (디자인 원본 SettingsTwoFactor · README "12단계 › 설정 › 계정 › 2단계 인증" · 계약 5절 "2단계 인증")
// — /settings/account/two-factor · 탭 제목 "2단계 인증"
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] / 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [2단계 인증 끄기]
// 머리 + 설명 → 상태 상자(글자로만 — 뱃지 · 스위치 없음): 「꺼짐」(회색 굵게) + [2단계 인증 켜기] /
// 「켜짐 — 2026년 10월 31일부터」(초록 굵게 — 켠 때를 모르면 「켜짐」) + [휴대폰 바꾸기](테두리 44 — 16단계) [2단계 인증 끄기]
// - 꺼져 있을 때: 갈색 경고(켜면 메일 프로그램은 앱 비밀번호로 — 앱 비밀번호 링크) + 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자)
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」)
// - 켜져 있을 때: 안내 상자(휴대폰 바꾸기 — [휴대폰 바꾸기]로 · 잃어버림 · 메일 프로그램은 앱 비밀번호로)
// - 켜기 창(TwoFactorEnableDialog — 620) · 끄기 창(TwoFactorDisableDialog — 400, 처음 초점 「취소」) ·
// 휴대폰 바꾸기 창(TwoFactorReplaceDialog — 620, 두 걸음 — 16단계 · 디자인 원본 Settings16 5~8)
// - 켠 직후: 머리 아래 안내 상자 "2단계 인증을 켰습니다" + [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다(화면 상태). 초점은 그 상자로
// - 끈 뒤: 알림 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」, 초점은 [2단계 인증 켜기]로
// - 휴대폰을 바꾼 뒤: 알림(창이 띄운다) — 화면은 그대로(켠 때도 그대로), 초점은 [휴대폰 바꾸기]로
// - 들어올 때마다 상태를 새로 받는다(two-factor.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 세션이 바뀌면(다른 기기의 [다른 곳 모두 로그아웃]으로 이 세션이 끊김 · 로그아웃 · 만료) 창을 모두 닫는다 — 세션 만료 창 뒤에 QR · 키 · 입력한 비밀번호가 남지 않게
// (창은 닫힐 때 키 · 칸을 지운다)
// 회사가 2단계 인증을 필수로 정한 직원(13단계 — GET /two-factor 의 isRequired · deadline, 디자인 README "13단계 › 설정 › 2단계 인증 — 필수인 직원"):
// - 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패) "11월 30일까지 2단계 인증을 켜 주세요" (날짜는 시작일 전날 — 올해가 아니면 해를 붙인다)
// - 켜짐 — [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 VAL_001), 아래 안내의 첫 줄은 관리자에게 해제를 요청하는 글
// - 켜짐 — [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 VAL_001). [휴대폰 바꾸기]는 그 앞에 그대로 —
// 바꾸는 동안 2단계 인증이 꺼지지 않아 정책과 어긋나지 않는다(16단계). 아래 안내의 첫 줄도 보통 직원과 같은 글
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -21,9 +25,10 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import TwoFactorDisableDialog from '@/components/settings/TwoFactorDisableDialog.vue'
import TwoFactorEnableDialog from '@/components/settings/TwoFactorEnableDialog.vue'
import TwoFactorReplaceDialog from '@/components/settings/TwoFactorReplaceDialog.vue'
import { useClock } from '@/composables/useClock'
import { useToast } from '@/composables/useToast'
import { TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
import { TWO_FACTOR_REPLACE_TEXT, TWO_FACTOR_TEXT } from '@/shared/constants/account-security'
import { TWO_FACTOR_GRACE_TEXT } from '@/shared/constants/session-restriction'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { twoFactorStateLabel } from '@/shared/utils/account-security'
@@ -37,9 +42,11 @@ const toast = useToast()
const clock = useClock()
const enableRef = ref<HTMLButtonElement | null>(null)
const replaceRef = ref<HTMLButtonElement | null>(null)
const noticeRef = ref<HTMLElement | null>(null)
const enableOpen = ref<boolean>(false)
const disableOpen = ref<boolean>(false)
const replaceOpen = ref<boolean>(false)
/** 이 화면에서 방금 켰다 — 머리 아래 안내 상자 (떠나면 사라진다) */
const justEnabled = ref<boolean>(false)
@@ -73,12 +80,20 @@ async function onDisabled() {
enableRef.value?.focus()
}
/** 휴대폰을 바꿨다 — 알림은 창이 띄웠다. 화면은 그대로, 초점은 [휴대폰 바꾸기]로 */
async function onReplaced() {
replaceOpen.value = false
await nextTick()
replaceRef.value?.focus()
}
// 세션이 바뀌면 창을 모두 닫는다 (켠 직후 안내도 내린다)
watch(
() => authStore.generation,
() => {
enableOpen.value = false
disableOpen.value = false
replaceOpen.value = false
justEnabled.value = false
},
)
@@ -169,20 +184,34 @@ void twoFactorStore.load()
/>
<span>{{ TWO_FACTOR_TEXT.enable }}</span>
</button>
<span
v-else-if="required"
class="two-factor__locked"
data-two-factor-locked
>{{ TWO_FACTOR_GRACE_TEXT.cannotDisable }}</span>
<button
<div
v-else
type="button"
class="two-factor__disable"
data-two-factor-disable
@click="disableOpen = true"
class="two-factor__actions"
>
{{ TWO_FACTOR_TEXT.disable }}
</button>
<button
ref="replaceRef"
type="button"
class="two-factor__replace"
data-two-factor-replace
@click="replaceOpen = true"
>
{{ TWO_FACTOR_REPLACE_TEXT.open }}
</button>
<span
v-if="required"
class="two-factor__locked"
data-two-factor-locked
>{{ TWO_FACTOR_GRACE_TEXT.cannotDisable }}</span>
<button
v-else
type="button"
class="two-factor__disable"
data-two-factor-disable
@click="disableOpen = true"
>
{{ TWO_FACTOR_TEXT.disable }}
</button>
</div>
</div>
<template v-if="!enabled">
@@ -239,7 +268,7 @@ void twoFactorStore.load()
>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
<span>{{ required ? TWO_FACTOR_GRACE_TEXT.noteChangePhone : TWO_FACTOR_TEXT.noteChangePhone }}</span>
<span>{{ TWO_FACTOR_TEXT.noteChangePhone }}</span>
</p>
<p class="two-factor__note">
<span aria-hidden="true">·</span>
@@ -275,6 +304,12 @@ void twoFactorStore.load()
@close="disableOpen = false"
@disabled="onDisabled"
/>
<TwoFactorReplaceDialog
:open="replaceOpen"
:email="email"
@close="replaceOpen = false"
@replaced="onReplaced"
/>
</SettingsPageFrame>
</template>
@@ -395,6 +430,29 @@ void twoFactorStore.load()
background: var(--color-brand-strong);
}
/* 켜져 있을 때의 단추 묶음 — [휴대폰 바꾸기] [2단계 인증 끄기](또는 끌 수 없다는 글). 좁으면 줄을 바꾼다 */
.two-factor__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-12);
}
/* 휴대폰 바꾸기 — 흰 바탕 · 테두리 (아이콘 없음 — 끄기 단추와 같은 꼴, 16단계) */
.two-factor__replace {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.two-factor__replace:hover {
background: var(--color-surface-muted);
}
/* 끄기 — 흰 바탕 · 빨간 글 · 테두리 */
.two-factor__disable {
min-height: var(--size-touch);
@@ -3,7 +3,8 @@ 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 type { SignatureImage } from '@/shared/types/mail'
import type { MailSignature, MailSignatureDefaults, MailSignaturePayload } from '@/shared/types/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useToastStore } from '@/stores/toast.store'
@@ -21,62 +22,95 @@ import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import MailSignaturePage from './MailSignaturePage.vue'
// 설정 › 메일 › 서명 (디자인 원본 Settings · 계약 2절 "서명")
// 설정 › 메일 › 서명 — 여러 서명 (디자인 원본 Signature16 · 계약 2절 "서명", 16단계)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
/** 서명 화면(지연 로딩한 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 SAVED_AT = '2026-11-10T06: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 signature(id: string, name: string, html: string): MailSignature {
return { id, name, bodyHtml: html, body: name, images: [], updatedAt: '2026-11-10T05:00:00Z' }
}
/** 보드의 예시 — 사내(답장과 전달 기본) · 대외(새 메일 기본) · 영문 */
const IN = signature('in', '사내', '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">내선 1234</p>')
const OUT = signature('out', '대외', '<p style="margin: 0"><strong>김아라</strong> · 인프라팀</p><p style="margin: 0">ARA SYSTEM</p>')
const EN = signature('en', '영문', '<p style="margin: 0"><strong>Ara Kim</strong> · Infrastructure Team</p>')
const DEFAULTS: MailSignatureDefaults = { newMessageId: 'out', replyId: 'in' }
function imageOf(id: string, width: number): SignatureImage {
return { id, type: 'image/png', size: 1000, width, height: 60 }
}
interface Server {
signature?: MailSignature | MockResult
interface ServerOptions {
list?: MailSignature[]
defaults?: MailSignatureDefaults
/** 목록 받기 응답을 바꾼다 (실패 등) — undefined 면 지금 목록 */
listReply?: () => MockResult | undefined
/** 그림 올리기 응답 — 비면 NEW_IMAGE_ID · 폭 480 */
uploads?: MockResult[]
save?: (request: MockRequest) => MockResult | Promise<MockResult>
/** 서명 만들기 · 고치기 응답 — undefined 면 서버처럼 저장한다 */
save?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
/** 기본 둘 저장 응답 — undefined 면 서버처럼 저장한다 */
saveDefaults?: (request: MockRequest) => MockResult | undefined
}
function server(options: Server = {}): MockHandler {
/** 서명 서버 — 목록 · 기본 둘을 기억한다 (만들기 · 고치기 · 지우기가 다음 받기에 보인다) */
function server(options: ServerOptions = {}): MockHandler {
let list = [...(options.list ?? [IN, OUT, EN])]
let defaults = { ...(options.defaults ?? DEFAULTS) }
const uploads = [...(options.uploads ?? [])]
return (request) => {
let created = 0
const page = () => ok({ items: list, pageInfo: { hasNext: false, nextCursor: null, total: list.length } })
return async (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 === 'GET' && url === '/mail-signatures') return options.listReply?.() ?? page()
if (method === 'GET' && url === '/mail-signature-defaults') return ok(defaults)
if (method === 'PUT' && url === '/mail-signature-defaults') {
const reply = options.saveDefaults?.(request)
if (reply) return reply
defaults = { ...(request.data as MailSignatureDefaults) }
return ok(defaults)
}
if (method === 'POST' && url === '/mail-signature/images') {
return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
if (method === 'POST' && url === '/mail-signature/images') return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
if ((method === 'POST' && url === '/mail-signatures') || (method === 'PUT' && url.startsWith('/mail-signatures/'))) {
const reply = await options.save?.(request)
if (reply) return reply
const body = request.data as MailSignaturePayload
if (method === 'POST') {
created += 1
const saved: MailSignature = { id: `new-${created}`, ...body, images: [], updatedAt: SAVED_AT }
if (list.length === 0) defaults = { newMessageId: saved.id, replyId: saved.id }
list = [...list, saved]
return ok(saved, 201)
}
const id = url.slice('/mail-signatures/'.length)
const saved: MailSignature = { id, ...body, images: [], updatedAt: SAVED_AT }
list = list.map((item) => (item.id === id ? saved : item))
return ok(saved)
}
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 })
if (method === 'DELETE' && url.startsWith('/mail-signatures/')) {
const id = url.slice('/mail-signatures/'.length)
list = list.filter((item) => item.id !== id)
defaults = {
newMessageId: defaults.newMessageId === id ? null : defaults.newMessageId,
replyId: defaults.replyId === id ? null : defaults.replyId,
}
return { status: 204 }
}
return ok(null)
}
}
describe('MailSignaturePage', () => {
describe('MailSignaturePage — 여러 서명 (16단계)', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/signature/SignatureForm.vue')
await import('@/components/mail/editor/signature/SignatureSettings.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
@@ -89,11 +123,13 @@ describe('MailSignaturePage', () => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
document.body.innerHTML = ''
useToastStore().clear()
restoreApiAdapter()
})
async function mountPage(handler: MockHandler, waitForEditor = true) {
/** 서명 화면을 띄운다 — waitFor 가 나타날 때까지 기다린다 (null 이면 기다리지 않는다) */
async function mountPage(handler: MockHandler, waitFor: string | null = '#signature-body') {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push('/settings/mail/signature')
@@ -105,10 +141,10 @@ describe('MailSignaturePage', () => {
})
mounted.push(wrapper)
await flushPromises()
if (waitForEditor) {
if (waitFor) {
await vi.waitFor(
() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
if (!wrapper.find(waitFor).exists()) throw new Error('서명 화면을 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
@@ -117,24 +153,66 @@ describe('MailSignaturePage', () => {
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')
/** 목록 줄 — 이름과 아래 회색 글 (사이에 빈칸 하나) */
function rows(wrapper: VueWrapper): string[] {
return wrapper.findAll('[data-signature-row]').map((row) =>
row
.findAll('span')
.map((part) => part.text())
.join(' '),
)
}
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')
function currentRow(wrapper: VueWrapper): string | undefined {
return wrapper.find('[data-signature-row][aria-current="true"]').attributes('data-signature-row')
}
it('저장한 서명으로 칸을 채우고, 넣을 메일 · 미리 보기를 보인다', async () => {
function dialogOpen(wrapper: VueWrapper, id: string): boolean {
return wrapper.find(`#${id}`).exists() && wrapper.get(`#${id}`).attributes('open') !== undefined
}
/** 서명 끝에 한 줄을 더 쓴다 */
async function typeLine(wrapper: VueWrapper, text: string) {
const editor = editorOf(wrapper.element)
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
await flushPromises()
}
/** 다른 서명을 열고 새 편집기가 붙을 때까지 기다린다 */
async function pick(wrapper: VueWrapper, id: string) {
await wrapper.get(`[data-signature-row="${id}"]`).trigger('click')
await flushPromises()
}
const saveRequests = (requests: MockRequest[]) =>
requests.filter((request) => request.url.startsWith('/mail-signatures') && request.method !== 'GET')
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.text()).toContain(
'보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.',
)
// 기본 둘 — 서명(만든 차례) · 넣지 않음
expect(wrapper.get('legend').text()).toBe('메일에 넣을 서명')
const options = wrapper.findAll('#signature-default-new option').map((option) => option.text())
expect(options).toEqual(['사내', '대외', '영문', '넣지 않음'])
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('out')
expect((wrapper.get('#signature-default-reply').element as HTMLSelectElement).value).toBe('in')
expect(wrapper.get('#signature-defaults-hint').text()).toBe(
'메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.',
)
// 목록 — 이름 아래 그 서명을 기본으로 넣는 메일
expect(wrapper.get('#signature-list-label').text()).toBe('내 서명')
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', '영문'])
expect(currentRow(wrapper)).toBe('out')
expect(wrapper.get('#signature-count').text()).toBe('서명 3개를 만들었고, 10개까지 만들 수 있습니다.')
// 고른 서명 — 이름 · 서명 칸 · 지우기
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('대외')
expect(wrapper.get('#signature-name').attributes('maxlength')).toBe('30')
expect(wrapper.get('#signature-name-hint').text()).toBe('메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.')
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀ARA SYSTEM')
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('서명 서식')
expect(wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))).toEqual([
'굵게',
@@ -144,25 +222,33 @@ describe('MailSignaturePage', () => {
'링크',
'그림 넣기',
])
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('#signature-delete').text()).toBe('이 서명 지우기')
expect(wrapper.find('#signature-cancel').exists()).toBe(false)
// 미리 보기 — 고른 서명이 들어갈 자리
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀ARA SYSTEM')
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('새 메일은 본문 끝에 서명을 넣습니다.')
await wrapper.get('[data-mode="reply"]').trigger('click')
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
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) {
server({
listReply: () => {
if (fails === 0) return undefined
fails -= 1
return fail(503, 'MAIL_001')
}
return server()(request)
},
false,
},
}),
null,
)
await vi.waitFor(() => {
if (!wrapper.find('[role="alert"]').exists()) throw new Error('받는 중')
})
expect(wrapper.get('[role="alert"]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
await wrapper.get('[role="alert"] button').trigger('click')
await vi.waitFor(
@@ -171,162 +257,357 @@ describe('MailSignaturePage', () => {
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
expect(requests.filter((request) => request.url === '/mail-signature')).toHaveLength(2)
expect(requests.filter((request) => request.url === '/mail-signatures')).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)
describe('서명이 없을 때 · 새 서명', () => {
it('안내와 [새 서명]만 — 메일에 넣을 서명 칸과 목록은 없다', async () => {
const { wrapper } = await mountPage(
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
'#signature-empty-new',
)
expect(wrapper.text()).toContain('아직 서명이 없습니다')
expect(wrapper.text()).toContain(
'보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. 사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.',
)
expect(wrapper.find('#signature-default-new').exists()).toBe(false)
expect(wrapper.find('[data-signature-row]').exists()).toBe(false)
})
it('[새 서명] — 목록 끝에 "저장하지 않음" 줄 · 빈 이름 칸에 초점, [취소]하면 돌아간다', async () => {
const { wrapper } = await mountPage(
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
'#signature-empty-new',
)
await wrapper.get('#signature-empty-new').trigger('click')
await vi.waitFor(() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 여는 중')
})
await flushPromises()
expect(rows(wrapper)).toEqual(['새 서명 저장하지 않음'])
expect(currentRow(wrapper)).toBe('new')
expect(document.activeElement?.id).toBe('signature-name')
expect(wrapper.get('#signature-name').attributes('placeholder')).toBe('예: 사내, 대외, 영문')
expect(wrapper.find('#signature-delete').exists()).toBe(false)
await wrapper.get('#signature-cancel').trigger('click')
await flushPromises()
expect(wrapper.find('[data-signature-row]').exists()).toBe(false)
expect(document.activeElement?.id).toBe('signature-empty-new')
})
it('첫 서명을 저장하면 두 곳의 기본이 그 서명 — "다음에 메일을 쓸 때부터 들어갑니다."', async () => {
const { wrapper, requests } = await mountPage(
server({ list: [], defaults: { newMessageId: null, replyId: null } }),
'#signature-empty-new',
)
await wrapper.get('#signature-empty-new').trigger('click')
await vi.waitFor(() => {
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 여는 중')
})
await wrapper.get('#signature-name').setValue(' 기본 서명 ')
// 빈 서명 칸의 첫 줄에 쓴다
editorOf(wrapper.element).commands.insertContent('김아라 · 인프라팀')
await flushPromises()
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequests(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /mail-signatures'])
expect(saveRequests(requests)[0]?.data).toEqual({
name: '기본 서명',
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
body: '김아라 · 인프라팀',
})
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.')
expect(rows(wrapper)).toEqual(['기본 서명 새 메일, 답장과 전달'])
expect(currentRow(wrapper)).toBe('new-1')
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('기본 서명')
// 같은 칸 그대로 — 이제 [이 서명 지우기] · 메일에 넣을 서명 칸이 보인다
expect(wrapper.find('#signature-delete').exists()).toBe(true)
expect((wrapper.get('#signature-default-reply').element as HTMLSelectElement).value).toBe('new-1')
// 다시 저장하면 고치기(PUT)
await typeLine(wrapper, 'ARA SYSTEM')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(saveRequests(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
'POST /mail-signatures',
'PUT /mail-signatures/new-1',
])
})
it('두 번째 서명은 기본을 바꾸지 않는다 — "저장했습니다."만', async () => {
const { wrapper } = await mountPage(server())
await wrapper.get('#signature-new').trigger('click')
await flushPromises()
await wrapper.get('#signature-name').setValue('견적용')
await typeLine(wrapper, '견적 담당 김아라')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다.')
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', '영문', '견적용'])
expect(useMailSignatureStore().defaults).toEqual(DEFAULTS)
})
it('10개면 [새 서명]은 누를 수 없다 — 초점은 받고 까닭이 목록 아래 글', async () => {
const ten = Array.from({ length: 10 }, (_unused, index) => signature(`s${index}`, `서명 ${index + 1}`, '<p>글</p>'))
const { wrapper } = await mountPage(server({ list: ten, defaults: { newMessageId: 's0', replyId: null } }))
const button = wrapper.get('#signature-new')
expect(button.attributes('aria-disabled')).toBe('true')
expect(button.attributes('aria-describedby')).toBe('signature-count')
expect(wrapper.get('#signature-count').text()).toBe('서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.')
await button.trigger('click')
await flushPromises()
expect(wrapper.find('[data-signature-row="new"]').exists()).toBe(false)
})
})
describe('이름 검사 — 1~30자 · 같은 이름(영문 대소문자 무시)', () => {
it('비었거나 같은 이름이면 칸 아래 빨간 글 · 이름 칸에 초점 (알림 · 요청 없음), 고치면 안내로', async () => {
const ENGLISH = signature('english', 'English', '<p>Ara Kim</p>')
const { wrapper, requests } = await mountPage(server({ list: [IN, OUT, ENGLISH] }))
const input = wrapper.get('#signature-name')
await input.setValue(' ')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-name-error').text()).toBe('이름을 넣어 주세요.')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBe('signature-name-error')
expect(document.activeElement?.id).toBe('signature-name')
await input.setValue('사내')
expect(wrapper.find('#signature-name-error').exists()).toBe(false)
expect(wrapper.find('#signature-name-hint').exists()).toBe(true)
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
// 영문은 대소문자를 가리지 않는다
await input.setValue(' ENGLISH ')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
expect(saveRequests(requests)).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('그사이 다른 창에서 같은 이름을 만들었으면 서버의 거절(VAL_001)도 같은 빨간 글 — 아니면 서명 칸 아래에 사전 문구', async () => {
let otherWindow = false
const { wrapper, requests } = await mountPage(
server({
listReply: () =>
otherWindow
? ok({ items: [IN, OUT, EN, signature('x', '행사 안내', '<p>글</p>')], pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
: undefined,
save: () => fail(400, 'VAL_001'),
}),
)
await wrapper.get('#signature-name').setValue('행사 안내')
otherWindow = true
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-name-error').text()).toBe('같은 이름의 서명이 있습니다.')
expect(requests.filter((request) => request.url === '/mail-signatures' && request.method === 'GET')).toHaveLength(2)
await wrapper.get('#signature-name').setValue('행사 안내 2')
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
expect(wrapper.find('#signature-name-error').exists()).toBe(false)
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([])
})
})
it('기본이 아닌 서명을 고쳐 저장 — PUT 이름 · 본문, "저장했습니다."', async () => {
const { wrapper, requests } = await mountPage(server())
await pick(wrapper, 'en')
expect((wrapper.get('#signature-name').element as HTMLInputElement).value).toBe('영문')
await wrapper.get('#signature-name').setValue('English')
await typeLine(wrapper, 'ARA SYSTEM')
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)
const put = saveRequests(requests)[0]
expect(put?.url).toBe('/mail-signatures/en')
expect(put?.data).toMatchObject({ name: 'English', body: 'Ara Kim · Infrastructure Team\nARA SYSTEM' })
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다.')
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '대외 새 메일', 'English'])
await wrapper.get('#signature-for-new').setValue(false)
// 무엇이든 다시 바꾸면 내린다
await wrapper.get('#signature-name').setValue('English 2')
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)
describe('메일에 넣을 서명 — 고르는 대로 바로 저장', () => {
it('고르면 PUT 기본 둘 → 고른 칸 오른쪽에 "저장했습니다", 목록의 회색 글도 바뀐다', async () => {
const { wrapper, requests } = await mountPage(server())
await wrapper.get('#signature-default-reply').setValue('out')
await flushPromises()
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
const put = requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature-defaults')
expect(put?.data).toEqual({ newMessageId: 'out', replyId: 'out' })
expect(wrapper.get('[data-default-saved="reply"]').text()).toBe('저장했습니다')
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('')
expect(rows(wrapper)).toEqual(['사내', '대외 새 메일, 답장과 전달', '영문'])
expect(saveRequest(requests)?.data).toEqual({ bodyHtml: null, body: null, isForNew: true, isForReply: true })
// 다시 고르면 내리고, 넣지 않음은 null
await wrapper.get('#signature-default-new').setValue('')
expect(wrapper.get('[data-default-saved="reply"]').text()).toBe('')
await flushPromises()
expect(requests.filter((request) => request.url === '/mail-signature-defaults' && request.method === 'PUT').at(-1)?.data).toEqual({
newMessageId: null,
replyId: 'out',
})
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('저장했습니다')
})
it('저장하지 못하면 고르기 전 값으로 되돌리고 오류 알림', async () => {
const { wrapper } = await mountPage(server({ saveDefaults: () => fail(503, 'MAIL_001') }))
await wrapper.get('#signature-default-new').setValue('en')
await flushPromises()
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('out')
expect(wrapper.get('[data-default-saved="new"]').text()).toBe('')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
})
it('서버가 서명을 거절하면(VAL_001) 알림 없이 서명 칸 아래에 사전 문구', async () => {
const { wrapper } = await mountPage(server({ save: () => fail(400, 'VAL_001') }))
describe('다른 서명을 고를 때 · [새 서명] — 저장하지 않은 것이 있으면 묻는다', () => {
it('바꾼 것이 없으면 묻지 않고 연다', async () => {
const { wrapper } = await mountPage(server())
await pick(wrapper, 'in')
expect(dialogOpen(wrapper, 'signature-switch-dialog')).toBe(false)
expect(currentRow(wrapper)).toBe('in')
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀내선 1234')
})
await wrapper.get('#signature-save').trigger('click')
await flushPromises()
it('[계속 고치기]는 그대로(처음 초점), [바꾼 내용 버리기]는 누른 서명을 연다', async () => {
const { wrapper } = await mountPage(server())
await typeLine(wrapper, '덧붙인 줄')
await pick(wrapper, 'in')
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([])
expect(dialogOpen(wrapper, 'signature-switch-dialog')).toBe(true)
expect(wrapper.get('#signature-switch-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#signature-switch-title').text()).toBe('저장하지 않은 서명이 있습니다')
expect(wrapper.get('#signature-switch-message').text()).toBe('다른 서명을 고르면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.')
expect(wrapper.get('#signature-switch-stay').text()).toBe('계속 고치기')
expect(wrapper.get('#signature-switch-confirm').text()).toBe('바꾼 내용 버리기')
expect(document.activeElement?.id).toBe('signature-switch-stay')
await wrapper.get('#signature-switch-stay').trigger('click')
await flushPromises()
expect(currentRow(wrapper)).toBe('out')
expect(wrapper.get('#signature-body').text()).toContain('덧붙인 줄')
await wrapper.get('#signature-new').trigger('click')
await flushPromises()
expect(wrapper.get('#signature-switch-message').text()).toBe('새 서명을 만들면 ‘대외’ 서명에서 바꾼 내용이 사라집니다.')
await wrapper.get('#signature-switch-stay').trigger('click')
await flushPromises()
await pick(wrapper, 'in')
await wrapper.get('#signature-switch-confirm').trigger('click')
await flushPromises()
expect(currentRow(wrapper)).toBe('in')
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀내선 1234')
})
})
describe('서명 지우기', () => {
it.each([
['out', '‘대외’ 서명을 지울까요?', '새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.'],
['in', '‘사내’ 서명을 지울까요?', '답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.'],
['en', '‘영문’ 서명을 지울까요?', '지운 서명은 되돌릴 수 없습니다.'],
])('묻는 글은 기본으로 넣던 곳마다 — %s (처음 초점 [취소])', async (id, title, message) => {
const { wrapper } = await mountPage(server())
await pick(wrapper, id)
await wrapper.get('#signature-delete').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper, 'signature-delete-dialog')).toBe(true)
expect(wrapper.get('#signature-delete-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#signature-delete-title').text()).toBe(title)
expect(wrapper.get('#signature-delete-message').text()).toBe(message)
expect(document.activeElement?.id).toBe('signature-delete-cancel')
await wrapper.get('#signature-delete-cancel').trigger('click')
await flushPromises()
expect(dialogOpen(wrapper, 'signature-delete-dialog')).toBe(false)
})
it('지우면 알림 · 목록의 첫 서명을 열고, 그 서명을 기본으로 넣던 곳은 넣지 않음', async () => {
const { wrapper, requests } = await mountPage(server())
await wrapper.get('#signature-delete').trigger('click')
await flushPromises()
await wrapper.get('#signature-delete-confirm').trigger('click')
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/mail-signatures/out')).toBe(true)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['‘대외’ 서명을 지웠습니다.'])
expect(rows(wrapper)).toEqual(['사내 답장과 전달', '영문'])
expect(currentRow(wrapper)).toBe('in')
expect((wrapper.get('#signature-default-new').element as HTMLSelectElement).value).toBe('')
})
it('마지막 서명을 지우면 서명이 없을 때의 화면', async () => {
const { wrapper } = await mountPage(server({ list: [IN], defaults: { newMessageId: 'in', replyId: 'in' } }))
await wrapper.get('#signature-delete').trigger('click')
await flushPromises()
await wrapper.get('#signature-delete-confirm').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('아직 서명이 없습니다')
expect(wrapper.find('#signature-default-new').exists()).toBe(false)
expect(document.activeElement?.id).toBe('signature-empty-new')
})
})
describe('서명 그림', () => {
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
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')
}
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)
expect(requests.find((request) => request.url === '/mail-signature/images')?.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('작게')
expect(wrapper.get('.signature-editor__sizes [role="group"]').attributes('aria-labelledby')).toBe(
wrapper.get('.signature-editor__sizes-label').attributes('id'),
)
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()])
it('형식이 맞지 않으면 올리기 전에 막고 서명 칸 아래에 사전 문구 · 한 사람 20장 넘김(VAL_001)은 알림으로', async () => {
const { wrapper, requests } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
await choose(wrapper, [new File(['x'], '로고.webp', { type: 'image/webp' })])
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단계 › 서명")', () => {
describe('다른 화면으로 갈 때 (나가기 확인)', () => {
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
@@ -334,42 +615,22 @@ describe('MailSignaturePage', () => {
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 () => {
it('바꾸고 떠나면 묻는다 — [계속 고치기]는 머물고 [나가기]는 떠난다', 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(dialogOpen(wrapper, 'signature-leave-dialog')).toBe(true)
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()
@@ -378,19 +639,13 @@ describe('MailSignaturePage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('불러온(저장한 뒤에는 저장한) 서명과 같으면 묻지 않는다 — 바꿨다 되돌린 것 · 저장한 뒤, 창 닫기 확인도 그동안만', async () => {
it('메일에 넣을 서명을 고른 것 · 저장한 뒤는 묻지 않는다', async () => {
const { wrapper, router } = await mountPage(server())
await wrapper.get('#signature-default-reply').setValue('en')
await flushPromises()
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()
@@ -398,7 +653,6 @@ describe('MailSignaturePage', () => {
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
@@ -408,7 +662,8 @@ describe('MailSignaturePage', () => {
server({
save: (request) =>
new Promise<MockResult>((resolve) => {
finishSave = () => resolve(ok({ ...(request.data as SaveSignaturePayload), images: [], updatedAt: SAVED_AT }))
finishSave = () =>
resolve(ok({ id: 'out', ...(request.data as MailSignaturePayload), images: [], updatedAt: SAVED_AT }))
}),
}),
)
@@ -419,57 +674,30 @@ describe('MailSignaturePage', () => {
await router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(dialogOpen(wrapper, 'signature-leave-dialog')).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')
expect(useMailSignatureStore().signatureById('out')?.body).toBe('김아라 · 인프라팀\nARA SYSTEM\nARA SYSTEM')
})
it('제한이 붙으면(13단계) 묻지 않는다 — 세션이 끝난 때처럼 창 닫기 확인도 뗀다', async () => {
it('제한이 붙거나(13단계) 세션이 끝나면 묻지 않는다', async () => {
const { wrapper, router } = await mountPage(server())
await typeLine(wrapper, 'ARA SYSTEM')
expect(blocksUnload()).toBe(true)
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
await flushPromises()
expect(blocksUnload()).toBe(false)
await router.push('/login')
await flushPromises()
expect(dialogOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.fullPath).toBe('/login')
})
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('답장과 전달에는 서명을 넣지 않습니다.')
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 › 메일 › 서명 (디자인 원본 Settings · README "2단계 › 서명") — /settings/mail/signature
// 들어올 때마다 서명을 새로 받는다(다른 창에서 고쳤을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
// 설정 › 메일 › 서명 (디자인 원본 Settings · Signature16 · README "2단계 › 서명" · "16단계 — 여러 서명") — /settings/mail/signature
// 들어올 때마다 서명 목록과 메일에 넣을 서명(기본 둘)을 새로 받는다(다른 창에서 고쳤을 수 있다).
// 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도". 받으면 서명 화면(SignatureSettings — 목록 · 고치는 칸 · 기본 둘)을 그린다.
// 서명 칸(TipTap)은 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도" (ComposeEditorLoadError · MAIL_EDITOR_RETRY).
import { defineAsyncComponent, provide, ref } from 'vue'
@@ -10,19 +11,19 @@ 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'),
/** 서명 화면 — TipTap 을 이 화면에서만 내려받는다 */
const SignatureSettings = defineAsyncComponent({
loader: () => import('@/components/mail/editor/signature/SignatureSettings.vue'),
errorComponent: ComposeEditorLoadError,
})
const signatureStore = useMailSignatureStore()
/** 서명 칸을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
const formKey = ref<number>(0)
/** 서명 화면을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
const settingsKey = ref<number>(0)
provide(MAIL_EDITOR_RETRY, () => {
formKey.value += 1
settingsKey.value += 1
})
function reload() {
@@ -57,10 +58,9 @@ reload()
leave-label=""
@retry="reload"
/>
<SignatureForm
v-else-if="signatureStore.status === 'ready' && signatureStore.signature"
:key="formKey"
:signature="signatureStore.signature"
<SignatureSettings
v-else-if="signatureStore.status === 'ready'"
:key="settingsKey"
/>
<div
v-else
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/core'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { toMailHtml } from '@/components/mail/editor/mail-serialize'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import type { MailVacation, MailVacationExternal, MailVacationState } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
@@ -19,20 +21,48 @@ import {
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import { WAIT_FOR_TIMEOUT_MS } from '@/test-utils/timeouts'
import MailVacationPage from './MailVacationPage.vue'
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 16단계 Settings16 1~4 · 계약 2절 "부재중 자동 응답")
// 16단계: 내용 칸이 서명 칸과 같은 서식 편집기(지연 로딩한 TipTap 청크)이고, 회사 밖에서 온 메일에 보낼 응답을 고른다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 양식(지연 로딩한 TipTap 청크)을 기다리는 시간 — 서명 설정 테스트와 같은 값 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답 · 쓴 글 없음 */
const SAME: MailVacationExternal = { mode: 'same', subject: null, textBody: null, htmlBody: null }
/** 설정한 적 없는 계정 — 꺼짐 · 빈 값 */
const NEVER: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
const NEVER: MailVacation = {
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: SAME,
}
/** 저장해 둔 서식 본문 — 편집기가 다시 내보내는 모양 그대로 (그대로 저장하면 같은 글이 간다) */
const SAVED_HTML = toMailHtml('<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>')
const SAVED: MailVacation = {
isEnabled: true,
fromDate: startOfDayIso('2026-10-30'),
toDate: endOfDayIso('2026-11-03'),
subject: '부재중 안내 — 11월 4일 복귀',
textBody: '안녕하세요, 김아라입니다.\n급한 일은 인프라팀으로 연락해 주세요.',
subject: '부재중 안내 (11월 4일 복귀)',
textBody: '안녕하세요, 김아라입니다.\n11월 4일에 돌아옵니다.',
htmlBody: SAVED_HTML,
external: SAME,
}
/** 회사 밖에 따로 쓴 응답 — 서식(굵게)이 있어 서식 본문도 함께 간다 */
const OUTSIDE_PARAGRAPHS = '<p>안녕하세요, ARA SYSTEM 김아라입니다.</p><p>급한 일은 <strong>인프라팀</strong>으로 보내 주세요.</p>'
const OUTSIDE_HTML = toMailHtml(OUTSIDE_PARAGRAPHS)
const SEPARATE: MailVacationExternal = {
mode: 'separate',
subject: '부재중 안내',
textBody: '안녕하세요, ARA SYSTEM 김아라입니다.\n급한 일은 인프라팀으로 보내 주세요.',
htmlBody: OUTSIDE_HTML,
}
interface Server {
@@ -40,11 +70,23 @@ interface Server {
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
/** 편집 영역(id)의 TipTap 편집기 — TipTap 이 view.dom.editor 에 붙여 둔다 */
function editorAt(id: string): Editor {
const dom = document.getElementById(id) as (HTMLElement & { editor?: Editor }) | null
if (!dom?.editor) throw new Error('편집기가 아직 없다')
return dom.editor
}
describe('MailVacationPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/settings/VacationForm.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
@@ -60,7 +102,19 @@ describe('MailVacationPage', () => {
document.body.innerHTML = ''
})
async function mountPage(server: Server = {}) {
async function waitForForm(wrapper: VueWrapper) {
await vi.waitFor(
() => {
if (!wrapper.find('#vacation-body').exists() || !wrapper.find('#vacation-external-body').exists()) {
throw new Error('부재중 양식을 불러오는 중')
}
},
{ timeout: WAIT_FOR_TIMEOUT_MS },
)
await flushPromises()
}
async function mountPage(server: Server = {}, waitForEditors = true) {
requests = installMockApi((request) => {
if (request.method === 'GET' && request.url === '/mail-vacation') {
const vacation = server.vacation ?? NEVER
@@ -80,75 +134,155 @@ describe('MailVacationPage', () => {
})
mounted.push(wrapper)
await flushPromises()
if (waitForEditors) await waitForForm(wrapper)
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailVacation)
const value = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).value
const checked = (wrapper: VueWrapper, selector: string) => (wrapper.get(selector).element as HTMLInputElement).checked
const separateSection = (wrapper: VueWrapper) => wrapper.get('[data-vacation-external-separate]').element as HTMLElement
async function save(wrapper: VueWrapper) {
await wrapper.get('#vacation-save').trigger('click')
await flushPromises()
}
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸, 끈 동안은 아래 칸이 흐리다', async () => {
/** 편집기의 내용을 바꾼다 — 사용자가 고친 것처럼 바뀜(update)을 알린다 */
async function setBody(id: string, html: string) {
editorAt(id).commands.setContent(html)
await flushPromises()
}
it('설정한 적 없는 계정 — 꺼짐 · 끌 때까지 · 빈 칸 · 사내와 같은 응답, 끈 동안은 아래 칸이 흐리다', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('부재중 자동 응답')
expect((wrapper.get('#vacation-enabled').element as HTMLInputElement).checked).toBe(false)
expect((wrapper.get('#vacation-period-until-off').element as HTMLInputElement).checked).toBe(true)
expect(checked(wrapper, '#vacation-enabled')).toBe(false)
expect(checked(wrapper, '#vacation-period-until-off')).toBe(true)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
// 내용 칸은 편집기 — 끈 동안은 읽기만
const body = wrapper.get('#vacation-body')
expect(body.attributes()).toMatchObject({ role: 'textbox', 'aria-disabled': 'true', contenteditable: 'false' })
expect(body.attributes('aria-labelledby')).toBe('vacation-body-label')
expect(body.attributes('aria-describedby')).toBe('vacation-body-hint')
expect(wrapper.get('#vacation-body-label').text()).toBe('내용')
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('0 / 600')
expect(wrapper.get('#vacation-body-hint').text()).toBe(
'굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다.',
)
expect(wrapper.get('[data-vacation-external]').attributes('disabled')).toBeDefined()
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
expect(separateSection(wrapper).style.display).toBe('none')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
})
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
it('내용 칸 툴바 — 굵게 · 기울임 · 밑줄 | 글자 색 · 링크, 그림 단추 없음 (서명 칸과 같은 편집기)', async () => {
const { wrapper } = await mountPage()
const toolbars = wrapper.findAll('[role="toolbar"]')
expect(toolbars.map((toolbar) => toolbar.attributes('aria-label'))).toEqual(['자동 응답 서식', '회사 밖 자동 응답 서식'])
expect(toolbars[0]?.findAll('button').map((button) => button.attributes('aria-label'))).toEqual([
'굵게',
'기울임',
'밑줄',
'글자 색: 기본',
'링크',
])
expect(toolbars[0]?.attributes('aria-controls')).toBe('vacation-body')
expect(toolbars[1]?.attributes('aria-controls')).toBe('vacation-external-body')
})
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 내용은 서식 그대로, 미리 보기는 보낸 사람 · 제목 · 서식 내용', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
expect(checked(wrapper, '#vacation-period-range')).toBe(true)
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
expect(wrapper.get('#vacation-period-hint').text()).toBe(VACATION_TEXT.rangeHint)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('true')
expect(wrapper.get('#vacation-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
'안녕하세요, 김아라입니다.',
'11월 4일에 돌아옵니다.',
])
expect(wrapper.get('#vacation-body strong').text()).toBe('11월 4일')
// 글자 수는 서식을 뺀 보이는 글
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe(`${SAVED.textBody.length} / 600`)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
expect(preview.get('[data-vacation-preview-body]').element.innerHTML).toBe(
'<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>',
)
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
})
it('16단계 전에 저장한 글만 있는 응답(서식 본문 · 회사 밖 칸이 없는 서버)은 줄마다 문단으로 열고, 그대로 저장하면 일곱 칸을 보낸다', async () => {
const old = {
isEnabled: true,
fromDate: null,
toDate: null,
subject: '부재중 안내',
textBody: '안녕하세요.\n\n복귀 후 답장 드리겠습니다.',
}
const { wrapper } = await mountPage({ vacation: old })
expect(wrapper.get('#vacation-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
'안녕하세요.',
'',
'복귀 후 답장 드리겠습니다.',
])
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
await save(wrapper)
expect(puts()).toEqual([
{
...old,
// 서식이 없으니 서식 본문은 보내지 않는다 — 글만 쓴 직원은 옛 판 그대로 (16단계 리뷰)
htmlBody: null,
external: SAME,
},
])
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
let reply: MailVacation | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get vacation() {
return reply
const { wrapper } = await mountPage(
{
get vacation() {
return reply
},
},
})
false,
)
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
reply = SAVED
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
await waitForForm(wrapper)
expect(wrapper.find('#vacation-enabled').exists()).toBe(true)
})
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
it('켜고 기간을 정해 저장 — 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로, 내용은 보이는 글 · 서식 본문을 함께 보내고 "…부터 …까지 자동으로 답합니다."', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('true')
await wrapper.get('#vacation-period-range').setValue(true)
// 날짜가 비었으면 시작을 오늘로 채운다
expect(value(wrapper, '#vacation-from')).toBe(formatDate(new Date()))
await wrapper.get('#vacation-from').setValue('2026-10-30')
await wrapper.get('#vacation-to').setValue('2026-11-03')
await wrapper.get('#vacation-subject').setValue('부재중 안내')
await wrapper.get('#vacation-body').setValue('휴가입니다.')
await setBody('vacation-body', '<p><strong>휴가</strong>입니다.</p>')
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('6 / 600')
await save(wrapper)
expect(puts()).toEqual([
@@ -158,6 +292,8 @@ describe('MailVacationPage', () => {
toDate: new Date(2026, 10, 4).toISOString(),
subject: '부재중 안내',
textBody: '휴가입니다.',
htmlBody: toMailHtml('<p><strong>휴가</strong>입니다.</p>'),
external: SAME,
},
])
expect(wrapper.get('#vacation-save-status').text()).toBe(
@@ -165,12 +301,70 @@ describe('MailVacationPage', () => {
)
expect(useMailVacationStore().vacation?.isEnabled).toBe(true)
// 다시 바꾸면 내린다
// 다시 바꾸면 내린다 — 제목 칸이든 내용 칸이든
await wrapper.get('#vacation-subject').setValue('부재중 안내 (수정)')
expect(wrapper.get('#vacation-save-status').text()).toBe('')
await save(wrapper)
expect(wrapper.get('#vacation-save-status').text()).not.toBe('')
await setBody('vacation-body', '<p>휴가입니다!</p>')
expect(wrapper.get('#vacation-save-status').text()).toBe('')
})
it('켜 둘 때는 제목 · 내용 · 기간을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다', async () => {
it('서식이 하나도 없으면 서식 본문을 보내지 않는다(htmlBody null) — 굵게 · 링크 · 글자 색이 하나라도 있으면 함께 보낸다 (16단계 리뷰)', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
const lastPut = () => puts().at(-1)
// 글만 — 문단 · 줄바꿈은 글자 본문에도 남아 서식이 아니다
await setBody('vacation-body', '<p>휴가입니다.</p><p>11월 4일에<br>돌아옵니다.</p>')
await save(wrapper)
expect(lastPut()).toMatchObject({ textBody: '휴가입니다.\n11월 4일에\n돌아옵니다.', htmlBody: null })
// 굵게 하나
await setBody('vacation-body', '<p><strong>휴가</strong>입니다.</p>')
await save(wrapper)
expect(lastPut()?.htmlBody).toBe(toMailHtml('<p><strong>휴가</strong>입니다.</p>'))
// 링크 · 글자 색도 서식이다
await setBody('vacation-body', '<p><a href="mailto:infra@arasystem.it.kr">인프라팀</a>으로 연락해 주세요.</p>')
await save(wrapper)
expect(lastPut()?.htmlBody).toContain('href="mailto:infra@arasystem.it.kr"')
await setBody('vacation-body', '<p><span style="color: #0E7C86">11월 4일</span>에 돌아옵니다.</p>')
await save(wrapper)
expect(lastPut()?.htmlBody).toContain('11월 4일')
expect(lastPut()?.htmlBody).not.toBeNull()
// 서식을 모두 지우면 다시 글만 — 꺼 두고 저장할 때도 같다
await setBody('vacation-body', '<p>11월 4일에 돌아옵니다.</p>')
await wrapper.get('#vacation-enabled').setValue(false)
await save(wrapper)
expect(lastPut()).toMatchObject({ isEnabled: false, textBody: '11월 4일에 돌아옵니다.', htmlBody: null })
})
it('서버가 MAIL_013(엔진 규칙 한도)으로 거절하면 알림 없이 [저장] 옆에 부재중 글 — 화면을 떠난 뒤면 그 글로 알림', async () => {
let reply: MockResult | Promise<MockResult> = fail(400, 'MAIL_013')
const { wrapper, router } = await mountPage({ vacation: SAVED, save: () => reply })
await save(wrapper)
expect(wrapper.get('#vacation-save-status').text()).toBe('자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요.')
expect(wrapper.get('#vacation-save-status').classes()).toContain('is-error')
expect(useToastStore().toasts).toEqual([])
let finish: (result: MockResult) => void = () => undefined
reply = new Promise<MockResult>((resolve) => {
finish = resolve
})
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await save(wrapper)
await router.push('/mail/inbox')
wrapper.unmount()
finish(fail(400, 'MAIL_013'))
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요.'],
])
})
it('켜 둘 때는 기간 · 제목 · 내용을 먼저 본다 — 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점 · 보내지 않는다, 고치기 시작한 칸만 내린다', async () => {
const { wrapper } = await mountPage()
await wrapper.get('#vacation-enabled').setValue(true)
await wrapper.get('#vacation-period-range').setValue(true)
@@ -183,46 +377,94 @@ describe('MailVacationPage', () => {
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-subject').attributes('aria-invalid')).toBe('true')
// 편집기 칸도 aria-invalid · 빨간 테두리 — 설명은 오류 → 안내 차례
expect(wrapper.get('#vacation-body').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#vacation-body').attributes('aria-describedby')).toBe('vacation-body-error vacation-body-hint')
expect(wrapper.get('.signature-editor').classes()).toContain('signature-editor--invalid')
expect(document.activeElement?.id).toBe('vacation-from')
expect(useToastStore().toasts).toEqual([])
// 고친 칸의 문구만 내린다
await wrapper.get('#vacation-subject').setValue('부재중 안내')
expect(wrapper.find('#vacation-subject-error').exists()).toBe(false)
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
await setBody('vacation-body', '<p>휴가입니다.</p>')
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
expect(wrapper.get('#vacation-body').attributes('aria-invalid')).toBeUndefined()
expect(wrapper.find('#vacation-period-error').exists()).toBe(true)
})
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 칸 아래에 사전 문구 — 줄이면 저장한다', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601) } })
it('내용 칸이 비었으면 내용 칸에 초점 — 사전 문구(VAL_001)', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: '', htmlBody: null } })
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('0 / 600')
expect(document.activeElement?.id).toBe('vacation-body')
})
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 제목은 사전 문구, 내용은 "내용은 600자까지 쓸 수 있습니다." + 빨간 글자 수 — 줄이면 저장한다', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601), htmlBody: null } })
const count = wrapper.get('[data-vacation-count="vacation"]')
expect(count.text()).toBe('601 / 600')
expect(count.classes()).toContain('is-over')
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe('내용은 600자까지 쓸 수 있습니다.')
expect(document.activeElement?.id).toBe('vacation-subject')
expect(useToastStore().toasts).toEqual([])
await wrapper.get('#vacation-subject').setValue('S'.repeat(150))
await wrapper.get('#vacation-body').setValue('B'.repeat(600))
await setBody('vacation-body', `<p>${'B'.repeat(600)}</p>`)
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('600 / 600')
expect(wrapper.get('[data-vacation-count="vacation"]').classes()).not.toContain('is-over')
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, subject: 'S'.repeat(150), textBody: 'B'.repeat(600) }])
expect(puts()).toEqual([
{
...SAVED,
subject: 'S'.repeat(150),
textBody: 'B'.repeat(600),
htmlBody: null,
},
])
})
it('글은 600자 안인데 서식이 많아 서식 본문이 10,000자를 넘으면 사전 문구(VAL_001) — 서명과 같은 한도', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
const colored = Array.from({ length: 200 }, () => '<span style="color: #B42318">가</span><span style="color: #067647">나</span>').join('')
await setBody('vacation-body', `<p>${colored}</p>`)
await save(wrapper)
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('400 / 600')
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
})
it('켜진 옛 긴 글(4단계에 저장한 내용 1,000자)도 끄는 것은 늘 저장된다 — 꺼 둘 때는 길이를 보지 않는다 (서버는 꺼 둔 채로 4단계 한도까지 받는다)', async () => {
const longBody = 'B'.repeat(1_000)
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody } })
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody, htmlBody: null } })
await wrapper.get('#vacation-enabled').setValue(false)
// 꺼 두면 넘은 글자 수도 빨갛지 않다 — 켤 때만 센다
expect(wrapper.get('[data-vacation-count="vacation"]').classes()).not.toContain('is-over')
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
expect(puts()).toEqual([
{ ...SAVED, isEnabled: false, textBody: longBody, htmlBody: null },
])
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
expect(useMailVacationStore().vacation?.isEnabled).toBe(false)
expect(value(wrapper, '#vacation-body')).toBe(longBody)
expect(wrapper.get('[data-vacation-count="vacation"]').text()).toBe('1,000 / 600')
})
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-enabled').setValue(false)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
expect(wrapper.get('#vacation-body').attributes('contenteditable')).toBe('false')
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
@@ -276,6 +518,220 @@ describe('MailVacationPage', () => {
])
})
describe('회사 밖에서 온 메일 (16단계 — 디자인 원본 Settings16 1 · 2 · 4)', () => {
const optionTexts = (wrapper: VueWrapper) =>
wrapper.get('[data-vacation-external]').findAll('.vacation-external__option').map((option) => option.text())
it('내용 칸과 [저장] 사이 — 내 주소의 도메인 설명 · 고르기 셋(사내와 같은 응답 · 보내지 않기 · 따로 쓰기), 처음 값은 사내와 같은 응답', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
const fieldset = wrapper.get('[data-vacation-external]')
expect(fieldset.get('legend').text()).toBe('회사 밖에서 온 메일')
expect(fieldset.get('#vacation-external-hint').text()).toBe('보낸 사람 주소가 회사 메일 주소(@arasystem.it.kr)가 아닌 메일입니다.')
expect(optionTexts(wrapper)).toEqual([
'사내와 같은 응답회사 안팎 모두 위의 제목과 내용으로 답합니다.',
'보내지 않기회사 밖에서 온 메일에는 자동으로 답하지 않습니다.',
'따로 쓰기회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.',
])
expect(wrapper.get('#vacation-external-none').attributes('aria-describedby')).toBe('vacation-external-none-hint')
expect(checked(wrapper, '#vacation-external-same')).toBe(true)
// 자리 — 내용 칸 뒤 · [저장] 앞
const position = (selector: string) => wrapper.get(selector).element
expect(position('#vacation-body').compareDocumentPosition(fieldset.element)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(fieldset.element.compareDocumentPosition(position('#vacation-save'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(separateSection(wrapper).style.display).toBe('none')
})
it('따로 쓰기 — 바로 아래에 회사 밖에 보낼 제목 · 내용 칸이 열리고, 저장하면 external 에 함께 보낸다', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-external-separate').setValue(true)
expect(separateSection(wrapper).style.display).toBe('')
expect(wrapper.get('label[for="vacation-external-subject"]').text()).toBe('회사 밖에 보낼 제목')
expect(wrapper.get('#vacation-external-body-label').text()).toBe('회사 밖에 보낼 내용')
// 회사 밖 내용 칸에는 아래 안내가 없다
expect(wrapper.find('#vacation-external-body-hint').exists()).toBe(false)
expect(wrapper.get('#vacation-external-body').attributes('aria-describedby')).toBeUndefined()
await wrapper.get('#vacation-external-subject').setValue(SEPARATE.subject ?? '')
await setBody('vacation-external-body', OUTSIDE_PARAGRAPHS)
expect(wrapper.get('[data-vacation-count="vacation-external"]').text()).toBe(`${SEPARATE.textBody?.length} / 600`)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, external: SEPARATE }])
})
it('회사 밖 칸도 서식이 없으면 서식 본문을 보내지 않는다 — 기울임 하나면 함께 보낸다 (16단계 리뷰)', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-external-separate').setValue(true)
await wrapper.get('#vacation-external-subject').setValue('부재중 안내')
await setBody('vacation-external-body', '<p>11월 20일까지 자리를 비웁니다.</p>')
await save(wrapper)
expect(puts().at(-1)?.external).toEqual({
mode: 'separate',
subject: '부재중 안내',
textBody: '11월 20일까지 자리를 비웁니다.',
htmlBody: null,
})
// 사내 칸은 그대로 서식(굵게)이 있어 서식 본문이 간다
expect(puts().at(-1)?.htmlBody).toBe(SAVED_HTML)
await setBody('vacation-external-body', '<p><em>11월 20일</em>까지 자리를 비웁니다.</p>')
await save(wrapper)
expect(puts().at(-1)?.external?.htmlBody).toBe(toMailHtml('<p><em>11월 20일</em>까지 자리를 비웁니다.</p>'))
})
it('저장해 둔 따로 쓰기로 연다 — 칸이 열려 있고 회사 밖 제목 · 서식 내용이 그대로', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
expect(checked(wrapper, '#vacation-external-separate')).toBe(true)
expect(separateSection(wrapper).style.display).toBe('')
expect(value(wrapper, '#vacation-external-subject')).toBe(SEPARATE.subject)
expect(wrapper.get('#vacation-external-body').findAll('p').map((paragraph) => paragraph.text())).toEqual([
'안녕하세요, ARA SYSTEM 김아라입니다.',
'급한 일은 인프라팀으로 보내 주세요.',
])
// 그대로 저장하면 같은 글이 간다
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, external: SEPARATE }])
})
it('다른 고르기로 바꾸면 두 칸을 닫지만 써 둔 글은 남겨 두고 함께 저장한다 — 다시 따로 쓰기를 고르면 그대로', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
await wrapper.get('#vacation-external-none').setValue(true)
expect(separateSection(wrapper).style.display).toBe('none')
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, external: { ...SEPARATE, mode: 'none' } }])
await wrapper.get('#vacation-external-separate').setValue(true)
expect(separateSection(wrapper).style.display).toBe('')
expect(value(wrapper, '#vacation-external-subject')).toBe(SEPARATE.subject)
expect(wrapper.get('#vacation-external-body').text()).toContain('급한 일은 인프라팀으로 보내 주세요.')
})
it('켤 때 회사 밖 두 칸은 따로 쓰기일 때만 본다 — 비었으면 사전 문구 · 600자를 넘으면 같은 전용 글, 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-external-separate').setValue(true)
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-external-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-external-subject').attributes('aria-invalid')).toBe('true')
expect(wrapper.get('#vacation-external-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-external-body').attributes('aria-describedby')).toBe('vacation-external-body-error')
expect(document.activeElement?.id).toBe('vacation-external-subject')
await wrapper.get('#vacation-external-subject').setValue('부재중 안내')
await setBody('vacation-external-body', `<p>${'가'.repeat(601)}</p>`)
const count = wrapper.get('[data-vacation-count="vacation-external"]')
expect(count.text()).toBe('601 / 600')
expect(count.classes()).toContain('is-over')
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-external-body-error').text()).toBe('내용은 600자까지 쓸 수 있습니다.')
expect(document.activeElement?.id).toBe('vacation-external-body')
// 보내지 않기로 바꾸면 회사 밖 두 칸의 문구를 내리고, 길어도 저장한다 (글은 남긴다)
await wrapper.get('#vacation-external-none').setValue(true)
expect(wrapper.find('#vacation-external-body-error').exists()).toBe(false)
await save(wrapper)
expect(puts()).toHaveLength(1)
expect(puts()[0]?.external).toMatchObject({ mode: 'none', subject: '부재중 안내', textBody: '가'.repeat(601) })
})
it('자동 응답을 끄면 이 칸도 다른 칸처럼 흐려진다 — 고르기 · 회사 밖 칸 모두', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
await wrapper.get('#vacation-enabled').setValue(false)
expect(wrapper.get('[data-vacation-external]').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-external-subject').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-external-body').attributes('contenteditable')).toBe('false')
})
})
describe('미리 보기 [사내 | 회사 밖] (16단계 — 디자인 원본 Settings16 3)', () => {
const preview = (wrapper: VueWrapper) => wrapper.get('[data-vacation-preview]')
const sides = (wrapper: VueWrapper) =>
preview(wrapper)
.findAll('[data-preview-side]')
.map((button) => [button.text(), button.attributes('aria-pressed')])
async function pick(wrapper: VueWrapper, side: 'inside' | 'outside') {
await preview(wrapper).get(`[data-preview-side="${side}"]`).trigger('click')
}
it('전환 — "미리 볼 답장" · 처음은 사내 쪽, 사내와 같은 응답이면 두 쪽이 같은 답장이고 아래 글만 다르다', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
expect(preview(wrapper).get('[role="group"]').attributes('aria-label')).toBe('미리 볼 답장')
expect(sides(wrapper)).toEqual([
['사내', 'true'],
['회사 밖', 'false'],
])
await pick(wrapper, 'outside')
expect(sides(wrapper)).toEqual([
['사내', 'false'],
['회사 밖', 'true'],
])
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview(wrapper).get('[data-vacation-preview-body]').text()).toContain('11월 4일에 돌아옵니다.')
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
'회사 밖에서 온 메일에도 같은 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
})
it('따로 쓰기 — 사내 쪽은 위의 글 · "사내에서 온 메일에 …", 회사 밖 쪽은 회사 밖에 보낼 제목 · 내용 · "회사 밖에서 온 메일에 …"', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: SEPARATE } })
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
'사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
await pick(wrapper, 'outside')
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe(SEPARATE.subject)
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe(
OUTSIDE_PARAGRAPHS,
)
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
'회사 밖에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
// 칸을 고친 값을 바로 그린다
await wrapper.get('#vacation-external-subject').setValue('부재중 안내 (수정)')
await setBody('vacation-external-body', '<p><em>잠시</em> 자리를 비웁니다.</p>')
expect(preview(wrapper).get('[data-vacation-preview-subject]').text()).toBe('부재중 안내 (수정)')
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe('<p><em>잠시</em> 자리를 비웁니다.</p>')
})
it('보내지 않기 — 회사 밖 쪽은 답장 대신 회색 상자 한 줄 · 아래 글 없음, 사내 쪽은 "사내에서 온 메일에 …"', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, external: { ...SAME, mode: 'none' } } })
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe(
'사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
await pick(wrapper, 'outside')
expect(preview(wrapper).get('[data-vacation-preview-none]').text()).toBe('회사 밖에서 온 메일에는 자동으로 답하지 않습니다.')
expect(preview(wrapper).find('[data-vacation-preview-subject]').exists()).toBe(false)
expect(preview(wrapper).find('[data-vacation-preview-note]').exists()).toBe(false)
// 사내 쪽으로 돌아오면 내용을 다시 그린다
await pick(wrapper, 'inside')
expect(preview(wrapper).get('[data-vacation-preview-body]').text()).toContain('안녕하세요, 김아라입니다.')
})
it('사내 쪽 내용도 칸을 고친 값을 바로 그린다 · 꺼 두면 두 쪽 모두 "꺼져 있어 자동으로 답하지 않습니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await setBody('vacation-body', '<p><u>내일</u> 돌아옵니다.</p>')
expect(preview(wrapper).get('[data-vacation-preview-body]').element.innerHTML).toBe('<p><u>내일</u> 돌아옵니다.</p>')
await wrapper.get('#vacation-enabled').setValue(false)
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
await pick(wrapper, 'outside')
expect(preview(wrapper).get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
})
})
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
const INACTIVE_TEXT =
'다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.'
@@ -350,11 +806,39 @@ describe('MailVacationPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('되돌렸거나 저장했으면 묻지 않는다', async () => {
it('내용 칸(편집기)만 고쳐도 묻는다 · 회사 밖 고르기 · 회사 밖 내용도', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await setBody('vacation-body', '<p>바꾼 내용</p>')
const first = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(true)
await wrapper.get('#vacation-leave-stay').trigger('click')
await first
// 내용을 처음대로 되돌리면 묻지 않는 대신, 회사 밖 고르기를 바꾸면 묻는다
await setBody('vacation-body', '<p>안녕하세요, 김아라입니다.</p><p><strong>11월 4일</strong>에 돌아옵니다.</p>')
await wrapper.get('#vacation-external-none').setValue(true)
const second = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(true)
await wrapper.get('#vacation-leave-stay').trigger('click')
await second
await wrapper.get('#vacation-external-same').setValue(true)
await setBody('vacation-external-body', '<p>회사 밖 글</p>')
const third = router.push('/mail/inbox')
await flushPromises()
expect(dialogOpen()).toBe(true)
await wrapper.get('#vacation-leave-stay').trigger('click')
await third
})
it('되돌렸거나 저장했으면 묻지 않는다 — 내용 칸은 저장한 내용과 견준다', async () => {
const { wrapper, router } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-subject').setValue('바꾼 제목')
await wrapper.get('#vacation-subject').setValue(SAVED.subject)
await wrapper.get('#vacation-body').setValue('바꾼 내용')
await setBody('vacation-body', '<p>바꾼 내용</p>')
await save(wrapper)
await router.push('/mail/inbox')
@@ -1,18 +1,36 @@
<script setup lang="ts">
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · 16단계 Settings16 · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
// 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false — 7단계, 디자인 원본 MailFilterApplyStates) 머리 아래 노란 줄
// "켜 둔 자동 응답이 지금은 동작하지 않습니다 …"(필터 화면의 줄과 같은 모양, 단추 없음 — [저장]은 늘 누를 수 있다).
// 저장하면 응답의 isActive 로 맞춘다(저장하면 다시 켜진다). 꺼 두었으면 보이지 않는다 — 칸을 고친 값이 아니라 저장한 값으로 본다
// 16단계: 내용 칸이 서식 편집기(TipTap)라 양식(VacationForm)째 지연 로딩한다 — 못 받으면 쓰기 화면 · 서명 설정과 같은 "다시 시도"
// (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 MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import VacationForm from '@/components/settings/VacationForm.vue'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import { useMailVacationStore } from '@/stores/mail-vacation.store'
/** 부재중 양식 — TipTap 을 이 화면에서만 내려받는다 */
const VacationForm = defineAsyncComponent({
loader: () => import('@/components/settings/VacationForm.vue'),
errorComponent: ComposeEditorLoadError,
})
const vacationStore = useMailVacationStore()
/** 양식을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
const formKey = ref<number>(0)
provide(MAIL_EDITOR_RETRY, () => {
formKey.value += 1
})
function reload() {
void vacationStore.load()
}
@@ -40,7 +58,10 @@ reload()
:message="VACATION_TEXT.inactive"
data-vacation-inactive
/>
<VacationForm :vacation="vacationStore.vacation" />
<VacationForm
:key="formKey"
:vacation="vacationStore.vacation"
/>
</template>
<div
v-else
@@ -95,7 +95,8 @@ export const TWO_FACTOR_TEXT = {
'[2단계 인증 켜기]를 누르고 화면의 QR 코드를 인증 앱으로 찍습니다.',
'앱에 표시된 6자리 코드와 지금 비밀번호를 넣으면 켜집니다. 그다음부터 로그인할 때마다 코드를 묻습니다.',
],
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다.',
/** 켜져 있을 때 아래 안내의 첫 줄 — 회사 정책으로 끌 수 없는 직원도 같은 글 (16단계 — 끄지 않고 [휴대폰 바꾸기]로 바꾼다) */
noteChangePhone: '휴대폰을 바꿀 때는 [휴대폰 바꾸기]를 눌러 주세요. 새 휴대폰과 지금 휴대폰의 코드가 모두 필요합니다.',
noteLostPhone: '휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.',
noteMailClientBefore: '메일 프로그램은 ',
noteMailClientLink: '앱 비밀번호',
@@ -132,6 +133,49 @@ export const TWO_FACTOR_ENABLE_TEXT = {
retry: '다시 시도',
} as const
/**
* 2단계 인증 휴대폰 바꾸기 창 (모달 620 · 두 걸음 — 16단계, 디자인 원본 Settings16 5~8).
* QR · 키 · [복사]의 그 밖의 글은 켜기 창(TWO_FACTOR_ENABLE_TEXT) 그대로다. 거절 글은 사전 문구(AUTH_005 · AUTH_006 · AUTH_007 · AUTH_008 · REQ_001)만
*/
export const TWO_FACTOR_REPLACE_TEXT = {
/** 상태 상자의 단추 — [2단계 인증 끄기] 앞 (회사 정책으로 끌 수 없는 직원에게도) */
open: '휴대폰 바꾸기',
title: '2단계 인증 휴대폰 바꾸기',
/** 제목 아래 차례 — 글로만(지금 걸음 청록 굵게 · 마친 걸음 체크) */
stepsLabel: '휴대폰 바꾸기 차례',
stepNew: '새 휴대폰 등록',
stepCurrent: '지금 휴대폰으로 확인',
/** ① 새 휴대폰 등록 */
newMessage: '새 휴대폰을 등록하고 지금 휴대폰으로 확인하면 바뀝니다. 바꾸기를 마칠 때까지는 지금 휴대폰의 코드로 로그인합니다.',
scanTitle: '새 휴대폰의 인증 앱으로 QR 코드를 찍어 주세요',
discardNote: '바꾸지 않고 닫으면 이 키는 더 쓸 수 없습니다. 새 휴대폰의 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.',
newCodeLabel: '새 휴대폰의 앱에 표시된 6자리 코드',
next: '다음',
verifying: '확인하는 중…',
/** ② 지금 휴대폰으로 확인 */
currentMessage: '새 휴대폰을 확인했습니다. 지금 휴대폰의 코드와 비밀번호를 넣으면 바뀌고, 그 뒤로는 새 휴대폰의 코드로만 로그인합니다.',
currentCodeLabel: '지금 휴대폰의 앱에 표시된 6자리 코드',
passwordLabel: '지금 비밀번호',
showPassword: '지금 비밀번호 보기',
/** ② 아래 문의 줄 — 연락처가 있으면 다음 줄에 연락처, 없으면 이 글만 (로그인 화면의 문의 줄과 같은 꼴) */
support: '지금 휴대폰을 쓸 수 없나요? 관리자에게 2단계 인증을 풀어 달라고 요청한 뒤, 새 휴대폰으로 다시 켜 주세요.',
back: '이전',
submit: '바꾸기',
submitting: '바꾸는 중…',
cancel: '취소',
/** 바꾼 뒤 알림 — 단추 없음 (「2단계 인증을 껐습니다.」와 같은 모양) */
done: '2단계 인증 휴대폰을 바꿨습니다. 이제 새 휴대폰의 코드로 로그인합니다.',
/**
* 결과를 모르는 실패(503 MAIL_001 · 시간 초과 · 연결 끊김) — 바뀌었다고도 아니라고도 하지 않는다. 창 위 상자(창은 ② 그대로).
* 바뀌었다면 지금 휴대폰의 코드는 이미 막혀 [바꾸기]가 코드가 맞지 않다고 거절한다 (보드에 없는 새 글 — 16단계 구현)
*/
unconfirmed:
'휴대폰을 바꿨는지 확인하지 못했습니다. 지금 휴대폰에 다음 코드가 뜨면 넣고 [바꾸기]를 다시 눌러 주세요. 코드가 맞지 않다고 나오면 이미 바뀌었을 수 있으니, 다음에 로그인할 때 새 휴대폰의 코드를 넣어 주세요.',
/** 결과를 모르는 실패가 창을 닫은 뒤 왔을 때의 알림 (보드에 없는 새 글 — 16단계 구현) */
unconfirmedClosed:
'휴대폰을 바꿨는지 확인하지 못했습니다. 다음에 로그인할 때 새 휴대폰의 코드가 맞지 않으면 지금 휴대폰의 코드를 넣어 주세요.',
} as const
/** 2단계 인증 끄기 창 (alertdialog 400 · 처음 초점 「취소」) */
export const TWO_FACTOR_DISABLE_TEXT = {
title: '2단계 인증을 끌까요?',
+202 -1
View File
@@ -2,7 +2,7 @@
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
import { directionParticle } from '@/shared/utils/korean'
import { directionParticle, subjectParticle } from '@/shared/utils/korean'
/**
* 주소록 라우트 이름 — 라우트 표(router/routes.ts) · 레일(app-modules.ts) · 화면 안의 링크 · 읽기 화면 ⋯ 「주소록에서 보기」가 함께 쓴다.
@@ -328,3 +328,204 @@ export const RECIPIENT_CONTACT_TEXT = {
/** "주소가 없는 1명 · 이미 넣은 2명은 뺐습니다." */
skipped: (parts: string) => `${parts}은 뺐습니다.`,
} as const
// ── 가져오기 · 내보내기 (16단계 — 디자인 원본 Contacts16 · 계약 3절 "가져오기 · 내보내기") ─────────────
// 글은 보드(Contacts16)의 글 그대로다. 파일은 이 브라우저에서 읽고(shared/utils/contact-import/) 넣기로 한 연락처만 100명씩 서버로 보낸다
/** 보조사 "은/는" — 받침이 있으면 "은"(subjectParticle 과 같은 판정), 소리를 알 수 없으면 "은(는)" */
function topicParticle(word: string): '은' | '는' | '은(는)' {
const subject = subjectParticle(word)
if (subject === '이(가)') return '은(는)'
return subject === '이' ? '은' : '는'
}
/** 가져올 수 있는 파일 하나의 크기 — 사진이 든 휴대폰 vCard 도 들어가게 20MB. 넘으면 읽지 않고 바로 알린다 */
export const CONTACT_IMPORT_FILE_MAX_BYTES = 20 * 1024 * 1024
/** 파일 고르는 창에 보일 형식 — 넣을 수 있는 셋만 */
export const CONTACT_IMPORT_ACCEPT = '.vcf,.csv,.xlsx'
/** 서버에 한 번에 보내는 연락처 수 — 확인(POST /contact-import-checks) · 넣기(POST /contact-imports) 모두 1~100 */
export const CONTACT_IMPORT_BATCH_SIZE = 100
/** [되돌리기]가 한 번에 지우는 수 — 서버는 500개까지 받지만 진행을 자주 보이려고 넣기와 같게 */
export const CONTACT_IMPORT_UNDO_BATCH_SIZE = 100
/**
* 한 요청 본문의 값 수 · 바이트 상한 — 앱 공통 본문 한도(값 10,000개 · JSON 1MB)보다 넉넉히 아래에서 묶음을 끊는다
* (칸을 한도까지 채운 연락처 100명이면 값 10,000개를 넘는다 — 계약 3절 "가져오기 · 내보내기"의 본문 한도). 사람 수와 함께 먼저 닿는 데서 끊는다
*/
export const CONTACT_IMPORT_BATCH_MAX_VALUES = 8_000
export const CONTACT_IMPORT_BATCH_MAX_BYTES = 700 * 1024
/** 미리 보기 표에 보이는 넣을 연락처 수 (나머지는 "그 밖에 N명") */
export const CONTACT_IMPORT_PREVIEW_ROWS = 6
/** 그룹 글에 이름을 몇 개까지 쓰는지 — 넘으면 "앞 셋 외 N개" */
export const CONTACT_IMPORT_GROUP_NAMES_SHOWN = 3
/** 그룹 하나의 구성원 수 — 넘는 넣기는 그 그룹만 빼고 만든다 (서버가 skippedGroupIds 로 알린다) */
export const CONTACT_GROUP_MEMBERS_MAX = 500
/** 엑셀(.xlsx — zip) 안의 파일을 풀었을 때 하나 · 모두의 크기 상한 — 넘으면 열지 않는다(압축 폭탄) */
export const CONTACT_IMPORT_XLSX_ENTRY_MAX_BYTES = 64 * 1024 * 1024
export const CONTACT_IMPORT_XLSX_TOTAL_MAX_BYTES = 96 * 1024 * 1024
/**
* 엑셀 첫 시트에서 늘어놓는 칸 수의 합(사이의 빈 칸 포함) — 넘으면 열지 않는다. 먼 열(XFD)에 칸 하나씩 둔 줄이 많으면
* 줄마다 빈 칸 16,384개를 채워 메모리가 부푼다. 보통 주소록 파일은 많아야 수십만 칸이다
*/
export const CONTACT_IMPORT_XLSX_CELLS_MAX = 4_000_000
/** 「전체 연락처」 머리의 [가져오기] · [내보내기]와 빈 주소록 (디자인 원본 Contacts16 "1") */
export const CONTACTS_TRANSFER_TEXT = {
import: '가져오기',
export: '내보내기',
/** 연락처가 하나도 없을 때 [내보내기]가 꺼진 까닭 */
exportEmptyNote: '내보낼 연락처가 없습니다.',
/** 빈 주소록 안내의 둘째 줄 */
firstImportNote: '다른 메일이나 휴대폰에서 내보낸 연락처 파일이 있으면 [가져오기]로 한 번에 넣을 수 있습니다.',
} as const
/** 가져오기 창 (넓은 창 620 — 디자인 원본 Contacts16 "2" · "3") */
export const CONTACT_IMPORT_TEXT = {
title: '연락처 가져오기',
message: '네이버, Google, Outlook 같은 메일이나 휴대폰에서 내보낸 연락처 파일을 그대로 넣을 수 있습니다.',
pick: '파일 고르기',
dropHint: '파일을 여기로 끌어다 놓아도 됩니다.',
dropping: '놓으면 이 파일에서 연락처를 찾습니다',
reading: '파일을 읽는 중…',
formats: 'vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 넣을 수 있습니다. 파일 하나는 20MB까지입니다.',
cancel: '취소',
pickAnother: '다른 파일 고르기',
found: (count: string) => `연락처 ${count}명을 찾았습니다.`,
insertSection: '넣을 연락처',
insertTable: (count: string) => `넣을 연락처 앞 ${count}명`,
columns: { name: '이름', email: '이메일', phone: '전화', company: '회사', groups: '그룹' },
others: (count: string) => `그 밖에 ${count}명`,
duplicateSection: '이미 있는 연락처는 건너뜁니다',
/** 넣지 못하는 묶음 — CSV · 엑셀은 줄, vCard 는 연락처 */
problemLines: '넣지 못하는 줄',
problemCards: '넣지 못하는 연락처',
people: (count: string) => `${count}명`,
lineCount: (count: string) => `${count}개`,
/** 줄 번호는 엑셀에서 보이는 행 번호(머리 줄이 1번째 줄), vCard 는 몇 번째 연락처 */
line: (position: string) => `${position}번째 줄`,
card: (position: string) => `${position}번째 연락처`,
inBook: '주소록에 있음',
sameLine: (position: string) => `${position}번째 줄과 같음`,
sameCard: (position: string) => `${position}번째 연락처와 같음`,
groupsLabel: '그룹',
groupsNone: '파일에 적힌 그룹이 없습니다.',
/** 이름 줄이기 — "거래처, 세무, 동호회 외 2개" */
groupNamesMore: (names: string, count: string) => `${names} 외 ${count}개`,
/** 파일에 적힌 그룹 — 이미 있는 그룹 · 새로 만들 그룹 */
groupsMixed: (count: string, existing: string, created: string) =>
`파일에 적힌 그룹 ${count}개 가운데 ${existing}${topicParticle(existing)} 이미 있는 그룹에 넣고 ${created}${topicParticle(created)} 새로 만듭니다.`,
groupsExisting: (names: string) => `파일에 적힌 그룹 ${names}${topicParticle(names)} 이미 있는 그룹에 넣습니다.`,
groupsCreated: (names: string) => `파일에 적힌 그룹 ${names}${topicParticle(names)} 새로 만듭니다.`,
extraLegend: '가져온 연락처를 모두 넣을 그룹',
extraNone: '넣지 않음',
extraExisting: '이미 있는 그룹',
extraNew: '새 그룹',
extraNewName: '새 그룹 이름',
extraHint: '파일에 적힌 그룹에 더해 넣습니다.',
extraNameRequired: '그룹 이름을 넣어 주세요.',
groupOption: (name: string, count: string) => `${name} · ${count}명`,
info: '주소, 생일, 홈페이지, 별명처럼 주소록에 칸이 없는 정보는 메모 끝에 붙이기',
infoHint: '메모가 1,000자를 넘게 되면 붙이지 않습니다.',
infoSample: (name: string) => `예: ${name}의 메모`,
limit: (count: string, rest: string) =>
`연락처는 3,000개까지 둘 수 있어 앞에서부터 ${count}명만 넣습니다. 나머지 ${rest}명은 넣지 않습니다.`,
submit: (count: string) => `연락처 ${count}명 넣기`,
submitNone: '연락처 넣기',
nothing: '넣을 연락처가 없습니다.',
} as const
/** 파일을 읽지 못했을 때 칸 아래 글 — 알림은 띄우지 않는다 (디자인 원본 Contacts16 "2") */
export const CONTACT_IMPORT_ERROR_TEXT = {
unsupported: '넣을 수 없는 파일입니다. vCard(.vcf), CSV(.csv), 엑셀(.xlsx) 파일을 골라 주세요.',
xls: '옛 엑셀 파일(.xls)은 넣을 수 없습니다. 엑셀에서 [다른 이름으로 저장]을 눌러 .xlsx나 CSV 파일로 저장한 뒤 넣어 주세요.',
tooLarge: '파일이 너무 큽니다. 파일 하나는 20MB까지 넣을 수 있습니다.',
notFound: '이 파일에서 연락처를 찾지 못했습니다. 이름이나 이메일이 든 열이 있는지 확인해 주세요.',
/** vCard 는 앞 문장만 */
notFoundCards: '이 파일에서 연락처를 찾지 못했습니다.',
unreadable: '이 파일을 열지 못했습니다. 암호를 건 엑셀 파일이면 암호를 푼 뒤 넣어 주세요.',
multiple: '파일은 한 번에 하나만 넣을 수 있습니다.',
} as const
/** 넣지 못하는 줄의 까닭 — 형식 · 길이 글은 연락처 창의 글을 쓴다 (한 줄 칸은 어느 칸인지를 앞에 붙인다) */
export const CONTACT_IMPORT_PROBLEM_TEXT = {
required: '이름과 이메일이 모두 없습니다.',
email: CONTACT_DIALOG_TEXT.emailInvalid,
textTooLong: (field: string) =>
`${field}${topicParticle(field)} ${CONTACT_DIALOG_TEXT.textTooLong(CONTACT_TEXT_MAX_LENGTH.toLocaleString('ko-KR'))}`,
note: CONTACT_DIALOG_TEXT.noteTooLong(CONTACT_NOTE_MAX_LENGTH.toLocaleString('ko-KR')),
fields: { name: '이름', company: '회사', department: '부서', title: '직책' },
} as const
/**
* 메모 끝에 붙이는 "가져온 정보" — 주소록에 칸이 없는 정보를 "이름: 값" 꼴로 한 줄씩 (디자인 원본 Contacts16 "3" 메모 견본).
* 형식에 맞지 않는 전화번호(내선이 붙은 번호 등)도 버리지 않고 여기에 둔다
*/
export const CONTACT_IMPORT_INFO_TEXT = {
heading: '가져온 정보',
workAddress: '회사 주소',
homeAddress: '집 주소',
otherAddress: '기타 주소',
address: '주소',
birthday: '생일',
anniversary: '기념일',
homepage: '홈페이지',
nickname: '별명',
messenger: '메신저',
phone: '전화',
/** 날짜 — 해를 모르면 "3월 14일" */
date: (year: number | null, month: number, day: number) =>
year === null ? `${month}월 ${day}일` : `${year}년 ${month}월 ${day}일`,
lunar: (date: string) => `${date} (음력)`,
line: (label: string, value: string) => `${label}: ${value}`,
} as const
/** 넣는 동안 · 끝난 뒤 · 되돌리기 알림 (디자인 원본 Contacts16 "4") */
export const CONTACT_IMPORT_TOAST_TEXT = {
progress: (done: string, total: string) => `연락처를 넣는 중 · ${done} / ${total}명`,
done: (count: string) => `연락처 ${count}명을 넣었습니다.`,
skipped: (count: string) => `이미 있던 ${count}명은 건너뛰었습니다.`,
full: (count: string) => `연락처는 3,000개까지 둘 수 있어 나머지 ${count}명은 넣지 못했습니다.`,
groupLimit: (count: string) =>
`그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 ${count}명은 그룹에 넣지 못했습니다.`,
/** 메일 서버가 일부만 받았을 때 (보드에 없는 글) */
missed: (count: string) => `${count}명은 넣지 못했습니다.`,
stopped: (total: string, done: string) => `연락처 ${total}명 가운데 ${done}명을 넣고 멈췄습니다. 다시 시도해 주세요.`,
notStarted: '연락처를 넣지 못했습니다. 다시 시도해 주세요.',
undo: '되돌리기',
retry: '다시 시도',
undoProgress: (done: string, total: string) => `가져온 연락처를 지우는 중 · ${done} / ${total}명`,
undone: (count: string) => `가져온 연락처 ${count}명을 지웠습니다.`,
undoneWithGroups: (count: string, groups: string) =>
`가져온 연락처 ${count}명과 새로 만든 그룹 ${groups}개를 지웠습니다.`,
undoStopped: (total: string, done: string) =>
`가져온 연락처 ${total}명 가운데 ${done}명을 지우고 멈췄습니다. 다시 시도해 주세요.`,
} as const
/** 내보내기 창 (칸 하나 창 400 — 디자인 원본 Contacts16 "5") */
export const CONTACT_EXPORT_TEXT = {
title: '연락처 내보내기',
scopeLegend: '내보낼 연락처',
all: '내 주소록 전체',
allCount: (count: string) => `연락처 ${count}명`,
group: '그룹 하나',
groupHint: '고른 그룹의 구성원만',
groupSelect: '내보낼 그룹',
groupOption: (name: string, count: string) => `${name} · ${count}명`,
groupEmpty: (name: string) => `‘${name}’ 그룹에는 아직 아무도 없어 내보낼 연락처가 없습니다.`,
directoryNote: '내 주소록의 연락처만 내보내고 사내 목록은 넣지 않습니다.',
formatLegend: '파일 형식',
vcard: 'vCard (.vcf)',
vcardHint: '다른 메일이나 휴대폰으로 옮길 때',
csv: 'CSV (.csv)',
csvHint: '엑셀에서 열어 볼 때',
fileName: '파일 이름',
/** 파일 이름 — 주소록-날짜, 그룹이면 주소록-그룹 이름-날짜 (서버가 붙이는 이름과 같은 꼴) */
fileBase: '주소록',
submit: '내보내기',
cancel: '취소',
} as const
/** 내보내기 형식마다 파일 끝 */
export const CONTACT_EXPORT_EXTENSIONS = {
vcard: 'vcf',
csv: 'csv',
} as const
+6
View File
@@ -129,6 +129,12 @@ export const ICONS = {
polyline('7 10 12 15 17 10'),
line(12, 15, 12, 3),
],
/** 올리기 — 주소록 [가져오기] (16단계, 디자인 원본 Contacts16) */
upload: [
path('M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'),
polyline('17 8 12 3 7 8'),
line(12, 3, 12, 15),
],
file: [path('M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z'), polyline('13 2 13 9 20 9')],
check: [polyline('20 6 9 17 4 12')],
/** 보기 전환 — 분할 */
+103 -16
View File
@@ -1,4 +1,4 @@
// 메일 서명 상수 — 계약 2절 "서명" · 디자인 README "2단계 › 서명" (디자인 원본 Settings)
// 메일 서명 상수 — 계약 2절 "서명" · 디자인 README "2단계 › 서명" · "16단계 — 여러 서명" (디자인 원본 Settings · Signature16 — 글은 보드 그대로)
// 한도는 서버(backend/src/modules/mail-signature/mail-signature.constants.ts)와 같은 값이다 — 서버가 최종 검증한다.
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
@@ -9,7 +9,7 @@ export const SIGNATURE_IMAGE_MAX_BYTES = 300 * 1024
/**
* 서명에 넣을 수 있는 그림 수 — 서명 칸에 있는 서로 다른 그림으로 센다(뺀 그림은 세지 않는다).
* 넘으면 서명 저장이 400 VAL_001 — 클라이언트가 그림을 넣기 전에 먼저 막는다.
* (올려 두기만 한 그림까지 한 사람 10장은 서버가 따로 막는다 — 올리기가 400 VAL_001, 알림은 인터셉터)
* (올려 두기만 한 그림까지 한 사람 20장(16단계 — 여러 서명이 함께 쓴다)은 서버가 따로 막는다 — 올리기가 400 VAL_001, 알림은 인터셉터)
*/
export const SIGNATURE_IMAGE_MAX_COUNT = 3
/** 서명 서식(HTML) 최대 길이 — 서버는 정화한 뒤 잰다(넘으면 400 VAL_001). 보내기 전에 같은 길이로 먼저 막는다 */
@@ -39,12 +39,29 @@ export const SIGNATURE_IMAGE_SIZES = {
} as const
export type SignatureImageSize = keyof typeof SIGNATURE_IMAGE_SIZES | 'original'
/** 그림 올리기의 실패 중 서명 칸 아래에 직접 보이는 코드 (형식 · 크기) — 전역 알림은 끈다. 10장 초과(VAL_001)는 알림으로 */
/** 서명은 직원마다 10개까지 — 11번째는 서버가 400 VAL_001 (화면이 [새 서명]을 먼저 막는다) */
export const MAIL_SIGNATURE_MAX_COUNT = 10
/** 서명 이름 — 앞뒤 공백을 뺀 1~30자 (JS 문자열 길이 — 칸의 maxlength 와 같다) */
export const SIGNATURE_NAME_MAX_LENGTH = 30
/**
* 쓰기 화면이 처음 넣는 서명 자리의 표시 — 편집기가 이 div 를 서명 경계 노드로 읽는다 (16단계, mail-signature-block.ts).
* 보낼 때는 표시 없는 <div> 로 나가고, 임시 저장본을 다시 열면 "빈 문단 뒤 속성 없는 div" 로 서명 자리를 다시 찾는다(앱과 같은 경계 — 계약 2절)
*/
export const MAIL_SIGNATURE_MARK_ATTRIBUTE = 'data-mail-signature'
/** 서명 칸 툴바의 화면 낭독 이름 — 공용 편집기(SignatureEditor)를 다른 칸이 쓸 때는 그 칸의 이름을 준다 (16단계 부재중 "자동 응답 서식") */
export const SIGNATURE_TOOLBAR_LABEL = '서명 서식'
/** 서명 칸의 편집 영역 id — 서명 화면에는 칸이 하나다 */
export const SIGNATURE_BODY_ID = 'signature-body'
/** 그림 올리기의 실패 중 서명 칸 아래에 직접 보이는 코드 (형식 · 크기) — 전역 알림은 끈다. 20장 초과(VAL_001)는 알림으로 */
export const SIGNATURE_IMAGE_INLINE_CODES: readonly ErrorCode[] = ['FILE_001', 'FILE_002']
/** 서명 저장의 실패 중 서명 칸 아래에 직접 보이는 코드 (길이 · 그림 3장 초과) — 전역 알림은 끈다 */
/** 서명 저장의 실패 중 화면이 직접 보이는 코드 (이름 · 길이 · 그림 3장 초과) — 전역 알림은 끈다 */
export const SIGNATURE_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
/** 서명을 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) */
/** 서명을 바꾸고 저장하지 않은 채 다른 화면으로 갈 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) */
export const SIGNATURE_LEAVE_DIALOG = {
title: '저장하지 않은 서명이 있습니다',
message: '이 화면을 나가면 바꾼 서명이 사라집니다.',
@@ -52,22 +69,48 @@ export const SIGNATURE_LEAVE_DIALOG = {
leave: '나가기',
} as const
/** 설정 화면 문구 (디자인 원본 Settings) */
/**
* 저장하지 않은 채 다른 서명을 고르거나 [새 서명] · [취소]를 누를 때 (디자인 원본 Signature16 5) — 나가기 확인과 같은 창.
* 새 서명(아직 저장하지 않은 줄)의 글은 보드에 없어 지었다 — "‘새 서명’ 서명에서"가 되지 않게 (16단계 구현하며 정함)
*/
export const SIGNATURE_SWITCH_DIALOG = {
title: '저장하지 않은 서명이 있습니다',
pick: (name: string) => `다른 서명을 고르면 ‘${name}’ 서명에서 바꾼 내용이 사라집니다.`,
create: (name: string) => `새 서명을 만들면 ‘${name}’ 서명에서 바꾼 내용이 사라집니다.`,
pickFromNew: '다른 서명을 고르면 새 서명에 쓴 내용이 사라집니다.',
cancelNew: '취소하면 새 서명에 쓴 내용이 사라집니다.',
stay: '계속 고치기',
discard: '바꾼 내용 버리기',
} as const
/** 설정 화면 문구 (디자인 원본 Settings · Signature16) */
export const SIGNATURE_TEXT = {
title: '서명',
description:
'보내는 메일 끝에 넣는 글입니다. 메일을 쓰기 시작할 때 본문에 미리 넣어 두니, 그 메일에서만 고치거나 지울 수도 있습니다.',
'보내는 메일 끝에 넣는 글입니다. 여러 개 만들어 두면 메일을 쓸 때 [서명]에서 골라 바꿀 수 있고, 그 메일에서만 고치거나 지울 수도 있습니다.',
listLabel: '내 서명',
newSignature: '새 서명',
/** 아직 저장하지 않은 새 줄의 이름 아래 글 */
unsavedRow: '저장하지 않음',
count: (count: number) => `서명 ${count}개를 만들었고, 10개까지 만들 수 있습니다.`,
full: '서명은 10개까지 만들 수 있습니다. 쓰지 않는 서명을 지워 주세요.',
nameLabel: '이름',
namePlaceholder: '예: 사내, 대외, 영문',
nameHint: '메일을 쓸 때 [서명]에서 고르는 이름입니다. 30자까지 쓸 수 있습니다.',
nameEmpty: '이름을 넣어 주세요.',
nameDuplicate: '같은 이름의 서명이 있습니다.',
fieldLabel: '서명 내용',
hint:
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.',
usageLegend: '서명을 넣을 메일',
forNew: '새 메일',
forReply: '답장과 전달',
forReplyNote: '인용한 원문 위에 넣습니다',
save: '저장',
saving: '저장하는 중…',
/** [저장] 뒤 — 기본으로 넣는 서명 */
saved: '저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.',
/** [저장] 뒤 — 기본이 아닌 서명 */
savedPlain: '저장했습니다.',
delete: '이 서명 지우기',
cancel: '취소',
uploading: '그림을 올리는 중…',
imageSizeLabel: '그림 크기',
imageSizes: { small: '작게', medium: '보통', original: '원본' },
@@ -77,14 +120,47 @@ export const SIGNATURE_TEXT = {
previewNew: '새 메일',
previewReply: '답장',
previewBody: '(여기에 쓴 내용)',
emptyTitle: '아직 서명이 없습니다',
emptyBody:
'보내는 메일 끝에 넣을 이름, 소속, 연락처를 만들어 두면 메일을 쓸 때마다 본문에 넣어 둡니다. ' +
'사내용과 대외용처럼 여러 개를 만들어 골라 쓸 수도 있습니다.',
} as const
/** 미리 보기 아래 안내 (디자인 원본 Settings) */
/** 메일에 넣을 서명 (기본 둘) — 고르는 대로 바로 저장 (디자인 원본 Signature16 1) */
export const SIGNATURE_DEFAULTS_TEXT = {
legend: '메일에 넣을 서명',
newMessage: '새 메일',
reply: '답장과 전달',
none: '넣지 않음',
hint: '메일을 쓰기 시작할 때 본문에 넣어 둡니다. 고르는 대로 바로 저장됩니다.',
saved: '저장했습니다',
} as const
/** 목록 줄의 이름 아래 회색 글 — 그 서명을 기본으로 넣는 메일 */
export const SIGNATURE_USES_TEXT = {
new: '새 메일',
reply: '답장과 전달',
both: '새 메일, 답장과 전달',
} as const
/** 서명 지우기 (디자인 원본 Signature16 4) — 처음 초점은 [취소] */
export const SIGNATURE_DELETE_TEXT = {
title: (name: string) => `‘${name}’ 서명을 지울까요?`,
messageNew: '새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
messageReply:
'답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
messageBoth:
'새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
message: '지운 서명은 되돌릴 수 없습니다.',
confirm: '지우기',
cancel: '취소',
done: (name: string) => `‘${name}’ 서명을 지웠습니다.`,
} as const
/** 미리 보기 아래 안내 (디자인 원본 Settings · Signature16 — 고른 서명이 들어갈 자리) */
export const SIGNATURE_PREVIEW_NOTES = {
newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
newOff: '새 메일에는 서명을 넣지 않습니다.',
replyOn: '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.',
replyOff: '답장과 전달에는 서명을 넣지 않습니다.',
new: '새 메일은 본문 끝에 서명을 넣습니다.',
reply: '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.',
} as const
/**
@@ -99,3 +175,14 @@ export const SIGNATURE_PREVIEW_QUOTE = {
],
body: '(원래 메일 내용)',
} as const
/** 쓰기 화면 [서명 ▾] (디자인 원본 Signature16 6) — 바꾼 뒤 글은 화면 낭독기만 읽는다(알림 없음) */
export const COMPOSE_SIGNATURE_TEXT = {
button: '서명',
/** 단추의 화면 낭독 이름 — 지금 본문에 든 서명까지 (툴바의 글꼴 단추처럼). 어느 서명과도 같지 않으면 "서명"만 */
buttonLabel: (name: string) => `서명: ${name}`,
menuLabel: '서명',
none: '넣지 않음',
manage: '서명 관리',
changed: (name: string, particle: string) => `서명을 ‘${name}’${particle} 바꿨습니다.`,
} as const
+51 -2
View File
@@ -5,10 +5,21 @@
// 한글로는 저장되지 않았다). 켤 때만 본다 — 꺼 둔 채로는 서버가 4단계 한도까지 받아, 4단계에 저장한 긴 글이 켜져 있어도 끌 수 있다
// (꺼 두면 칸이 흐려 고칠 수 없다).
// 16단계(디자인 원본 Settings16 1~4): 내용 칸이 서명 칸과 같은 편집기(그림 없음)다 — 600자는 서식을 뺀 보이는 글(편집기의 getText — 서버가 받는
// textBody 와 같다)로 세고, 서식 본문은 서명과 같은 10,000자(서버가 정화한 뒤 잰다). 회사 밖에서 온 메일에 보낼 응답을 고른다(사내와 같은 응답 · 보내지 않기 · 따로 쓰기 —
// 열리는 칸이 맨 아래). 따로 쓴 제목 · 내용은 위 칸과 같은 한도다.
import type { MailVacationExternalMode } from '@/shared/types/mail'
/** 제목 — 켤 때 1~150자 */
export const VACATION_SUBJECT_MAX_LENGTH = 150
/** 내용 — 켤 때 1~600자 (글자만) */
/** 내용 — 켤 때 1~600자 (서식을 뺀 보이는 글) */
export const VACATION_BODY_MAX_LENGTH = 600
/** 서식 본문 — 켤 때 10,000자까지 (서명과 같은 한도 · 서버는 정화한 뒤 잰다 — 16단계) */
export const VACATION_HTML_MAX_LENGTH = 10_000
/** 회사 밖 응답 고르기의 차례 — 칸이 열리는 따로 쓰기가 맨 끝 (16단계 — 보드 검토에서 정함) */
export const VACATION_EXTERNAL_MODES: readonly MailVacationExternalMode[] = ['same', 'none', 'separate']
/**
* 메일함 패널 안내를 다시 계산하는 간격 — 끝나는 시각이 지나면 사라지고, 시작 전 문구("10월 30일 ~ …")가 시작한 뒤의 문구로 바뀐다
* (안내의 날짜는 하루 단위라 1분이면 충분하다)
@@ -27,13 +38,51 @@ export const VACATION_TEXT = {
rangeHint: '시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다.',
subject: '제목',
body: '내용',
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
/** 내용 칸 아래 안내 (16단계 — 서식 편집기) */
bodyHint: '굵게, 기울임, 밑줄, 글자 색, 링크를 쓸 수 있습니다. 그림은 넣을 수 없고, 서명은 붙지 않습니다.',
/** 칸 이름 오른쪽 글자 수 — "132 / 600" (주소록 메모와 같은 자리) */
bodyCount: (length: string, max: string) => `${length} / ${max}`,
/** 켠 채 저장할 때 600자를 넘으면 칸 아래 (빨간 글자 수와 함께 — 회사 밖에 보낼 내용 칸도 같은 글) */
bodyTooLong: (max: string) => `내용은 ${max}자까지 쓸 수 있습니다.`,
/** 툴바의 화면 낭독 이름 */
toolbarLabel: '자동 응답 서식',
externalToolbarLabel: '회사 밖 자동 응답 서식',
/** 회사 밖에서 온 메일 — 고르기 셋 (16단계) */
externalLegend: '회사 밖에서 온 메일',
/** 도메인은 내 주소의 것 */
externalHint: (domain: string) => `보낸 사람 주소가 회사 메일 주소(@${domain})가 아닌 메일입니다.`,
externalOptions: {
same: { label: '사내와 같은 응답', hint: '회사 안팎 모두 위의 제목과 내용으로 답합니다.' },
none: { label: '보내지 않기', hint: '회사 밖에서 온 메일에는 자동으로 답하지 않습니다.' },
separate: { label: '따로 쓰기', hint: '회사 밖에서 온 메일에 보낼 제목과 내용을 따로 씁니다.' },
} satisfies Record<MailVacationExternalMode, { label: string; hint: string }>,
/** 따로 쓰기를 고르면 열리는 두 칸 */
externalSubject: '회사 밖에 보낼 제목',
externalBody: '회사 밖에 보낼 내용',
save: '저장',
saving: '저장하는 중…',
/**
* 서버가 400 MAIL_013(필터 · 차단 · 부재중을 담는 엔진 규칙이 한도를 넘음)으로 거절했을 때 [저장] 옆 글 — 알림은 끈다.
* 부재중은 서식 본문이 원본과 응답에 두 번 들어가 필터가 없어도 넘을 수 있어 사전 문구("필터나 차단 목록이 …") 대신 이 글 (16단계 리뷰)
*/
saveTooLong: '자동 응답이 너무 깁니다. 서식이나 내용을 줄여 주세요.',
previewTitle: '보낸 사람이 받는 답장',
/** 미리 보기 머리 오른쪽 전환 [사내 | 회사 밖] (36 — 서명 미리 보기와 같은 부품) */
previewSwitchLabel: '미리 볼 답장',
previewInside: '사내',
previewOutside: '회사 밖',
previewFrom: '보낸 사람',
previewSubject: '제목',
/** 사내와 같은 응답 — 사내 쪽 아래 글(16단계 전의 글 그대로) */
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
/** 사내와 같은 응답 — 회사 밖 쪽 아래 글 */
previewNoteSameOutside: '회사 밖에서 온 메일에도 같은 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
/** 보내지 않기 · 따로 쓰기 — 사내 쪽 */
previewNoteInside: '사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
/** 따로 쓰기 — 회사 밖 쪽 */
previewNoteOutside: '회사 밖에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
/** 보내지 않기 — 회사 밖 쪽에 답장 대신(회색 상자 · 아래 글 없음) */
previewNone: '회사 밖에서 온 메일에는 자동으로 답하지 않습니다.',
previewNoteOff: '꺼져 있어 자동으로 답하지 않습니다.',
// 저장한 뒤의 상태 글 — 날짜는 "10월 30일(금)" (올해가 아니면 앞에 연도)
savedOff: '저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.',
@@ -82,8 +82,9 @@ export const TWO_FACTOR_GRACE_TEXT = {
action: '지금 켜기',
/** 설정 › 2단계 인증 머리 아래 갈색 상자의 둘째 줄 */
settingsText: '회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다.',
/** 필수인 직원이 켠 뒤 — [2단계 인증 끄기] 대신 */
/**
* 필수인 직원이 켠 뒤 — [2단계 인증 끄기] 대신 ([휴대폰 바꾸기]는 그 앞에 그대로 — 16단계).
* 아래 안내의 첫 줄은 16단계부터 보통 직원과 같은 글이다(TWO_FACTOR_TEXT.noteChangePhone — 끄지 않고 바꾼다)
*/
cannotDisable: '회사 정책에 따라 끌 수 없습니다',
/** 필수인 직원이 켠 뒤 — 아래 안내의 첫 줄 (12단계 「끈 뒤 새 휴대폰으로 다시 켜 주세요」 대신) */
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.',
} as const
@@ -37,6 +37,25 @@ export interface TwoFactorDeactivationPayload {
otpCode: string
}
/** POST /two-factor/enrollments/{id}/verification 본문 (16단계 — 휴대폰 바꾸기 ①, 새 휴대폰의 코드) */
export interface TwoFactorVerificationPayload {
otpCode: string
}
/** POST /two-factor/enrollments/{id}/verification 응답 — 새 휴대폰의 코드가 그 등록의 키와 맞다 (서버가 등록에 "확인됨"을 둔다) */
export interface TwoFactorVerification {
verified: true
}
/** POST /two-factor/replacements 본문 (16단계 — 휴대폰 바꾸기 ②). 응답은 GET /two-factor 와 같은 모양 */
export interface TwoFactorReplacementPayload {
/** ①에서 확인한 등록 */
enrollmentId: string
/** 지금 휴대폰의 코드 */
currentOtpCode: string
currentPassword: string
}
/** 앱 비밀번호 · 로그인 기록의 메일 프로그램 방식 */
export type MailClientProtocol = 'imap' | 'smtp'
+53 -1
View File
@@ -1,4 +1,4 @@
// 주소록 API 공유 타입 — docs/mail/api-contract.md 3절 "주소록 — 8단계"와 같은 구조
// 주소록 API 공유 타입 — docs/mail/api-contract.md 3절 "주소록 — 8단계"(16단계 가져오기 · 내보내기 포함)와 같은 구조
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
import type { CursorPaginationQuery } from '@/shared/types/pagination'
@@ -138,3 +138,55 @@ export interface DirectoryListQuery extends CursorPaginationQuery {
/** 그 그룹 구성원이 가진 주소를 뺀다 — 구성원 넣기 창 */
excludeGroupId?: string
}
// ── 가져오기 · 내보내기 (16단계 — 계약 3절 "가져오기 · 내보내기") ──────────────
/**
* 가져오기 한 줄 — 만들기 본문(ContactInput — 검사도 같다)에 화면이 만든 uid(`urn:uuid:` + 소문자 UUID).
* 같은 uid 를 다시 보내면 서버는 두 번 만들지 않고 그 카드 id 를 돌려준다 (멈춘 넣기를 [다시 시도]로 잇는다)
*/
export interface ContactImportItem extends ContactInput {
uid: string
}
/** POST /contact-import-checks · /contact-imports 본문 — 1~100명 */
export interface ContactImportPayload {
contacts: ContactImportItem[]
}
/** 이미 있는 연락처인가 — 같은 이메일(앞뒤 공백 · 대소문자 무시), 이메일이 없으면 이름과 전화(숫자만)가 모두 같은 연락처 */
export type ContactImportCheckStatus = 'new' | 'exists'
/** POST /contact-import-checks 응답 — 만들지 않는다. remaining 은 더 둘 수 있는 연락처 수(3,000 − 지금 수) */
export interface ContactImportChecked {
items: { uid: string; status: ContactImportCheckStatus }[]
remaining: number
}
/**
* POST /contact-imports 응답(201) — 만든 것만 온다(엔진이 일부만 받으면 빠진 uid 는 넣지 못한 것).
* skippedGroupIds 는 구성원 500명이 넘어 넣지 못한 그룹 (그 연락처는 그 그룹 없이 만들었다)
*/
export interface ContactImported {
items: { uid: string; id: string; skippedGroupIds: string[] }[]
}
/** POST /contact-deletions 본문 — 1~500개, 겹치지 않게 */
export interface ContactDeletionsPayload {
ids: string[]
}
/** POST /contact-deletions 응답 — 없던 id 는 notFoundIds (되돌리기 표는 남기지 않는다) */
export interface ContactDeletions {
deletedIds: string[]
notFoundIds: string[]
}
/** 내보내기 형식 — vCard 3.0 · CSV(UTF-8 BOM) */
export type ContactExportFormat = 'vcard' | 'csv'
/** GET /contact-exports 쿼리 — groupId 가 없으면 내 주소록 전체 (사내 목록은 넣지 않는다) */
export interface ContactExportQuery {
format: ContactExportFormat
groupId?: string
}
@@ -0,0 +1,42 @@
// 여러 서명 — 계약 2절 "서명" (16단계 · 디자인 원본 Signature16)
// 서명은 직원마다 10개까지, 만든 차례로 온다. 새 메일 · 답장과 전달에 넣을 서명(기본 둘)은 따로 고른다.
// 서명 그림의 모양은 지금 그대로다 (shared/types/mail.ts 의 SignatureImage — 그림 경로 /mail-signature/images 도 그대로).
import type { SignatureImage } from '@/shared/types/mail'
/** GET · POST · PUT /mail-signatures 의 서명 하나 */
export interface MailSignature {
id: string
/** 이름 — 앞뒤 공백을 뺀 1~30자, 겹치지 않는다(영문 대소문자를 가리지 않는다) */
name: string
/** 서명 서식 — 서식 본문 규칙 + 서명 그림 (비었으면 null) */
bodyHtml: string | null
/** 텍스트 서명 — 텍스트 대체 본문에 넣는다 (비었으면 null) */
body: string | null
/** 이 서명이 쓰는 그림 */
images: SignatureImage[]
/** 마지막으로 저장한 시각 (ISO 8601 UTC) */
updatedAt: string | null
}
/** POST · PUT /mail-signatures 본문 */
export interface MailSignaturePayload {
name: string
bodyHtml: string | null
body: string | null
}
/** GET · PUT /mail-signature-defaults — 새 메일 · 답장과 전달에 넣을 서명 (null = 넣지 않음) */
export interface MailSignatureDefaults {
newMessageId: string | null
replyId: string | null
}
/** 서명을 넣는 쓰기 종류 — 새 메일 · 답장과 전달 */
export type SignatureUse = 'new' | 'reply'
/**
* 쓰기 화면 본문에 든 서명 — 저장한 서명(그 id) · 넣지 않음(서명 자리가 없다) ·
* 서명 자리가 있지만 어느 서명과도 글 · 그림이 같지 않다(고쳤거나 지운 서명 — [서명 ▾] 에 ✓ 를 붙이지 않는다)
*/
export type ComposeSignatureState = { kind: 'signature'; id: string } | { kind: 'none' } | { kind: 'edited' }
+27 -26
View File
@@ -498,9 +498,30 @@ export interface MailboxBulkResult {
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────────
/**
* 회사 밖에서 온 메일(봉투 보낸 사람의 도메인이 회사 메일 도메인이 아님)에 보낼 응답 (16단계) —
* same: 사내와 같은 응답(처음 값 — 위 제목 · 내용) · none: 보내지 않기 · separate: 따로 쓰기(아래 제목 · 내용)
*/
export type MailVacationExternalMode = 'same' | 'none' | 'separate'
/**
* 회사 밖 응답 (16단계). 다른 mode 로 바꿔도 따로 쓴 제목 · 내용은 그대로 저장한다 — 다시 따로 쓰기를 고르면 그대로 나온다.
* 따로 쓰기를 켤 때는 제목(1~150자) · 내용(1~600자 — 보이는 글) · 서식 본문(정화 뒤 10,000자)이 위 칸과 같은 한도다. 쓴 적 없으면 셋 다 null
*/
export interface MailVacationExternal {
mode: MailVacationExternalMode
subject: string | null
/** 보이는 글 — 서식 본문의 대체 본문 */
textBody: string | null
/** 서식 본문 — null 이면 글만 */
htmlBody: string | null
}
/**
* PUT /mail-vacation 본문 — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
* 제목(1~150자) · 내용(1~600자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
* 제목(1~150자) · 내용(1~600자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다.
* 16단계에 더한 칸(htmlBody · external)은 응답에 늘 있다 — 없으면(16단계 전 서버) 글만 · 사내와 같은 응답이다(useMail 이 맞춘다).
* 기간만 보는 곳(메일함 패널 안내)은 4단계 칸만 쓴다. 화면은 늘 일곱 칸을 다 보낸다(useMail.saveVacation)
*/
export interface MailVacation {
isEnabled: boolean
@@ -509,8 +530,12 @@ export interface MailVacation {
/** ISO 8601 UTC — null 이면 끌 때까지. 화면은 끝나는 날 다음 날 0시(브라우저 시간대)를 보낸다 */
toDate: string | null
subject: string
/** 글자만(서식 없음) — 서명은 붙지 않는다 */
/** 보이는 글 — 서식 본문의 대체 본문(16단계 전에는 글만). 서명은 붙지 않는다 */
textBody: string
/** 서식 본문 (16단계 — 서명 칸과 같은 편집기, 그림 없음). null 이면 글만 */
htmlBody?: string | null
/** 회사 밖에서 온 메일에 보낼 응답 (16단계) */
external?: MailVacationExternal
}
/** GET · PUT /mail-vacation 의 응답 (7단계) — 저장한 값 + 엔진에서 지금 동작하는지 */
@@ -647,30 +672,6 @@ export interface SignatureImage {
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절 "받는 사람 추천" · 3절 "메일과 함께") ────────────────
/**
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
CONTACT_IMPORT_BATCH_MAX_BYTES,
CONTACT_IMPORT_BATCH_MAX_VALUES,
CONTACT_IMPORT_BATCH_SIZE,
} from '@/shared/constants/contacts'
import type { ContactImportItem } from '@/shared/types/contacts'
import { envelopeOf, jsonValueCount, requestBatchSize, utf8Length } from './batches'
// 요청 묶음 나누기 — 100명 · JSON 값 8,000개 · 700KB 가운데 먼저 닿는 데서 (계약 3절 "가져오기 · 내보내기"의 본문 한도)
const LIMITS = {
maxItems: CONTACT_IMPORT_BATCH_SIZE,
maxValues: CONTACT_IMPORT_BATCH_MAX_VALUES,
maxBytes: CONTACT_IMPORT_BATCH_MAX_BYTES,
}
/** 칸을 한도까지 채운 연락처 — 이메일 · 전화 20개씩 */
function fullContact(index: number, groupIds: string[] = []): ContactImportItem {
return {
uid: `urn:uuid:00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
name: `사람${index}`,
emails: Array.from({ length: 20 }, (_, k) => ({ address: `p${index}.${k}@example.org`, label: 'work' as const })),
phones: Array.from({ length: 20 }, (_, k) => ({ number: `010-${1000 + index}-${2000 + k}`, label: 'mobile' as const })),
company: '한빛상사',
department: '구매팀',
title: '과장',
note: null,
groupIds,
}
}
/** 서버(backend shared/utils/json-shape.ts — findJsonShapeProblem)와 같은 셈: 맨 바깥 1 + 객체 · 배열마다 항목 수 */
function serverValueCount(body: unknown): number {
let values = 1
const pending: unknown[] = [body]
while (pending.length > 0) {
const next = pending.pop()
if (typeof next !== 'object' || next === null) continue
const children = Array.isArray(next) ? next : Object.values(next)
values += children.length
pending.push(...children)
}
return values
}
describe('jsonValueCount · utf8Length', () => {
it('값 하나하나를 센다 — 서버의 셈(맨 바깥 + 항목 수)과 같다', () => {
const body = { contacts: [fullContact(1), fullContact(2, ['g1', 'g2'])] }
expect(jsonValueCount(body)).toBe(serverValueCount(body))
// 연락처 하나 = 자기 1 + 칸 9 + 이메일 20 × (1 + 2) + 전화 20 × (1 + 2) + 그룹 수
expect(jsonValueCount(fullContact(1))).toBe(130)
expect(jsonValueCount(fullContact(2, ['g1', 'g2']))).toBe(132)
expect(jsonValueCount({ a: undefined, b: null })).toBe(2)
})
it('UTF-8 길이 — 한글 3바이트 · 그림 글자 4바이트', () => {
expect(utf8Length('abc')).toBe(3)
expect(utf8Length('강민지')).toBe(9)
expect(utf8Length('😀')).toBe(4)
expect(utf8Length('é')).toBe(2)
expect(envelopeOf('contacts')).toEqual({ values: 2, bytes: '{"contacts":[]}'.length })
})
})
describe('requestBatchSize', () => {
it('보통 연락처는 100명에서 끊는다', () => {
const items = Array.from({ length: 150 }, (_, index) => ({ uid: `u${index}`, name: `사람${index}`, emails: [], phones: [] }))
expect(requestBatchSize(items, LIMITS, envelopeOf('contacts'))).toBe(100)
expect(requestBatchSize([], LIMITS, envelopeOf('contacts'))).toBe(0)
})
it('칸을 한도까지 채운 연락처 100명은 값 8,000개에서 나뉜다 — 나뉜 본문이 서버 한도(값 10,000)를 넘지 않는다', () => {
const items = Array.from({ length: 100 }, (_, index) => fullContact(index))
const first = requestBatchSize(items, LIMITS, envelopeOf('contacts'))
expect(first).toBe(61)
const body = { contacts: items.slice(0, first) }
expect(serverValueCount(body)).toBeLessThanOrEqual(CONTACT_IMPORT_BATCH_MAX_VALUES)
expect(serverValueCount({ contacts: items.slice(0, first + 1) })).toBeGreaterThan(CONTACT_IMPORT_BATCH_MAX_VALUES)
expect(requestBatchSize(items.slice(first), LIMITS, envelopeOf('contacts'))).toBe(39)
})
it('바이트에서도 끊는다 — 본문의 UTF-8 길이가 상한을 넘지 않게', () => {
const big = { uid: 'u', note: '메'.repeat(1000) }
const items = Array.from({ length: 100 }, () => big)
const limits = { ...LIMITS, maxBytes: 10_000 }
const size = requestBatchSize(items, limits, envelopeOf('contacts'))
expect(size).toBe(3)
expect(utf8Length(JSON.stringify({ contacts: items.slice(0, size) }))).toBeLessThanOrEqual(10_000)
expect(utf8Length(JSON.stringify({ contacts: items.slice(0, size + 1) }))).toBeGreaterThan(10_000)
})
it('첫 항목이 홀로 넘어도 하나는 담는다', () => {
expect(requestBatchSize([{ note: 'x'.repeat(100) }], { ...LIMITS, maxBytes: 10 }, envelopeOf('contacts'))).toBe(1)
})
})
@@ -0,0 +1,79 @@
// 요청 묶음 나누기 — 순수 함수 (16단계 가져오기 · 계약 3절 "가져오기 · 내보내기"의 본문 한도)
// 확인 · 넣기 · 되돌리기 요청의 본문은 앱 공통 한도(JSON 1MB · 값 10,000개)를 지나야 한다. 보통 100명이 50KB 남짓이지만
// 칸을 한도까지 채운 연락처(이메일 · 전화 20개씩 · 그룹 여럿)가 몰리면 100명이 값 10,000개를 넘는다 —
// 그래서 사람 수 · 값 수 · 바이트 가운데 먼저 닿는 데서 끊는다(상한은 서버 한도보다 넉넉히 아래 — constants/contacts.ts).
// 값 수는 서버와 같은 셈이다 — 값 하나하나를 센다(맨 바깥 포함, 객체 · 배열마다 그 항목 수를 더한 것과 같다 — 짝: backend shared/utils/json-shape.ts).
// 바이트는 JSON.stringify 의 UTF-8 길이다
/** 한 묶음의 상한 */
export interface RequestBatchLimits {
maxItems: number
maxValues: number
maxBytes: number
}
/** 본문의 겉 — `{"contacts":[ … ]}` 처럼 항목 배열을 감싼 부분의 값 수 · 바이트 (항목 사이 쉼표는 따로 센다) */
export interface RequestEnvelope {
values: number
bytes: number
}
/** `{ "<key>": [ … ] }` 꼴 본문의 겉 — 맨 바깥 객체 · 그 안의 배열 둘이 값이고, 바이트는 `{"key":[` + `]}` */
export function envelopeOf(key: string): RequestEnvelope {
return { values: 2, bytes: utf8Length(`{${JSON.stringify(key)}:[]}`) }
}
/** 글의 UTF-8 바이트 수 */
export function utf8Length(text: string): number {
let bytes = 0
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index)
if (code < 0x80) bytes += 1
else if (code < 0x800) bytes += 2
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
// 서로게이트 쌍 — 네 바이트 글자 하나
bytes += 4
index += 1
} else bytes += 3
}
return bytes
}
/** 값 하나가 본문에서 차지하는 값 수 — 그 값과 그 안의 모든 값 (JSON 으로 나가지 않는 undefined 는 세지 않는다) */
export function jsonValueCount(value: unknown): number {
let count = 0
const pending: unknown[] = [value]
while (pending.length > 0) {
const next = pending.pop()
if (next === undefined) continue
count += 1
if (typeof next === 'object' && next !== null) {
for (const child of Array.isArray(next) ? next : Object.values(next)) pending.push(child)
}
}
return count
}
/**
* 앞에서부터 한 묶음에 담을 항목 수 — 사람 수 · 값 수 · 바이트 가운데 먼저 닿는 데서 끊는다.
* 첫 항목이 홀로 넘어도 하나는 담는다(그런 연락처는 없다 — 칸마다 한도가 있다). 항목이 없으면 0
*/
export function requestBatchSize(
items: readonly unknown[],
limits: RequestBatchLimits,
envelope: RequestEnvelope,
): number {
let values = envelope.values
let bytes = envelope.bytes
let size = 0
for (const item of items) {
if (size >= limits.maxItems) break
const itemValues = jsonValueCount(item)
const itemBytes = utf8Length(JSON.stringify(item) ?? '') + (size > 0 ? 1 : 0)
if (size > 0 && (values + itemValues > limits.maxValues || bytes + itemBytes > limits.maxBytes)) break
values += itemValues
bytes += itemBytes
size += 1
}
return size
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { encodeEucKr } from '@/test-utils/euc-kr'
import { detectCsvDelimiter, parseCsv } from './csv'
import { decodeText, decodeWithCharset, looksBinary } from './text'
// CSV 읽기 · 글자 풀기 (16단계 가져오기 — 디자인 원본 Contacts16 "6 받는 파일")
describe('parseCsv', () => {
it('따옴표로 감싼 칸 안의 쉼표 · 줄바꿈 · "" 를 그대로 둔다', () => {
const text = '이름,메모\r\n강민지,"견적은 월말 전에, ""꼭""\n보내기"\r\n박지훈,\r\n'
expect(parseCsv(text)).toEqual([
['이름', '메모'],
['강민지', '견적은 월말 전에, "꼭"\n보내기'],
['박지훈', ''],
])
})
it('줄 끝은 CRLF · LF · CR 모두 받고, 글 끝의 줄바꿈 뒤는 줄로 세지 않는다', () => {
expect(parseCsv('a,b\nc,d\re,f\r\n')).toEqual([
['a', 'b'],
['c', 'd'],
['e', 'f'],
])
expect(parseCsv('a,b')).toEqual([['a', 'b']])
})
it('빈 줄도 한 줄로 둔다 — 엑셀의 행 번호와 맞게 (부르는 쪽이 건너뛴다)', () => {
expect(parseCsv('이름\n\n강민지\n')).toEqual([['이름'], [''], ['강민지']])
})
it('구분자는 첫 줄에 가장 많은 것 — 탭 · 세미콜론도 받는다', () => {
expect(detectCsvDelimiter('이름\t이메일\t회사\n김\ta@b.kr\tX')).toBe('\t')
expect(detectCsvDelimiter('Name;E-mail;Phone\nKim;a@b.kr;010')).toBe(';')
expect(detectCsvDelimiter('"a,b";c\nx')).toBe(';')
expect(parseCsv('이름\t메모\n김\t"a\tb"')).toEqual([
['이름', '메모'],
['김', 'a\tb'],
])
})
it('닫히지 않은 따옴표 · 칸 가운데의 따옴표는 느슨하게 읽는다', () => {
expect(parseCsv('a,"bc\nd')).toEqual([['a', 'bc\nd']])
expect(parseCsv('a,b"c,d')).toEqual([['a', 'b"c', 'd']])
})
})
describe('decodeText', () => {
it('UTF-8 BOM 은 떼고 UTF-8 로 읽는다 (네이버 · 우리 내보내기)', () => {
const bytes = Uint8Array.from([0xef, 0xbb, 0xbf, ...new TextEncoder().encode('이름,이메일')])
expect(decodeText(bytes)).toBe('이름,이메일')
})
it('BOM 이 없으면 UTF-8, 틀린 바이트가 있으면 EUC-KR (한국어 엑셀의 CSV)', () => {
expect(decodeText(new TextEncoder().encode('김아라,ara@x.kr'))).toBe('김아라,ara@x.kr')
// 시험은 KS X 1001 글자만 쓴다 — Node(ICU)의 euc-kr 은 CP949 확장 글자(똠 등)를 모른다(브라우저는 WHATWG 대로 안다)
expect(decodeText(encodeEucKr('성,이름\n강,민지\n홍길동'))).toBe('성,이름\n강,민지\n홍길동')
})
it('UTF-16 BOM 이 있으면 그 꼴로 읽는다', () => {
const utf16 = Uint8Array.from([0xff, 0xfe, 0x74, 0xc7, 0x84, 0xb9])
expect(decodeText(utf16)).toBe('이름')
})
it('vCard 2.1 의 CHARSET — 모르는 이름이면 UTF-8, cp949 처럼 브라우저가 모르는 한국어 이름은 EUC-KR', () => {
const euckr = encodeEucKr('강민지')
expect(decodeWithCharset(euckr, 'EUC-KR')).toBe('강민지')
expect(decodeWithCharset(euckr, 'cp949')).toBe('강민지')
expect(decodeWithCharset(new TextEncoder().encode('강민지'), null)).toBe('강민지')
expect(decodeWithCharset(new TextEncoder().encode('강민지'), 'x-unknown')).toBe('강민지')
})
it('NUL 이 든 글은 바이너리로 본다', () => {
expect(looksBinary('PNG\u0000\u0001')).toBe(true)
expect(looksBinary('이름,이메일')).toBe(false)
})
})
@@ -0,0 +1,92 @@
// CSV 읽기 — 순수 함수 (16단계 가져오기)
// RFC 4180 꼴: 따옴표로 감싼 칸 안의 구분자 · 줄바꿈 · "" (따옴표 하나)를 받는다. 줄 끝은 CRLF · LF · CR 모두.
// 구분자는 첫 줄(따옴표 밖)에 쉼표 · 탭 · 세미콜론 가운데 가장 많은 것 — 같으면 쉼표 (엑셀의 "텍스트(탭으로 분리)"도 받는다).
// 글 길이에 비례하게 한 번만 훑는다 — 정규식 되돌림 없이
const QUOTE = '"'
const CANDIDATE_DELIMITERS = [',', '\t', ';'] as const
export type CsvDelimiter = (typeof CANDIDATE_DELIMITERS)[number]
/** 첫 줄(따옴표 밖)에서 가장 많이 나온 구분자 */
export function detectCsvDelimiter(text: string): CsvDelimiter {
const counts = new Map<CsvDelimiter, number>(CANDIDATE_DELIMITERS.map((delimiter) => [delimiter, 0]))
let quoted = false
for (const char of text) {
if (char === QUOTE) {
quoted = !quoted
continue
}
if (quoted) continue
if (char === '\n' || char === '\r') break
const count = counts.get(char as CsvDelimiter)
if (count !== undefined) counts.set(char as CsvDelimiter, count + 1)
}
let best: CsvDelimiter = ','
for (const delimiter of CANDIDATE_DELIMITERS) {
if ((counts.get(delimiter) ?? 0) > (counts.get(best) ?? 0)) best = delimiter
}
return best
}
/**
* CSV 글 → 줄마다 칸. 빈 줄도 한 줄로 둔다(엑셀의 행 번호와 맞게 — 부르는 쪽이 건너뛴다). 글 끝의 줄바꿈 뒤는 줄로 세지 않는다.
* 따옴표가 닫히지 않은 채 끝나면 끝까지를 그 칸으로 본다 (느슨하게)
*/
export function parseCsv(text: string, delimiter: CsvDelimiter = detectCsvDelimiter(text)): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
/** 따옴표로 감싼 칸 안이다 — 닫는 따옴표 뒤의 글은 그대로 붙인다(느슨하게) */
let quoted = false
let index = 0
const length = text.length
const endField = () => {
row.push(field)
field = ''
}
const endRow = () => {
endField()
rows.push(row)
row = []
}
while (index < length) {
const char = text[index] ?? ''
if (quoted) {
if (char === QUOTE) {
if (text[index + 1] === QUOTE) {
field += QUOTE
index += 2
continue
}
quoted = false
index += 1
continue
}
field += char
index += 1
continue
}
if (char === QUOTE && field === '') {
quoted = true
index += 1
continue
}
if (char === delimiter) {
endField()
index += 1
continue
}
if (char === '\r' || char === '\n') {
endRow()
index += char === '\r' && text[index + 1] === '\n' ? 2 : 1
continue
}
field += char
index += 1
}
// 마지막 줄 — 글이 줄바꿈으로 끝났으면 더할 것이 없다
if (field !== '' || row.length > 0 || quoted) endRow()
return rows
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { CONTACT_IMPORT_FILE_MAX_BYTES } from '@/shared/constants/contacts'
import { encodeEucKr } from '@/test-utils/euc-kr'
import { buildXlsx, buildZip } from '@/test-utils/zip'
import { readContactFile } from './read-file'
// 파일 하나 읽기 — 형식은 이름 끝과 파일 앞부분을 함께 본다 (디자인 원본 Contacts16 "2" — 읽지 못한 까닭)
function fileOf(name: string, data: Uint8Array | string): File {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
return new File([bytes.slice()], name)
}
/** 옛 오피스 파일(CFB)의 머리 + 목록 이름(UTF-16LE) */
function cfbWith(streamName: string): Uint8Array {
const name: number[] = []
for (const char of streamName) name.push(char.charCodeAt(0), 0)
return Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, ...new Array(64).fill(0), ...name])
}
describe('readContactFile', () => {
it('20MB 를 넘으면 읽지 않고 바로 tooLarge', async () => {
const huge = { name: 'big.vcf', size: CONTACT_IMPORT_FILE_MAX_BYTES + 1, arrayBuffer: () => Promise.reject(new Error('읽으면 안 된다')) }
expect(await readContactFile(huge)).toEqual({ ok: false, problem: 'tooLarge', kind: null })
})
it('네이버 CSV(UTF-8 BOM · 값 앞의 \') — 표로 읽는다', async () => {
const csv = "\uFEFF성,이름,휴대폰번호,이메일,회사·소속명\n'강,'민지,'01041203387,'minji.kang@example.com,'한빛상사\n"
const result = await readContactFile(fileOf('NAVER_Contacts.csv', csv))
expect(result).toMatchObject({ ok: true, kind: 'table' })
expect(result.ok && result.records[0]).toMatchObject({ position: 2, name: '강민지', company: '한빛상사' })
})
it('EUC-KR CSV(한국어 엑셀의 "CSV(쉼표로 분리)")', async () => {
const bytes = encodeEucKr('이름,전자 메일 주소,휴대폰\r\n홍길동,gildong@example.com,010-1111-2222\r\n')
const result = await readContactFile(fileOf('주소록.csv', bytes))
expect(result.ok && result.records[0]).toMatchObject({
name: '홍길동',
emails: [{ address: 'gildong@example.com', label: 'work' }],
})
})
it('이름이 .csv 여도 안이 엑셀이면 엑셀로 읽는다', async () => {
const bytes = await buildXlsx([
['이름', '이메일'],
['김도현', 'dohyun.kim@example.net'],
])
const result = await readContactFile(fileOf('contacts.csv', bytes))
expect(result).toMatchObject({ ok: true, kind: 'table', records: [{ name: '김도현' }] })
})
it('vCard — 이름이 .txt 여도 첫 줄이 BEGIN:VCARD 면 vCard', async () => {
const text = 'BEGIN:VCARD\nVERSION:3.0\nFN:문가은\nEMAIL:gaeun.moon@example.net\nEND:VCARD\n'
expect(await readContactFile(fileOf('export.txt', text))).toMatchObject({
ok: true,
kind: 'vcard',
records: [{ position: 1, name: '문가은' }],
})
})
it('옛 엑셀(.xls) · 암호를 건 엑셀 · 그 밖의 옛 오피스 파일을 가른다', async () => {
expect(await readContactFile(fileOf('list.xls', cfbWith('Workbook')))).toMatchObject({ ok: false, problem: 'xls' })
expect(await readContactFile(fileOf('list.csv', cfbWith('Workbook')))).toMatchObject({ ok: false, problem: 'xls' })
expect(await readContactFile(fileOf('secret.xlsx', cfbWith('EncryptedPackage')))).toMatchObject({
ok: false,
problem: 'unreadable',
})
expect(await readContactFile(fileOf('memo.doc', cfbWith('WordDocument')))).toMatchObject({
ok: false,
problem: 'unsupported',
})
})
it('넣을 수 없는 형식 — 그림 · 워드(zip) · 이름 끝을 모르는 글', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00])
expect(await readContactFile(fileOf('photo.png', png))).toMatchObject({ ok: false, problem: 'unsupported' })
const docx = await buildZip([{ name: 'word/document.xml', data: '<w:document/>' }])
expect(await readContactFile(fileOf('resume.docx', docx))).toMatchObject({ ok: false, problem: 'unsupported' })
expect(await readContactFile(fileOf('notes.md', '# 메모'))).toMatchObject({ ok: false, problem: 'unsupported' })
})
it('.xlsx 인데 zip 이 아니면 열지 못한 것이다', async () => {
expect(await readContactFile(fileOf('broken.xlsx', '이름,이메일'))).toMatchObject({ ok: false, problem: 'unreadable' })
})
it('연락처를 찾지 못함 — 이름 · 이메일 열이 없는 CSV, 카드가 없는 vCard(vCard 는 kind 로 앞 문장만 쓴다)', async () => {
expect(await readContactFile(fileOf('a.csv', '날짜,금액\n2026-11-10,3000\n'))).toEqual({
ok: false,
problem: 'notFound',
kind: 'table',
})
expect(await readContactFile(fileOf('a.vcf', 'VERSION:3.0\n'))).toEqual({ ok: false, problem: 'notFound', kind: 'vcard' })
})
})
@@ -0,0 +1,99 @@
// 연락처 파일 하나를 읽는다 — 형식을 알아보고(이름 끝 + 파일 앞부분) 연락처를 꺼낸다 (16단계 가져오기 · 디자인 원본 Contacts16 "2")
// - 20MB 를 넘으면 읽지 않고 바로 tooLarge
// - 안이 zip 이면 엑셀(.xlsx)로 — 이름이 .csv 여도. xl/workbook.xml 이 없는 zip(워드 · 그냥 zip)은 unsupported
// - 안이 옛 오피스 파일(CFB)이면: 암호를 건 .xlsx(EncryptedPackage)는 unreadable, 옛 엑셀(Workbook · Book)은 xls, 그 밖은 unsupported
// - 이름이 .xls 면 xls, .xlsx 인데 zip 이 아니면 unreadable
// - 그 밖은 글로 풀어(BOM → UTF-8 → EUC-KR) 첫 줄이 BEGIN:VCARD 면 vCard, 이름이 .vcf 면 vCard, .csv · .tsv · .txt 면 CSV, 아니면 unsupported
// 파일은 서버로 보내지 않는다 — 이 브라우저에서만 읽는다
import { CONTACT_IMPORT_FILE_MAX_BYTES } from '@/shared/constants/contacts'
import { parseCsv } from './csv'
import { tableRecords } from './table'
import { decodeText, looksBinary } from './text'
import type { ContactFileReadResult, ImportedFileKind, TableRow } from './types'
import { looksLikeVCard, parseVCards } from './vcard'
import { NotSpreadsheetError, readXlsxRows } from './xlsx'
import { isZip } from './zip'
/** 옛 오피스 파일(CFB — .xls · .doc · 암호를 건 .xlsx)의 처음 여덟 바이트 */
const CFB_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] as const
const CSV_EXTENSIONS = new Set(['csv', 'tsv', 'txt'])
const VCARD_EXTENSIONS = new Set(['vcf', 'vcard'])
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
}
function isCfb(bytes: Uint8Array): boolean {
return CFB_SIGNATURE.every((value, index) => bytes[index] === value)
}
function matchesAt(bytes: Uint8Array, pattern: readonly number[], index: number): boolean {
return pattern.every((value, offset) => bytes[index + offset] === value)
}
/** UTF-16LE 로 적힌 이름이 바이트 안에 있는가 (CFB 의 목록 이름) */
function containsUtf16Name(bytes: Uint8Array, name: string): boolean {
const pattern: number[] = []
for (const char of name) pattern.push(char.charCodeAt(0), 0)
const first = pattern[0]
for (let index = 0; index + pattern.length <= bytes.length; index += 1) {
if (bytes[index] === first && matchesAt(bytes, pattern, index)) return true
}
return false
}
function failed(problem: Exclude<ContactFileReadResult, { ok: true }>['problem'], kind: ImportedFileKind | null = null): ContactFileReadResult {
return { ok: false, problem, kind }
}
async function readSpreadsheet(bytes: Uint8Array): Promise<ContactFileReadResult> {
let rows: TableRow[]
try {
rows = await readXlsxRows(bytes)
} catch (caught) {
return failed(caught instanceof NotSpreadsheetError ? 'unsupported' : 'unreadable', 'table')
}
return tableResult(rows)
}
function tableResult(rows: readonly TableRow[]): ContactFileReadResult {
const table = tableRecords(rows)
if (!table.recognized || table.records.length === 0) return failed('notFound', 'table')
return { ok: true, kind: 'table', records: table.records }
}
function textResult(text: string, extension: string): ContactFileReadResult {
if (looksLikeVCard(text) || VCARD_EXTENSIONS.has(extension)) {
const records = parseVCards(text)
return records.length > 0 ? { ok: true, kind: 'vcard', records } : failed('notFound', 'vcard')
}
if (!CSV_EXTENSIONS.has(extension)) return failed('unsupported')
const rows = parseCsv(text).map((cells, index) => ({ position: index + 1, cells }))
return tableResult(rows)
}
/** 파일 하나를 읽는다 — 읽기(파일 · 압축 풀기)만 기다린다 */
export async function readContactFile(file: Pick<File, 'name' | 'size' | 'arrayBuffer'>): Promise<ContactFileReadResult> {
if (file.size > CONTACT_IMPORT_FILE_MAX_BYTES) return failed('tooLarge')
const extension = extensionOf(file.name)
let bytes: Uint8Array
try {
bytes = new Uint8Array(await file.arrayBuffer())
} catch {
return failed('unreadable')
}
if (bytes.length > CONTACT_IMPORT_FILE_MAX_BYTES) return failed('tooLarge')
if (isZip(bytes)) return readSpreadsheet(bytes)
if (isCfb(bytes)) {
if (containsUtf16Name(bytes, 'EncryptedPackage')) return failed('unreadable', 'table')
if (containsUtf16Name(bytes, 'Workbook') || containsUtf16Name(bytes, 'Book')) return failed('xls', 'table')
return failed('unsupported')
}
if (extension === 'xls') return failed('xls', 'table')
if (extension === 'xlsx') return failed('unreadable', 'table')
const text = decodeText(bytes)
if (looksBinary(text)) return failed('unsupported')
return textResult(text, extension)
}
@@ -0,0 +1,341 @@
import { describe, expect, it } from 'vitest'
import type { ContactGroup } from '@/shared/types/contacts'
import {
contactGroupKey,
duplicateReasonText,
fileGroupsOf,
fileGroupsText,
importPlanOf,
importPositionText,
infoSampleOf,
namePhonesKey,
noteWithInfo,
phoneDigitsKey,
prepareImport,
previewOf,
previewRowsOf,
} from './records'
import { emptyImportedRecord, type ImportedRecord } from './types'
// 읽은 연락처 → 넣을 · 이미 있는 · 넣지 못하는 (디자인 원본 Contacts16 "3" · 계약 3절 — 서버와 같은 검사 · 겹침 규칙)
function record(position: number, patch: Partial<ImportedRecord>): ImportedRecord {
return { ...emptyImportedRecord(position), ...patch }
}
let uidSeq = 0
const uid = () => {
uidSeq += 1
return `urn:uuid:00000000-0000-4000-8000-${String(uidSeq).padStart(12, '0')}`
}
const GROUPS: ContactGroup[] = [
{ id: 'g1', name: '거래처', memberCount: 4 },
{ id: 'g2', name: '세무', memberCount: 2 },
{ id: 'g3', name: 'Project Alpha', memberCount: 3 },
]
describe('prepareImport', () => {
it('넣지 못하는 줄 — 이메일 형식 → 이름과 이메일이 모두 없음 → 한 줄 칸 100자 → 메모 1,000자', () => {
const prepared = prepareImport(
'table',
[
record(57, { phones: [{ number: '01090346621', label: 'mobile' }] }),
record(203, { name: '정해린', emails: [{ address: 'haerin.jung@@example.net', label: 'work' }] }),
record(204, { name: '가'.repeat(101) }),
record(205, { name: '나', company: '회'.repeat(101) }),
record(206, { name: '다', note: '메'.repeat(1001) }),
],
uid,
)
expect(prepared.candidates).toEqual([])
expect(prepared.problems).toEqual([
{ position: 57, what: '010-9034-6621', reason: '이름과 이메일이 모두 없습니다.' },
{ position: 203, what: '정해린 · haerin.jung@@example.net', reason: '이메일 형식이 올바르지 않습니다.' },
{ position: 204, what: '가'.repeat(101), reason: '이름은 100자까지 쓸 수 있습니다.' },
{ position: 205, what: `나 · ${'회'.repeat(101)}`, reason: '회사는 100자까지 쓸 수 있습니다.' },
{ position: 206, what: '다', reason: '메모는 1,000자까지 쓸 수 있습니다.' },
])
})
it('값을 다듬는다 — 이메일 겹침 빼기(대소문자 무시), 전화 하이픈, 형식에 맞지 않는 전화는 가져온 정보로', () => {
const prepared = prepareImport(
'table',
[
record(2, {
name: ' 강민지 ',
emails: [
{ address: 'Minji.Kang@Example.com', label: 'work' },
{ address: 'minji.kang@example.com ', label: 'personal' },
],
phones: [
{ number: '01041203387', label: 'mobile' },
{ number: '010-4120-3387', label: 'mobile' },
{ number: '02-555-0147 내선 3', label: 'work' },
],
note: '첫 줄\r\n둘째 줄\t끝',
groups: ['거래처', ' 거래처 ', '* myContacts', '동호회'],
info: [{ kind: 'nickname', value: '민지' }],
}),
],
uid,
)
const [candidate] = prepared.candidates
expect(candidate?.input).toEqual({
name: '강민지',
emails: [{ address: 'Minji.Kang@Example.com', label: 'work' }],
phones: [{ number: '010-4120-3387', label: 'mobile' }],
company: null,
department: null,
title: null,
note: '첫 줄\n둘째 줄 끝',
groupIds: [],
})
expect(candidate?.groups).toEqual(['거래처', '동호회'])
expect(candidate?.info).toEqual(['별명: 민지', '전화: 02-555-0147 내선 3'])
expect(candidate?.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/)
})
it('파일 안의 겹침 — 같은 이메일은 앞 줄과 같음, 이메일이 없으면 이름과 전화(숫자만)가 모두 같을 때', () => {
const prepared = prepareImport(
'table',
[
record(2, { name: '강민지', emails: [{ address: 'minji.kang@example.com', label: 'work' }] }),
record(3, { name: '김도현', phones: [{ number: '010-5521-0934', label: 'mobile' }] }),
record(31, { name: '강민지2', emails: [{ address: 'MINJI.KANG@example.com', label: 'work' }] }),
record(40, { name: ' 김도현', phones: [{ number: '01055210934', label: 'mobile' }] }),
record(41, { name: '김도현', phones: [{ number: '010-0000-0000', label: 'mobile' }] }),
],
uid,
)
expect(prepared.candidates.map((item) => item.position)).toEqual([2, 3, 41])
expect(prepared.duplicates).toEqual([
{ position: 31, name: '강민지2', detail: 'MINJI.KANG@example.com', samePosition: 2 },
{ position: 40, name: '김도현', detail: '010-5521-0934', samePosition: 3 },
])
expect(prepared.found).toBe(5)
})
it('이름 + 전화 열쇠는 서버(contact-import.ts 의 phoneDigitsKey)와 같다 — 숫자만 · 숫자 없는 번호 빼기 · 겹침 없이 · 차례대로', () => {
expect(
phoneDigitsKey([{ number: '02-555-0147' }, { number: '(02) 555-0147' }, { number: '#*' }, { number: '010-1' }]),
).toBe('0101,025550147')
expect(phoneDigitsKey([])).toBe('')
expect(namePhonesKey({ name: ' 김도현 ', phones: [{ number: '010-5521-0934', label: 'mobile' }] })).toBe(
'김도현\u000001055210934',
)
expect(namePhonesKey({ name: ' ', phones: [] })).toBeNull()
})
it('같은 번호가 두 번 적힌 줄도 한 번 적힌 줄과 같은 사람이다 (겹침을 없애고 견준다)', () => {
const prepared = prepareImport(
'table',
[
record(2, {
name: '김도현',
phones: [
{ number: '02-555-0147', label: 'work' },
{ number: '(02) 555-0147', label: 'other' },
],
}),
record(3, { name: '김도현', phones: [{ number: '025550147', label: 'work' }] }),
],
uid,
)
expect(prepared.candidates.map((item) => item.position)).toEqual([2])
expect(prepared.duplicates).toEqual([{ position: 3, name: '김도현', detail: '02-555-0147', samePosition: 2 }])
})
it('숫자 없는 번호는 열쇠에 넣지 않는다 — 가져온 정보로 가고, 번호 하나만 적힌 줄과 같은 사람이다', () => {
const prepared = prepareImport(
'table',
[
record(2, {
name: '문가은',
phones: [
{ number: '010-7714-2093', label: 'mobile' },
{ number: '내선 없음', label: 'other' },
],
}),
record(3, { name: '문가은', phones: [{ number: '01077142093', label: 'mobile' }] }),
],
uid,
)
expect(prepared.candidates[0]?.info).toEqual(['전화: 내선 없음'])
expect(prepared.duplicates.map((item) => [item.position, item.samePosition])).toEqual([[3, 2]])
})
it('이메일 · 전화는 20개까지만 담는다 (서버 한도)', () => {
const emails = Array.from({ length: 25 }, (_, index) => ({ address: `p${index}@example.com`, label: 'work' as const }))
const [candidate] = prepareImport('vcard', [record(1, { name: '많음', emails })], uid).candidates
expect(candidate?.input.emails).toHaveLength(20)
})
})
describe('previewOf', () => {
const prepared = prepareImport(
'table',
[
record(2, { name: '강민지', emails: [{ address: 'minji@example.com', label: 'work' }], groups: ['거래처'] }),
record(4, { name: '박지훈', emails: [{ address: 'jihoon@example.com', label: 'work' }] }),
record(5, { name: '문가은', emails: [{ address: 'gaeun@example.com', label: 'work' }], groups: ['동호회'] }),
record(6, { name: '강민지', emails: [{ address: 'minji@example.com', label: 'work' }] }),
record(7, { phones: [{ number: '010', label: 'mobile' }] }),
],
uid,
)
const statuses = new Map(prepared.candidates.map((item) => [item.uid, item.position === 4 ? 'exists' : 'new'] as const))
it('주소록에 있는 연락처는 건너뛰고(줄 차례로 합친다), 남은 자리만큼 앞에서부터 넣는다', () => {
const preview = previewOf(prepared, statuses, 1)
expect(preview.toInsert.map((item) => item.position)).toEqual([2])
expect(preview.overLimit).toBe(1)
expect(preview.duplicates.map((item) => [item.position, item.samePosition])).toEqual([
[4, null],
[6, 2],
])
expect(preview.problems.map((item) => item.position)).toEqual([7])
expect(preview.found).toBe(5)
})
it('묻지 않은 연락처(남은 자리를 다 채워 멈춤)는 새 연락처로 본다', () => {
const preview = previewOf(prepared, new Map(), null)
expect(preview.toInsert.map((item) => item.position)).toEqual([2, 4, 5])
expect(preview.overLimit).toBe(0)
})
it('표 줄 · 줄 번호 · 겹침 까닭 글', () => {
const preview = previewOf(prepared, statuses, null)
expect(previewRowsOf(preview)).toEqual([
{ key: prepared.candidates[0]?.uid, name: '강민지', email: 'minji@example.com', phone: '', company: '', groups: '거래처' },
{ key: prepared.candidates[2]?.uid, name: '문가은', email: 'gaeun@example.com', phone: '', company: '', groups: '동호회' },
])
expect(importPositionText('table', 4)).toBe('4번째 줄')
expect(importPositionText('vcard', 12)).toBe('12번째 연락처')
expect(duplicateReasonText('table', preview.duplicates[0]!)).toBe('주소록에 있음')
expect(duplicateReasonText('table', preview.duplicates[1]!)).toBe('2번째 줄과 같음')
expect(duplicateReasonText('vcard', preview.duplicates[1]!)).toBe('2번째 연락처와 같음')
})
})
describe('그룹 글', () => {
function candidatesWith(groups: string[][]) {
return prepareImport(
'table',
groups.map((names, index) => record(index + 2, { name: `사람${index}`, groups: names })),
uid,
).candidates
}
it('이미 있는 그룹 · 새로 만들 그룹 — 그룹 키는 앞뒤 공백 · ASCII 대소문자 무시', () => {
expect(contactGroupKey(' Project ALPHA ')).toBe('project alpha')
const summary = fileGroupsOf(candidatesWith([['거래처', '동호회'], ['세무', 'project alpha', '학교']]), GROUPS)
expect(summary).toEqual({ existing: ['거래처', '세무', 'Project Alpha'], created: ['동호회', '학교'] })
// 라틴 글자로 끝나는 이름은 소리를 알 수 없어 "은(는)"
expect(fileGroupsText(summary)).toBe(
'파일에 적힌 그룹 5개 가운데 거래처, 세무, Project Alpha은(는) 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다.',
)
})
it('보드의 글 — "거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다."', () => {
const summary = fileGroupsOf(candidatesWith([['거래처', '동호회'], ['세무', '학교']]), GROUPS)
expect(fileGroupsText(summary)).toBe('파일에 적힌 그룹 4개 가운데 거래처, 세무는 이미 있는 그룹에 넣고 동호회, 학교는 새로 만듭니다.')
})
it('한쪽뿐 · 없음 · 넷 넘으면 앞 셋과 외 N개', () => {
expect(fileGroupsText({ existing: ['거래처'], created: [] })).toBe('파일에 적힌 그룹 거래처는 이미 있는 그룹에 넣습니다.')
expect(fileGroupsText({ existing: [], created: ['동호회', '학교'] })).toBe('파일에 적힌 그룹 동호회, 학교는 새로 만듭니다.')
expect(fileGroupsText({ existing: [], created: [] })).toBe('파일에 적힌 그룹이 없습니다.')
expect(fileGroupsText({ existing: [], created: ['가', '나', '다', '라', '마'] })).toBe(
'파일에 적힌 그룹 가, 나, 다 외 2개는 새로 만듭니다.',
)
})
})
describe('가져온 정보 · 넣을 계획', () => {
it('메모 끝에 빈 줄 하나와 가져온 정보를 붙인다 — 1,000자를 넘게 되면 붙이지 않는다', () => {
expect(noteWithInfo('견적 문의는 월말 전에 보내기.', ['회사 주소: 서울 중구 세종대로 110', '별명: 민지'])).toBe(
'견적 문의는 월말 전에 보내기.\n\n가져온 정보\n회사 주소: 서울 중구 세종대로 110\n별명: 민지',
)
expect(noteWithInfo(null, ['별명: 민지'])).toBe('가져온 정보\n별명: 민지')
expect(noteWithInfo('메'.repeat(990), ['별명: 민지'])).toBe('메'.repeat(990))
expect(noteWithInfo('그대로', [])).toBe('그대로')
})
it('가져온 정보의 값은 한 줄 글로 다듬는다 — 탭 · \\r · 제어 문자가 메모에 남으면 서버가 그 묶음 전체를 400 으로 거절한다', () => {
const prepared = prepareImport(
'vcard',
[
record(1, {
name: '강민지',
phones: [{ number: '02-555\u0001-0147\t내선 3', label: 'work' }],
info: [
{ kind: 'nickname', value: '민\t지\r\n' },
{ kind: 'nickname', value: '\u0007' },
{ kind: 'homepage', value: 'https://example.com/\u0000a' },
],
}),
],
uid,
)
expect(prepared.candidates[0]?.info).toEqual(['별명: 민 지', '홈페이지: https://example.com/ a', '전화: 02-555 -0147 내선 3'])
const plan = importPlanOf(previewOf(prepared, new Map(), 3000), GROUPS, { kind: 'none' }, true)
const note = plan.entries[0]?.item.note ?? ''
expect(note).toBe('가져온 정보\n별명: 민 지\n홈페이지: https://example.com/ a\n전화: 02-555 -0147 내선 3')
// 서버의 메모 규칙 — 줄바꿈(\n) 밖의 제어 문자(C0 · DEL · C1)는 받지 않는다
const controls = [...note].filter((char) => {
const code = char.charCodeAt(0)
return char !== '\n' && (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
})
expect(controls).toEqual([])
})
it('붙인 메모는 연락처 창과 같은 메모 정리를 한 번 더 지난다', () => {
expect(noteWithInfo('첫 줄\r\n둘째\t줄', ['별명: 민\u0001지'])).toBe('첫 줄\n둘째 줄\n\n가져온 정보\n별명: 민 지')
})
it('견본은 정보가 붙는 첫 연락처 — 원래 메모 · 붙는 줄', () => {
const candidates = prepareImport(
'table',
[
record(2, { name: '정보 없음' }),
record(3, { name: '강민지', note: '견적 문의', info: [{ kind: 'birthday', value: '1988년 3월 14일 (음력)' }] }),
],
uid,
).candidates
expect(infoSampleOf(candidates)).toEqual({
name: '강민지',
original: ['견적 문의'],
added: ['가져온 정보', '생일: 1988년 3월 14일 (음력)'],
})
})
it('계획 — 그룹 열쇠 · 모두 넣을 그룹(이미 있는 이름이면 그 그룹) · 메모 붙이기 · 건너뛴 수', () => {
const prepared = prepareImport(
'table',
[
record(2, { name: '강민지', emails: [{ address: 'minji@example.com', label: 'work' }], groups: ['거래처', '동호회'], info: [{ kind: 'nickname', value: '민지' }] }),
record(3, { name: '박지훈', emails: [{ address: 'minji@example.com', label: 'work' }] }),
record(4, { name: '문가은', emails: [{ address: 'gaeun@example.com', label: 'work' }] }),
],
uid,
)
const preview = previewOf(prepared, new Map(), 3000)
const plan = importPlanOf(preview, GROUPS, { kind: 'new', name: ' 세무 ' }, true)
expect(plan.groups).toEqual([
{ key: '세무', name: '세무', id: 'g2' },
{ key: '거래처', name: '거래처', id: 'g1' },
{ key: '동호회', name: '동호회', id: null },
])
expect(plan.entries.map((entry) => entry.groupKeys)).toEqual([['거래처', '동호회', '세무'], ['세무']])
expect(plan.entries[0]?.item.note).toBe('가져온 정보\n별명: 민지')
expect(plan.entries[0]?.item.uid).toBe(prepared.candidates[0]?.uid)
expect(plan.skipped).toBe(1)
expect(plan.overLimit).toBe(0)
const without = importPlanOf(preview, GROUPS, { kind: 'existing', id: 'g3' }, false)
expect(without.entries[0]?.item.note).toBeNull()
expect(without.entries.map((entry) => entry.groupKeys)).toEqual([['거래처', '동호회', 'project alpha'], ['project alpha']])
})
})
@@ -0,0 +1,508 @@
// 읽은 연락처 → 넣을 연락처 · 이미 있는 연락처 · 넣지 못하는 줄 (16단계 가져오기 · 디자인 원본 Contacts16 "3" · 계약 3절)
// - 검사는 연락처 추가 창 · 서버와 같거나 더 좁다(contacts.ts · mail-address.ts 의 isStrictEmail) — 화면이 넘긴 줄은 서버도 받는다
// 넣지 못하는 까닭: 이메일 형식 → 이름과 이메일이 모두 없음 → 이름 · 회사 · 부서 · 직책 100자 → 메모 1,000자
// - 파일 안의 겹침(서버와 같은 규칙 — 짝: backend modules/contacts/contact-import.ts): 같은 이메일(앞뒤 공백 · 대소문자 무시)이 앞 줄에 있으면 "N번째 줄과 같음",
// 이메일이 없으면 이름(앞뒤 공백만 뺌)과 전화 숫자 모음(번호마다 숫자만 — 겹침 · 차례 무시, 숫자 없는 번호는 뺌)이 모두 같은 앞 줄.
// 주소록과의 겹침은 서버가 본다(POST /contact-import-checks)
// - 형식이 맞지 않는 전화번호(내선 · 글자가 든 번호)는 버리지 않고 "가져온 정보"의 전화 줄로 둔다. 전화 · 이메일은 20개까지
// - 넣을 때 메모 끝에 "가져온 정보"를 붙인다(고를 수 있다) — 붙이면 1,000자를 넘는 연락처는 그 연락처만 붙이지 않는다.
// 정보 값은 한 줄 글로 다듬고(cleanText), 붙인 메모는 연락처 창과 같은 메모 정리를 한 번 더 지난다(서버는 메모의 줄바꿈 밖 제어 문자를 받지 않는다)
import {
CONTACT_EMAIL_MAX_LENGTH,
CONTACT_EMAILS_MAX,
CONTACT_GROUP_NAME_MAX_LENGTH,
CONTACT_IMPORT_GROUP_NAMES_SHOWN,
CONTACT_IMPORT_INFO_TEXT,
CONTACT_IMPORT_PREVIEW_ROWS,
CONTACT_IMPORT_PROBLEM_TEXT,
CONTACT_IMPORT_TEXT,
CONTACT_NOTE_MAX_LENGTH,
CONTACT_PHONES_MAX,
CONTACT_TEXT_MAX_LENGTH,
} from '@/shared/constants/contacts'
import type {
ContactEmail,
ContactGroup,
ContactImportCheckStatus,
ContactImportItem,
ContactInput,
ContactPhone,
} from '@/shared/types/contacts'
import { contactNoteText, isValidContactPhone } from '@/shared/utils/contacts'
import { formatCount } from '@/shared/utils/format'
import { isStrictEmail, normalizeEmail } from '@/shared/utils/mail-address'
import type { ImportedFileKind, ImportedInfo, ImportedInfoKind, ImportedRecord } from './types'
import { cleanGroupName, cleanText, formatKoreanPhone } from './values'
/** 넣을 수 있는 연락처 하나 — 서버의 검사를 통과하는 값 (그룹 · 가져온 정보는 넣을 때 붙인다) */
export interface ImportCandidate {
position: number
/** urn:uuid: + 소문자 UUID — 확인 · 넣기 · 다시 넣기가 같은 값을 쓴다 */
uid: string
/** groupIds 는 비어 있다. note 는 파일의 메모 그대로(가져온 정보 없이) */
input: ContactInput
/** 파일에 적힌 그룹 이름 — 다듬었고 겹치지 않는다 */
groups: string[]
/** 가져온 정보 줄 ("회사 주소: 서울 중구 세종대로 110") */
info: string[]
}
/** 넣지 못하는 줄 */
export interface ImportProblem {
position: number
/** 그 줄에 적힌 것 — 이름 · 이메일 · 전화 · 회사 가운데 앞의 둘 */
what: string
reason: string
}
/** 이미 있는 연락처 — samePosition 이 있으면 파일 앞 줄과 같음, null 이면 주소록에 있음 */
export interface ImportDuplicate {
position: number
name: string
/** 이메일 — 없으면 전화번호 */
detail: string
samePosition: number | null
}
export interface PreparedImport {
kind: ImportedFileKind
/** 파일에서 찾은 연락처 수 (넣지 못하는 줄 포함) */
found: number
/** 파일 안에서 겹치지 않는 넣을 수 있는 연락처 — 파일 차례 */
candidates: ImportCandidate[]
/** 파일 안의 겹침 */
duplicates: ImportDuplicate[]
problems: ImportProblem[]
}
const INFO_LABELS: Readonly<Record<ImportedInfoKind, string>> = {
workAddress: CONTACT_IMPORT_INFO_TEXT.workAddress,
homeAddress: CONTACT_IMPORT_INFO_TEXT.homeAddress,
otherAddress: CONTACT_IMPORT_INFO_TEXT.otherAddress,
address: CONTACT_IMPORT_INFO_TEXT.address,
birthday: CONTACT_IMPORT_INFO_TEXT.birthday,
anniversary: CONTACT_IMPORT_INFO_TEXT.anniversary,
homepage: CONTACT_IMPORT_INFO_TEXT.homepage,
nickname: CONTACT_IMPORT_INFO_TEXT.nickname,
messenger: CONTACT_IMPORT_INFO_TEXT.messenger,
phone: CONTACT_IMPORT_INFO_TEXT.phone,
}
const TEXT_FIELDS = ['name', 'company', 'department', 'title'] as const
/** 그룹 이름을 견줄 열쇠 — 서버와 같다(앞뒤 공백을 빼고 ASCII 대소문자 무시) */
export function contactGroupKey(name: string): string {
return name.trim().replace(/[A-Z]/g, (char) => char.toLowerCase())
}
/** 줄 번호 글 — "4번째 줄" · vCard "12번째 연락처" */
export function importPositionText(kind: ImportedFileKind, position: number): string {
const text = formatCount(position)
return kind === 'vcard' ? CONTACT_IMPORT_TEXT.card(text) : CONTACT_IMPORT_TEXT.line(text)
}
/** 겹침의 까닭 — "주소록에 있음" · "2번째 줄과 같음" */
export function duplicateReasonText(kind: ImportedFileKind, duplicate: ImportDuplicate): string {
if (duplicate.samePosition === null) return CONTACT_IMPORT_TEXT.inBook
const text = formatCount(duplicate.samePosition)
return kind === 'vcard' ? CONTACT_IMPORT_TEXT.sameCard(text) : CONTACT_IMPORT_TEXT.sameLine(text)
}
/**
* 가져온 정보 줄 — 값은 한 줄 글로 다듬는다(cleanText — 탭 · \r · 제어 문자는 띄어쓰기). vCard 의 별명 · 형식이 틀린 전화 등에 남은 제어 문자가
* 메모에 들어가면 서버가 메모 규칙으로 그 묶음 전체를 400 으로 거절한다. 다듬어 빈 값은 뺀다
*/
function infoLinesOf(info: readonly ImportedInfo[], invalidPhones: readonly string[]): string[] {
const lines = new Set<string>()
const add = (label: string, value: string) => {
const text = cleanText(value)
if (text !== null) lines.add(CONTACT_IMPORT_INFO_TEXT.line(label, text))
}
for (const item of info) add(INFO_LABELS[item.kind], item.value)
for (const number of invalidPhones) add(INFO_LABELS.phone, number)
return [...lines]
}
interface Checked {
input: ContactInput
groups: string[]
info: string[]
/** 넣지 못하는 까닭 — 없으면 null */
problem: string | null
}
function uniqueGroups(names: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const raw of names) {
const name = cleanGroupName(raw, CONTACT_GROUP_NAME_MAX_LENGTH)
if (!name) continue
const key = contactGroupKey(name)
if (seen.has(key)) continue
seen.add(key)
result.push(name)
}
return result
}
/** 연락처 하나를 서버의 규칙으로 맞추고 본다 */
function checkRecord(record: ImportedRecord): Checked {
const emails: ContactEmail[] = []
const seenEmails = new Set<string>()
let emailProblem = false
for (const email of record.emails) {
const address = email.address.trim()
if (address === '') continue
if (address.length > CONTACT_EMAIL_MAX_LENGTH || !isStrictEmail(address)) {
emailProblem = true
continue
}
const key = normalizeEmail(address)
if (seenEmails.has(key)) continue
seenEmails.add(key)
emails.push({ address, label: email.label })
}
const phones: ContactPhone[] = []
const invalidPhones: string[] = []
const seenPhones = new Set<string>()
for (const phone of record.phones) {
const number = formatKoreanPhone(phone.number.trim())
if (number === '' || seenPhones.has(number)) continue
seenPhones.add(number)
if (isValidContactPhone(number)) phones.push({ number, label: phone.label })
else invalidPhones.push(number)
}
const note = record.note === null ? null : contactNoteText(record.note).trim() || null
const input: ContactInput = {
name: cleanText(record.name),
emails: emails.slice(0, CONTACT_EMAILS_MAX),
phones: phones.slice(0, CONTACT_PHONES_MAX),
company: cleanText(record.company),
department: cleanText(record.department),
title: cleanText(record.title),
note,
groupIds: [],
}
let problem: string | null = null
if (emailProblem) problem = CONTACT_IMPORT_PROBLEM_TEXT.email
else if (input.name === null && input.emails.length === 0) problem = CONTACT_IMPORT_PROBLEM_TEXT.required
else {
const long = TEXT_FIELDS.find((field) => (input[field]?.length ?? 0) > CONTACT_TEXT_MAX_LENGTH)
if (long) problem = CONTACT_IMPORT_PROBLEM_TEXT.textTooLong(CONTACT_IMPORT_PROBLEM_TEXT.fields[long])
else if ((note?.length ?? 0) > CONTACT_NOTE_MAX_LENGTH) problem = CONTACT_IMPORT_PROBLEM_TEXT.note
}
return { input, groups: uniqueGroups(record.groups), info: infoLinesOf(record.info, invalidPhones), problem }
}
/** 넣지 못하는 줄에 적힌 것 — 이름 · 이메일 · 전화 · 회사 가운데 앞의 둘 */
function problemWhat(record: ImportedRecord): string {
const phone = record.phones[0]?.number
const parts = [
cleanText(record.name),
cleanText(record.emails[0]?.address),
cleanText(phone === undefined ? null : formatKoreanPhone(phone.trim())),
cleanText(record.company),
].filter((part): part is string => part !== null)
return parts.slice(0, 2).join(' · ')
}
/** 이름과 전화 숫자 모음을 잇는 글 — 이름 칸에 올 수 없는 제어 문자 (서버와 같다) */
const NAME_PHONES_SEPARATOR = '\u0000'
/**
* 전화번호들의 숫자 모음 — 번호마다 숫자만 남기고, 숫자가 없는 번호는 빼고, 겹침을 없앤 뒤 차례로 "," 로 잇는다.
* 짝: backend modules/contacts/contact-import.ts 의 phoneDigitsKey (계약 3절 "가져오기" — 한쪽을 고치면 둘 다)
*/
export function phoneDigitsKey(phones: readonly Pick<ContactPhone, 'number'>[]): string {
const digits = new Set<string>()
for (const phone of phones) {
const only = phone.number.replace(/\D+/g, '')
if (only !== '') digits.add(only)
}
return [...digits].sort().join(',')
}
/**
* 이메일이 없는 연락처를 견줄 열쇠 — 이름(앞뒤 공백만 뺀 글) + 전화 숫자 모음. 이름이 없으면 null (견주지 않는다).
* 짝: backend contact-import.ts 의 namePhonesKey
*/
export function namePhonesKey(input: Pick<ContactInput, 'name' | 'phones'>): string | null {
const name = input.name?.trim() ?? ''
return name === '' ? null : `${name}${NAME_PHONES_SEPARATOR}${phoneDigitsKey(input.phones)}`
}
/** 보이는 이름 — 이름이 없으면 첫 이메일 */
function displayNameOf(input: ContactInput): string {
return input.name ?? input.emails[0]?.address ?? ''
}
/** 읽은 연락처를 나눈다 — 넣을 수 있는 것 · 파일 안 겹침 · 넣지 못하는 것 (uid 는 넣을 수 있는 것에만 만든다) */
export function prepareImport(
kind: ImportedFileKind,
records: readonly ImportedRecord[],
createUid: () => string,
): PreparedImport {
const candidates: ImportCandidate[] = []
const duplicates: ImportDuplicate[] = []
const problems: ImportProblem[] = []
const emailOwners = new Map<string, number>()
const namePhoneOwners = new Map<string, number>()
for (const record of records) {
const checked = checkRecord(record)
if (checked.problem !== null) {
problems.push({ position: record.position, what: problemWhat(record), reason: checked.problem })
continue
}
const { input } = checked
const emailKeys = input.emails.map((email) => normalizeEmail(email.address))
const nameKey = namePhonesKey(input)
const same =
emailKeys.length > 0
? emailKeys.map((key) => emailOwners.get(key)).find((owner) => owner !== undefined)
: nameKey === null
? undefined
: namePhoneOwners.get(nameKey)
if (same !== undefined) {
duplicates.push({
position: record.position,
name: displayNameOf(input),
detail: input.emails[0]?.address ?? input.phones[0]?.number ?? '',
samePosition: same,
})
continue
}
for (const key of emailKeys) emailOwners.set(key, record.position)
if (nameKey !== null && !namePhoneOwners.has(nameKey)) namePhoneOwners.set(nameKey, record.position)
candidates.push({ position: record.position, uid: createUid(), input, groups: checked.groups, info: checked.info })
}
return { kind, found: records.length, candidates, duplicates, problems }
}
/** 서버에 묻는 항목 — 그룹 없이 (확인은 그룹을 보지 않는다) */
export function checkItemOf(candidate: ImportCandidate): ContactImportItem {
return { ...candidate.input, groupIds: [], uid: candidate.uid }
}
// ── 미리 보기 ──────────────────────────────────────────
export interface ImportPreview {
kind: ImportedFileKind
found: number
/** 넣을 연락처 — 파일 차례, 남은 자리만큼(넘으면 앞에서부터) */
toInsert: ImportCandidate[]
/** 넣을 수 있지만 3,000개 한도로 넣지 않는 수 */
overLimit: number
/** 이미 있는 연락처 — 파일 안 겹침과 주소록에 있음, 줄 차례 */
duplicates: ImportDuplicate[]
problems: ImportProblem[]
}
/**
* 서버의 확인 결과로 미리 보기를 만든다. statuses 에 없는 연락처(남은 자리를 다 채워 묻지 않은 것)는 새 연락처로 본다.
* remaining 이 null 이면(묻지 않음) 한도를 보지 않는다
*/
export function previewOf(
prepared: PreparedImport,
statuses: ReadonlyMap<string, ContactImportCheckStatus>,
remaining: number | null,
): ImportPreview {
const fresh: ImportCandidate[] = []
const inBook: ImportDuplicate[] = []
for (const candidate of prepared.candidates) {
if (statuses.get(candidate.uid) === 'exists') {
inBook.push({
position: candidate.position,
name: displayNameOf(candidate.input),
detail: candidate.input.emails[0]?.address ?? candidate.input.phones[0]?.number ?? '',
samePosition: null,
})
} else {
fresh.push(candidate)
}
}
const room = remaining === null ? fresh.length : Math.max(0, remaining)
const toInsert = fresh.slice(0, room)
return {
kind: prepared.kind,
found: prepared.found,
toInsert,
overLimit: fresh.length - toInsert.length,
duplicates: [...prepared.duplicates, ...inBook].sort((a, b) => a.position - b.position),
problems: prepared.problems,
}
}
/** 미리 보기 표의 줄 */
export interface ImportPreviewRow {
key: string
name: string
email: string
phone: string
company: string
groups: string
}
export function previewRowsOf(preview: ImportPreview): ImportPreviewRow[] {
return preview.toInsert.slice(0, CONTACT_IMPORT_PREVIEW_ROWS).map((candidate) => ({
key: candidate.uid,
name: displayNameOf(candidate.input),
email: candidate.input.emails[0]?.address ?? '',
phone: candidate.input.phones[0]?.number ?? '',
company: candidate.input.company ?? '',
groups: candidate.groups.join(', '),
}))
}
// ── 그룹 ──────────────────────────────────────────────
export interface FileGroupSummary {
/** 이미 있는 그룹 — 주소록의 이름으로 */
existing: string[]
/** 새로 만들 그룹 — 파일의 이름으로 */
created: string[]
}
/** 넣을 연락처에 적힌 그룹을 이미 있는 것 · 새로 만들 것으로 나눈다 (처음 나온 차례) */
export function fileGroupsOf(toInsert: readonly ImportCandidate[], groups: readonly ContactGroup[]): FileGroupSummary {
const byKey = new Map(groups.map((group) => [contactGroupKey(group.name), group]))
const seen = new Set<string>()
const summary: FileGroupSummary = { existing: [], created: [] }
for (const candidate of toInsert) {
for (const name of candidate.groups) {
const key = contactGroupKey(name)
if (seen.has(key)) continue
seen.add(key)
const group = byKey.get(key)
if (group) summary.existing.push(group.name)
else summary.created.push(name)
}
}
return summary
}
/** 이름 늘어놓기 — 셋을 넘으면 "앞 셋 외 N개" */
function namesText(names: readonly string[]): string {
const shown = names.slice(0, CONTACT_IMPORT_GROUP_NAMES_SHOWN).join(', ')
const rest = names.length - CONTACT_IMPORT_GROUP_NAMES_SHOWN
return rest > 0 ? CONTACT_IMPORT_TEXT.groupNamesMore(shown, formatCount(rest)) : shown
}
/** 미리 보기의 그룹 글 */
export function fileGroupsText(summary: FileGroupSummary): string {
const total = summary.existing.length + summary.created.length
if (total === 0) return CONTACT_IMPORT_TEXT.groupsNone
if (summary.created.length === 0) return CONTACT_IMPORT_TEXT.groupsExisting(namesText(summary.existing))
if (summary.existing.length === 0) return CONTACT_IMPORT_TEXT.groupsCreated(namesText(summary.created))
return CONTACT_IMPORT_TEXT.groupsMixed(formatCount(total), namesText(summary.existing), namesText(summary.created))
}
// ── 메모 끝 "가져온 정보" ─────────────────────────────────
/**
* 메모 + 가져온 정보 — 붙이면 1,000자를 넘으면 메모 그대로.
* 붙인 메모는 연락처 창과 같은 메모 정리(contactNoteText)를 한 번 더 지난다 — 서버가 받는 메모만 보낸다
*/
export function noteWithInfo(note: string | null, info: readonly string[]): string | null {
if (info.length === 0) return note
const block = [CONTACT_IMPORT_INFO_TEXT.heading, ...info].join('\n')
const combined = contactNoteText(note ? `${note}\n\n${block}` : block).trim()
return combined.length > CONTACT_NOTE_MAX_LENGTH ? note : combined
}
/** 미리 보기의 메모 견본 — 가져온 정보가 붙는 첫 연락처. 없으면 null */
export interface ImportInfoSample {
name: string
/** 원래 메모의 줄 (흐리게) */
original: string[]
/** 붙는 줄 — 첫 줄이 "가져온 정보" */
added: string[]
}
export function infoSampleOf(toInsert: readonly ImportCandidate[]): ImportInfoSample | null {
const candidate = toInsert.find(
(item) => item.info.length > 0 && noteWithInfo(item.input.note, item.info) !== item.input.note,
)
if (!candidate) return null
return {
name: displayNameOf(candidate.input),
original: candidate.input.note ? candidate.input.note.split('\n') : [],
added: [CONTACT_IMPORT_INFO_TEXT.heading, ...candidate.info],
}
}
/** 가져온 정보가 있는 연락처가 있는가 — 없으면 메모 체크 칸을 두지 않는다 */
export function hasImportedInfo(toInsert: readonly ImportCandidate[]): boolean {
return toInsert.some((candidate) => candidate.info.length > 0)
}
// ── 넣을 계획 ──────────────────────────────────────────
/** 가져온 연락처를 모두 넣을 그룹 */
export type ImportExtraGroup = { kind: 'none' } | { kind: 'existing'; id: string } | { kind: 'new'; name: string }
export interface ContactImportPlanEntry {
/** groupIds 는 비어 있다 — 넣을 때 groupKeys 의 그룹 id 로 채운다 */
item: ContactImportItem
groupKeys: string[]
}
export interface ContactImportPlanGroup {
key: string
name: string
/** 이미 있는 그룹이면 그 id, 새로 만들 그룹이면 null */
id: string | null
}
/** [연락처 N명 넣기]가 넘기는 계획 — 넣는 동안(다른 화면으로 가도) 이것만 본다 */
export interface ContactImportPlan {
entries: ContactImportPlanEntry[]
groups: ContactImportPlanGroup[]
/** 이미 있던 수 (주소록 + 파일 안 겹침) */
skipped: number
/** 3,000개 한도로 넣지 않는 수 */
overLimit: number
}
/** 새 그룹 이름을 다듬는다 — 빈 이름이면 null (창이 "그룹 이름을 넣어 주세요.") */
export function extraGroupName(name: string): string | null {
return cleanGroupName(name, CONTACT_GROUP_NAME_MAX_LENGTH)
}
export function importPlanOf(
preview: ImportPreview,
groups: readonly ContactGroup[],
extra: ImportExtraGroup,
appendInfo: boolean,
): ContactImportPlan {
const byKey = new Map(groups.map((group) => [contactGroupKey(group.name), group]))
const planGroups = new Map<string, ContactImportPlanGroup>()
const remember = (name: string, id: string | null = null): string => {
const key = contactGroupKey(name)
if (!planGroups.has(key)) {
const existing = byKey.get(key)
planGroups.set(key, { key, name: existing?.name ?? name, id: id ?? existing?.id ?? null })
}
return key
}
let extraKey: string | null = null
if (extra.kind === 'existing') {
const group = groups.find((item) => item.id === extra.id)
if (group) extraKey = remember(group.name, group.id)
} else if (extra.kind === 'new') {
const name = extraGroupName(extra.name)
if (name) extraKey = remember(name)
}
const entries = preview.toInsert.map((candidate) => {
const keys = candidate.groups.map((name) => remember(name))
if (extraKey !== null && !keys.includes(extraKey)) keys.push(extraKey)
const note = appendInfo ? noteWithInfo(candidate.input.note, candidate.info) : candidate.input.note
return { item: { ...candidate.input, note, groupIds: [], uid: candidate.uid }, groupKeys: keys }
})
return {
entries,
groups: [...planGroups.values()],
skipped: preview.duplicates.length,
overLimit: preview.overLimit,
}
}
@@ -0,0 +1,290 @@
import { describe, expect, it } from 'vitest'
import { parseCsv } from './csv'
import { detectTableLayout, normalizeHeader, tableRecords } from './table'
import type { TableRow } from './types'
// 표(CSV · 엑셀)의 머리 줄로 꼴을 알아보고 칸을 맞춘다 (16단계 — 디자인 원본 Contacts16 "6 칸 맞추기")
// 표본은 지어낸 사람 · 주소다. 네이버 머리는 decisions 0절의 63열 차례(실제 머리 글의 띄어쓰기 · 괄호는 다를 수 있어 낱말로 견준다)
const NAVER_HEADERS = [
'성', '이름', '경칭', '중간이름', '호칭', '닉네임', '휴대폰번호', '이메일', '그룹명', '회사번호', '집번호',
'회사Fax번호', '집Fax번호', '기타번호', '전화번호직접입력(전화종류)', '전화번호직접입력(번호)', '생일(YYYYMMDD)',
'생일(양력음력)', '기념일(이름)', '기념일(날짜)', '회사·소속명', '부서명', '직책', '회사우편번호', '회사주소',
'집우편번호', '집주소', '기타우편번호', '기타주소', '주소직접입력(이름)', '주소직접입력(우편번호)', '주소직접입력(정보)',
'홈페이지', '메신저타입', '메신저주소', '메모',
...[1, 2, 3].map((n) => `추가휴대폰번호${n}`),
...[1, 2, 3].map((n) => `추가회사번호${n}`),
...[1, 2, 3].map((n) => `추가집번호${n}`),
...[1, 2, 3].map((n) => `추가회사Fax번호${n}`),
...[1, 2, 3].map((n) => `추가집Fax번호${n}`),
...[1, 2, 3].map((n) => `추가기타번호${n}`),
...[1, 2, 3].map((n) => `추가이메일${n}`),
...[1, 2, 3].map((n) => `추가홈페이지${n}`),
...[1, 2, 3].map((n) => `추가그룹명${n}`),
]
/** 네이버 CSV 한 줄 — 모든 값 앞에 ' (빈 칸도) */
function naverRow(values: Partial<Record<string, string>>): string[] {
return NAVER_HEADERS.map((header) => `'${values[header] ?? ''}`)
}
function rowsOf(table: readonly (readonly string[])[]): TableRow[] {
return table.map((cells, index) => ({ position: index + 1, cells: [...cells] }))
}
describe('머리 이름 · 꼴', () => {
it('공백 · 기호를 빼고 소문자로 견준다', () => {
expect(normalizeHeader('E-mail 1 - Value')).toBe('email1value')
expect(normalizeHeader("'회사·소속명")).toBe('회사소속명')
expect(normalizeHeader('\uFEFFBusiness Country/Region')).toBe('businesscountryregion')
})
it('네이버 · Google · Outlook · 우리 내보내기를 머리 줄로 알아본다', () => {
expect(detectTableLayout(NAVER_HEADERS.map(normalizeHeader))).toBe('naver')
expect(detectTableLayout(['firstname', 'lastname', 'email1label', 'email1value'])).toBe('google')
expect(detectTableLayout(['name', 'givenname', 'groupmembership'])).toBe('google')
expect(detectTableLayout(['firstname', 'lastname', 'emailaddress', 'businessphone', 'jobtitle'])).toBe('outlook')
expect(
detectTableLayout(['이름', '이메일', '휴대폰', '회사전화', '집전화', '기타전화', '회사', '부서', '직함', '메모', '그룹']),
).toBe('ours')
expect(detectTableLayout(['성명', '휴대전화'])).toBe('other')
})
})
describe('네이버 63열', () => {
it('성 + 이름을 붙이고, 값 앞의 작은따옴표를 떼고, 칸 없는 정보는 info 로', () => {
const table = [
NAVER_HEADERS,
naverRow({
성: '강',
이름: '민지',
닉네임: '민지',
휴대폰번호: '01041203387',
이메일: 'minji.kang@example.com',
그룹명: '거래처',
회사번호: '025550147',
회사Fax번호: '025550148',
'전화번호직접입력(전화종류)': '사무실2',
'전화번호직접입력(번호)': '01099998888',
'생일(YYYYMMDD)': '19880314',
'생일(양력음력)': '음력',
'기념일(이름)': '입사일',
'기념일(날짜)': '20150302',
'회사·소속명': '한빛상사',
부서명: '구매팀',
직책: '과장',
회사우편번호: '04524',
회사주소: '서울 중구 세종대로 110',
'주소직접입력(이름)': '본가',
'주소직접입력(정보)': '부산 해운대구',
홈페이지: 'www.example.com',
메신저타입: '카카오톡',
메신저주소: 'minji88',
메모: '견적 문의는 월말 전에 보내기.',
추가휴대폰번호1: '01055556666',
추가이메일1: 'minji@home.example',
추가그룹명1: '동호회',
}),
naverRow({}),
]
const result = tableRecords(rowsOf(table))
expect(result.layout).toBe('naver')
expect(result.recognized).toBe(true)
expect(result.records).toHaveLength(1)
const [record] = result.records
expect(record).toMatchObject({
position: 2,
name: '강민지',
company: '한빛상사',
department: '구매팀',
title: '과장',
note: '견적 문의는 월말 전에 보내기.',
groups: ['거래처', '동호회'],
})
expect(record?.emails).toEqual([
{ address: 'minji.kang@example.com', label: 'work' },
{ address: 'minji@home.example', label: 'work' },
])
// 하이픈은 칸 맞추기(records.ts)가 넣는다 — 여기서는 종류만
expect(record?.phones).toEqual([
{ number: '01041203387', label: 'mobile' },
{ number: '025550147', label: 'work' },
{ number: '025550148', label: 'other' },
{ number: '01099998888', label: 'other' },
{ number: '01055556666', label: 'mobile' },
])
expect(record?.info).toEqual([
{ kind: 'nickname', value: '민지' },
{ kind: 'homepage', value: 'www.example.com' },
{ kind: 'workAddress', value: '서울 중구 세종대로 110 (04524)' },
{ kind: 'address', value: '본가 부산 해운대구' },
{ kind: 'birthday', value: '1988년 3월 14일 (음력)' },
{ kind: 'anniversary', value: '입사일 2015년 3월 2일' },
{ kind: 'messenger', value: '카카오톡 minji88' },
])
})
it('머리 글의 띄어쓰기 · 괄호가 달라도 낱말로 알아본다 (회사 주소 · 생일 · 전화종류)', () => {
const table = [
['성', '이름', '휴대폰 번호', '회사·소속명', '그룹명', '회사 주소', '생일', '전화종류', '전화번호'],
['박', '지훈', '010-3192-5540', '한빛상사', '거래처', '서울 중구', '1990-01-02', '집', '02-111-2222'],
]
const [record] = tableRecords(rowsOf(table)).records
expect(record).toMatchObject({ name: '박지훈', company: '한빛상사', groups: ['거래처'] })
expect(record?.phones).toEqual([
{ number: '010-3192-5540', label: 'mobile' },
{ number: '02-111-2222', label: 'other' },
])
expect(record?.info).toEqual([
{ kind: 'workAddress', value: '서울 중구' },
{ kind: 'birthday', value: '1990년 1월 2일' },
])
})
})
describe('Google CSV', () => {
it('새 머리 — 짝 칸(E-mail 1 - Label/Value), " ::: " 여러 값, 시스템 라벨 빼기', () => {
const csv = [
'First Name,Middle Name,Last Name,Nickname,Organization Name,Organization Title,Organization Department,Birthday,Notes,Labels,' +
'E-mail 1 - Label,E-mail 1 - Value,E-mail 2 - Label,E-mail 2 - Value,Phone 1 - Label,Phone 1 - Value,Phone 2 - Label,Phone 2 - Value,' +
'Address 1 - Label,Address 1 - Formatted,Website 1 - Label,Website 1 - Value,Event 1 - Label,Event 1 - Value,IM 1 - Label,IM 1 - Service,IM 1 - Value',
'John,,Smith,Johnny,Acme,Engineer,R&D,1985-07-04,"Met at expo",* myContacts ::: 거래처 ::: * starred,' +
'* Work,john@acme.example,Home,john@home.example ::: js@home.example,Mobile,+1 555-0100,Work Fax,+1 555-0199,' +
'Work,"1 Main St\nSpringfield",Profile,https://acme.example/john,Anniversary,2010-06-01,,Skype,john.smith',
].join('\n')
const result = tableRecords(rowsOf(parseCsv(csv)))
expect(result.layout).toBe('google')
const [record] = result.records
expect(record).toMatchObject({
name: 'John Smith',
company: 'Acme',
title: 'Engineer',
department: 'R&D',
note: 'Met at expo',
groups: ['* myContacts', '거래처', '* starred'],
})
expect(record?.emails).toEqual([
{ address: 'john@acme.example', label: 'work' },
{ address: 'john@home.example', label: 'personal' },
{ address: 'js@home.example', label: 'personal' },
])
expect(record?.phones).toEqual([
{ number: '+1 555-0100', label: 'mobile' },
{ number: '+1 555-0199', label: 'other' },
])
expect(record?.info).toEqual(
expect.arrayContaining([
{ kind: 'nickname', value: 'Johnny' },
{ kind: 'workAddress', value: '1 Main St Springfield' },
{ kind: 'homepage', value: 'https://acme.example/john' },
{ kind: 'anniversary', value: '2010년 6월 1일' },
{ kind: 'messenger', value: 'Skype john.smith' },
{ kind: 'birthday', value: '1985년 7월 4일' },
]),
)
})
it('옛 머리 — Name 이 이름 전체, Group Membership, Organization 1 - Name', () => {
const csv = [
'Name,Given Name,Additional Name,Family Name,Yomi Name,Group Membership,E-mail 1 - Type,E-mail 1 - Value,Phone 1 - Type,Phone 1 - Value,Organization 1 - Name,Organization 1 - Title',
'강서윤,서윤,,강,,* myContacts ::: 거래처,* ,seoyun@hanbit.example,,01024813307,한빛상사,과장',
].join('\r\n')
const [record] = tableRecords(rowsOf(parseCsv(csv))).records
expect(record).toMatchObject({ name: '강서윤', company: '한빛상사', title: '과장', groups: ['* myContacts', '거래처'] })
expect(record?.emails).toEqual([{ address: 'seoyun@hanbit.example', label: 'work' }])
// 종류를 모르는 번호는 번호로 짐작한다 — 국내 휴대전화
expect(record?.phones).toEqual([{ number: '01024813307', label: 'mobile' }])
})
})
describe('Outlook CSV (영어)', () => {
it('Title 은 경칭이라 쓰지 않고 Job Title 이 직책, 회사 주소 조각을 잇는다', () => {
const csv = [
'Title,First Name,Middle Name,Last Name,Suffix,Company,Department,Job Title,Business Street,Business City,Business State,' +
'Business Postal Code,Business Country/Region,Business Phone,Business Fax,Home Phone,Mobile Phone,Other Phone,E-mail Address,' +
'E-mail Type,E-mail Display Name,E-mail 2 Address,Birthday,Notes,Categories,Web Page',
'Mr.,Ryu,,Jihwan,,Bareun Accounting,Tax,Partner,12 Teheran-ro,Gangnam-gu,Seoul,06236,,02-555-0100,02-555-0101,,010-7714-2093,,' +
'jihwan@bareun.example,SMTP,Ryu Jihwan (jihwan@bareun.example),ryu@mail.example,0/0/00,,세무;거래처,www.bareun.example',
].join('\r\n')
const result = tableRecords(rowsOf(parseCsv(csv)))
expect(result.layout).toBe('outlook')
const [record] = result.records
expect(record).toMatchObject({
name: 'Ryu Jihwan',
company: 'Bareun Accounting',
department: 'Tax',
title: 'Partner',
groups: ['세무', '거래처'],
})
expect(record?.emails).toEqual([
{ address: 'jihwan@bareun.example', label: 'work' },
{ address: 'ryu@mail.example', label: 'work' },
])
expect(record?.phones).toEqual([
{ number: '02-555-0100', label: 'work' },
{ number: '02-555-0101', label: 'other' },
{ number: '010-7714-2093', label: 'mobile' },
])
expect(record?.info).toEqual([
{ kind: 'homepage', value: 'www.bareun.example' },
{ kind: 'workAddress', value: '12 Teheran-ro, Gangnam-gu, Seoul (06236)' },
])
})
})
describe('우리가 내보낸 CSV · 모르는 꼴', () => {
it('우리 머리 — 한 칸에 "; " 로 이은 값을 나누고, 수식 막기로 붙인 작은따옴표만 뗀다', () => {
const csv = [
'이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹',
"강민지,minji.kang@example.com; minji@home.example,010-4120-3387,02-555-0147,,'+82 2 555 0000,한빛상사,구매팀,과장,'=SUM(1),거래처; 동호회",
].join('\r\n')
const result = tableRecords(rowsOf(parseCsv(csv)))
expect(result.layout).toBe('ours')
const [record] = result.records
expect(record).toMatchObject({ name: '강민지', title: '과장', note: '=SUM(1)', groups: ['거래처', '동호회'] })
expect(record?.emails).toEqual([
{ address: 'minji.kang@example.com', label: 'work' },
{ address: 'minji@home.example', label: 'work' },
])
expect(record?.phones).toEqual([
{ number: '010-4120-3387', label: 'mobile' },
{ number: '02-555-0147', label: 'work' },
{ number: '+82 2 555 0000', label: 'other' },
])
})
it('모르는 꼴은 열 이름 낱말로 짐작한다 (성명 · 휴대전화 · 직위 · 비고 · 소속)', () => {
const table = [
['번호', '성명', '휴대전화', 'E-mail', '소속', '직위', '비고'],
['1', '최유진', '010-2222-3333', 'yujin@example.net', '그린클라우드', '대리', '세미나'],
]
const result = tableRecords(rowsOf(table))
expect(result.layout).toBe('other')
expect(result.records[0]).toMatchObject({
name: '최유진',
company: '그린클라우드',
title: '대리',
note: '세미나',
emails: [{ address: 'yujin@example.net', label: 'work' }],
})
// "번호" 열(일련번호)은 전화로 보지 않는다
expect(result.records[0]?.phones).toEqual([{ number: '010-2222-3333', label: 'mobile' }])
})
it('이름 · 이메일 열이 하나도 없으면 알아보지 못한 것이다', () => {
const result = tableRecords(rowsOf([['날짜', '금액'], ['2026-11-10', '3000']]))
expect(result.recognized).toBe(false)
expect(result.records).toEqual([])
})
it('줄 번호는 엑셀의 행 번호다 — 머리 위 빈 줄 · 사이의 빈 줄도 센다', () => {
const result = tableRecords(rowsOf([[''], ['이름', '이메일'], ['김', 'a@x.kr'], ['', ''], ['이', 'b@x.kr']]))
expect(result.records.map((record) => record.position)).toEqual([3, 5])
})
it('네이버가 아니어도 값 대부분이 앞에 작은따옴표를 달았으면 모두 뗀다', () => {
const result = tableRecords(rowsOf([['이름', '전화번호'], ["'김아라", "'01012345678"]]))
expect(result.records[0]).toMatchObject({ name: '김아라', phones: [{ number: '01012345678', label: 'mobile' }] })
})
})
@@ -0,0 +1,406 @@
// 표(CSV · 엑셀)의 줄 → 연락처 — 머리 줄로 꼴을 알아보고 칸마다 주소록 칸을 정한다 (16단계 가져오기 · 디자인 원본 Contacts16 "6")
// 아는 꼴: 네이버 주소록(한국어 63열 — 성 · 이름 · 휴대폰번호 …) · Google(새 머리 "First Name … E-mail 1 - Label/Value" · 옛 머리 "Name, Given Name …") ·
// Outlook(영어 — "First Name … E-mail Address … Business Phone") · 우리가 내보낸 CSV(이름 · 이메일 · 휴대폰 · 회사 전화 …).
// 모르는 꼴은 열 이름의 낱말(이메일 · 전화 · 회사 · 부서 · 메모 · 그룹 …)로 짐작한다. 이름이나 이메일 열이 하나도 없으면 연락처를 찾지 못한 것이다.
// - 머리 이름은 공백 · 기호를 빼고 소문자로 견준다("E-mail 1 - Value" → email1value, "회사·소속명" → 회사소속명)
// - 네이버 CSV 는 값마다 앞에 ' 가 붙는다(빈 칸도) — 떼고 읽는다. 다른 꼴은 엑셀 수식 막기로 붙인 ' (뒤가 = + - @) 만 뗀다
// - 이메일 · 전화는 종류대로(팩스 · 직접 입력한 번호는 기타, 종류를 모르는 "전화번호" 칸은 번호로 짐작 — 국내 휴대전화면 휴대전화),
// 종류를 모르는 이메일 칸은 회사(연락처 추가 창의 처음 값과 같다)
// - 칸이 없는 정보(주소 · 생일 양력/음력 · 기념일 · 홈페이지 · 별명 · 메신저)는 info 로 — 메모 끝 "가져온 정보"가 된다
import type { ContactEmailLabel, ContactPhoneLabel } from '@/shared/types/contacts'
import { emptyImportedRecord, type ImportedInfoKind, type ImportedRecord, type TableRow } from './types'
import {
cleanText,
composeName,
emptyAddressParts,
formatAddress,
formatImportedDate,
guessPhoneLabel,
isLunarMarker,
normalizePhone,
phoneLabelOfText,
splitEmails,
splitMultiValue,
type AddressParts,
} from './values'
export type TableLayout = 'naver' | 'google' | 'outlook' | 'ours' | 'other'
type AddressSlot = 'work' | 'home' | 'other' | 'direct' | 'main'
type AddressPart = keyof AddressParts | 'label'
/** Google 의 짝 칸 — "E-mail 1 - Label" + "E-mail 1 - Value" */
type PairedFamily = 'email' | 'phone' | 'address' | 'website' | 'event' | 'im' | 'organization'
type ColumnRole =
| { kind: 'name' | 'family' | 'given' | 'middle' }
| { kind: 'email'; label: ContactEmailLabel }
/** auto — 종류를 모르는 칸: 번호로 짐작한다 */
| { kind: 'phone'; label: ContactPhoneLabel | 'auto' }
| { kind: 'company' | 'department' | 'title' | 'note' | 'groups' }
| { kind: 'nickname' | 'homepage' | 'birthday' | 'birthdayCalendar' }
| { kind: 'anniversary'; part: 'name' | 'date' }
| { kind: 'messenger'; part: 'service' | 'address' }
| { kind: 'address'; slot: AddressSlot; part: AddressPart }
| { kind: 'paired'; family: PairedFamily; index: string; part: string }
| { kind: 'ignore' }
const IGNORE: ColumnRole = { kind: 'ignore' }
/** 머리 이름 → 견줄 글: 앞의 BOM · ' 를 떼고, 공백 · 기호를 빼고 소문자로 */
export function normalizeHeader(value: string): string {
return value
.replace(/^\uFEFF/, '')
.replace(/^'+/, '')
.trim()
.toLowerCase()
.replace(/[\s·・.\-_/()[\]{}:,'"’&]/g, '')
}
/** Google 의 짝 칸 — 무리 · 번호 · 조각 */
const PAIRED_COLUMN =
/^(e?mail|phone|address|website|event|im|organization|relation|customfield)(\d+)(type|label|value|formatted|street|city|pobox|region|postalcode|country|extendedaddress|service|name|title|department|yominame|symbol|location|jobdescription)$/
const FAMILY_COLUMN = /^(성|성씨|lastname|familyname|surname)$/
/** 전화 열의 낱말 — "번호"만으로는 보지 않는다(일련번호 열이 흔하다) */
const PHONE_WORDS = /(phone|tel|mobile|cell|fax|pager|전화|휴대|핸드폰|팩스)/
/** 네이버의 "회사번호 · 집번호 · 기타번호 · 추가회사번호1" */
const NAVER_PHONE = /^(추가)?(회사|집|기타|직장|자택)(fax)?번호\d*$/
const NAVER_SIGNATURE = ['성', '이름', '휴대폰번호', '회사소속명', '부서명', '그룹명', '추가이메일1', '회사번호', '집번호']
const OURS_SIGNATURE = ['이름', '이메일', '휴대폰', '회사전화', '집전화', '기타전화', '회사', '부서', '직함', '메모', '그룹']
/** 네이버로 보는 머리 수 · 우리 꼴로 보는 머리 수 */
const NAVER_MIN_HITS = 4
const OURS_MIN_HITS = 6
/** 머리 줄로 꼴을 알아본다 */
export function detectTableLayout(headers: readonly string[]): TableLayout {
const set = new Set(headers)
if (set.has('email1value') || set.has('phone1value') || set.has('groupmembership')) return 'google'
if (set.has('emailaddress') && (set.has('jobtitle') || set.has('businessphone') || set.has('mobilephone'))) {
return 'outlook'
}
if (NAVER_SIGNATURE.filter((header) => set.has(header)).length >= NAVER_MIN_HITS) return 'naver'
if (OURS_SIGNATURE.filter((header) => set.has(header)).length >= OURS_MIN_HITS) return 'ours'
return 'other'
}
function emailLabelOfHeader(header: string): ContactEmailLabel {
if (/(회사|직장|업무|work|business|office)/.test(header)) return 'work'
if (/(개인|집|home|personal|private)/.test(header)) return 'personal'
if (/(기타|other)/.test(header)) return 'other'
return 'work'
}
function phoneLabelOfHeader(header: string): ContactPhoneLabel | 'auto' {
if (/(직접|fax|팩스|pager|호출)/.test(header)) return 'other'
if (/(mobile|cell|휴대|핸드폰|모바일)/.test(header)) return 'mobile'
if (/(회사|직장|업무|work|business|company|office)/.test(header)) return 'work'
if (/(집|자택|home)/.test(header)) return 'home'
if (/(기타|other|car|radio|telex|tty|isdn|primary|callback|assistant|대표)/.test(header)) return 'other'
return 'auto'
}
function addressRole(header: string): ColumnRole {
const slot: AddressSlot = /직접/.test(header)
? 'direct'
: /(회사|직장|business|work|office)/.test(header)
? 'work'
: /(집|자택|home)/.test(header)
? 'home'
: /(기타|other)/.test(header)
? 'other'
: 'main'
let part: AddressPart = 'full'
if (/(우편|postal|zip|postcode)/.test(header)) part = 'postal'
else if (/(이름|label|종류)$/.test(header)) part = 'label'
else if (/street/.test(header)) part = 'street'
else if (/city/.test(header)) part = 'city'
else if (/country/.test(header)) part = 'country'
else if (/(state|region)/.test(header)) part = 'region'
else if (/pobox/.test(header)) part = 'pobox'
return { kind: 'address', slot, part }
}
/** 머리 하나의 쓰임 — 차례가 중요하다(회사번호는 전화, 회사주소는 주소, 회사소속명은 회사) */
function classifyHeader(header: string, layout: TableLayout, hasFamily: boolean): ColumnRole {
if (header === '' || /(yomi|phonetic|발음|읽기)/.test(header)) return IGNORE
const paired = PAIRED_COLUMN.exec(header)
if (paired) {
const family = (paired[1] === 'mail' ? 'email' : paired[1]) as PairedFamily | 'relation' | 'customfield'
if (family === 'relation' || family === 'customfield') return IGNORE
return { kind: 'paired', family, index: paired[2] ?? '', part: paired[3] ?? '' }
}
if (FAMILY_COLUMN.test(header)) return { kind: 'family' }
if (/^(firstname|givenname)$/.test(header) || (header === '이름' && hasFamily)) return { kind: 'given' }
if (/^(middlename|additionalname|중간이름)$/.test(header)) return { kind: 'middle' }
if (/^(name|fullname|displayname|이름|성명|성함|표시이름|연락처이름|contactname)$/.test(header)) return { kind: 'name' }
if (/^(nickname|닉네임|별명|애칭)$/.test(header)) return { kind: 'nickname' }
// Outlook 의 Title 은 Mr. · Ms. 같은 경칭이다 (직책은 Job Title)
if (header === 'title' && layout === 'outlook') return IGNORE
if (/^(nameprefix|namesuffix|prefix|suffix|경칭|호칭|fileas|shortname|initials|maidenname)$/.test(header)) return IGNORE
if (/(e?mail|메일)/.test(header)) {
return /(type|label|displayname|표시|종류)/.test(header) ? IGNORE : { kind: 'email', label: emailLabelOfHeader(header) }
}
if (/(메신저|messenger|imaddress|impp)/.test(header) || header === 'im') {
return { kind: 'messenger', part: /(타입|type|종류|service|서비스)/.test(header) ? 'service' : 'address' }
}
if (/(우편|postal|zip|postcode|주소|address|street|city|state|region|country|국가|pobox)/.test(header)) return addressRole(header)
if (PHONE_WORDS.test(header) || NAVER_PHONE.test(header) || header === '연락처') {
return /(종류|type|label)$/.test(header) ? IGNORE : { kind: 'phone', label: phoneLabelOfHeader(header) }
}
if (/^(company|companyname|organization|organizationname|회사|회사명|회사소속명|소속|소속명|직장|직장명|회사이름)$/.test(header)) {
return { kind: 'company' }
}
if (/(department|부서)/.test(header)) return { kind: 'department' }
if (/^(jobtitle|organizationtitle|title|직책|직함|직위|직급|position)$/.test(header)) return { kind: 'title' }
if (/^(notes?|메모|비고|memo|comments?|설명)$/.test(header)) return { kind: 'note' }
if (/^(labels|groupmembership|categories|category|그룹|그룹명|추가그룹명\d*|groups?|분류|카테고리|라벨)$/.test(header)) {
return { kind: 'groups' }
}
if (/(생일|생년월일|birthday|birthdate)/.test(header)) {
return { kind: /(양력|음력|lunar|solar)/.test(header) ? 'birthdayCalendar' : 'birthday' }
}
if (/(기념일|anniversary)/.test(header)) return { kind: 'anniversary', part: /(이름|name|종류|label)/.test(header) ? 'name' : 'date' }
if (/(홈페이지|homepage|website|webpage|웹사이트|블로그|blog)/.test(header) || /^url\d*$/.test(header)) {
return { kind: 'homepage' }
}
return IGNORE
}
/** 이름 · 이메일을 읽을 수 있는 열이 있는가 — 없으면 "연락처를 찾지 못했습니다" */
function hasContactColumns(roles: readonly ColumnRole[]): boolean {
return roles.some(
(role) =>
role.kind === 'name' ||
role.kind === 'family' ||
role.kind === 'given' ||
role.kind === 'email' ||
(role.kind === 'paired' && role.family === 'email'),
)
}
// ── 값 ────────────────────────────────────────────────
/** 엑셀 수식 막기로 붙인 ' — 뒤가 = + - @ 일 때만 */
const FORMULA_GUARD = /^'[=+\-@]/
/** 값 앞의 ' 를 모두 떼는 꼴로 볼 비율 — 값이 있는 칸의 절반 넘게 ' 로 시작한다(네이버) */
const QUOTED_MAJORITY = 0.5
const QUOTE_SAMPLE_ROWS = 50
function quotesEveryValue(rows: readonly TableRow[], layout: TableLayout): boolean {
if (layout === 'naver') return true
let filled = 0
let quoted = 0
for (const row of rows.slice(0, QUOTE_SAMPLE_ROWS)) {
for (const cell of row.cells) {
if (cell === '') continue
filled += 1
if (cell.startsWith("'")) quoted += 1
}
}
return filled > 0 && quoted / filled > QUOTED_MAJORITY
}
function cellValue(raw: string, stripQuotes: boolean): string {
if (stripQuotes && raw.startsWith("'")) return raw.slice(1)
return FORMULA_GUARD.test(raw) ? raw.slice(1) : raw
}
/** Google 라벨 "* Work" → "work" (별표는 대표 표시) */
function pairedLabel(value: string | undefined): string {
return (value ?? '').replace(/^\*\s*/, '').trim().toLowerCase()
}
function emailLabelOfText(label: string): ContactEmailLabel {
if (/(work|회사|직장)/.test(label)) return 'work'
if (/(home|personal|개인|집)/.test(label)) return 'personal'
if (label !== '') return 'other'
return 'work'
}
function addressKindOf(slot: AddressSlot): ImportedInfoKind {
if (slot === 'work') return 'workAddress'
if (slot === 'home') return 'homeAddress'
if (slot === 'other') return 'otherAddress'
return 'address'
}
/** 한 줄 → 연락처 (빈 줄이면 null) */
function recordOfRow(row: TableRow, roles: readonly ColumnRole[], stripQuotes: boolean): ImportedRecord | null {
const cells = row.cells.map((cell) => cellValue(cell, stripQuotes))
if (cells.every((cell) => cell.trim() === '')) return null
const record = emptyImportedRecord(row.position)
const names = { name: null as string | null, family: null as string | null, given: null as string | null, middle: null as string | null }
const addresses = new Map<string, AddressParts & { label: string | null }>()
const paired = new Map<string, { family: PairedFamily; parts: Map<string, string> }>()
let birthday: string | null = null
let lunar = false
let anniversaryName: string | null = null
let anniversaryDate: string | null = null
let messengerService: string | null = null
const messengerAddresses: string[] = []
const notes: string[] = []
const addPhone = (value: string, label: ContactPhoneLabel | 'auto') => {
for (const part of splitMultiValue(value)) {
const number = normalizePhone(part)
if (number) record.phones.push({ number, label: label === 'auto' ? guessPhoneLabel(number) : label })
}
}
roles.forEach((role, index) => {
const value = cells[index] ?? ''
if (value.trim() === '') return
switch (role.kind) {
case 'name':
case 'family':
case 'given':
case 'middle':
names[role.kind] ??= value
break
case 'email':
for (const address of splitEmails(value)) record.emails.push({ address, label: role.label })
break
case 'phone':
addPhone(value, role.label)
break
case 'company':
case 'department':
case 'title':
record[role.kind] ??= cleanText(value)
break
case 'note':
notes.push(value.trim())
break
case 'groups':
// 펼쳐 넘기지 않는다(push(...) 는 인자 수 한도 — 조각이 수십만 개면 RangeError)
for (const name of splitMultiValue(value)) record.groups.push(name)
break
case 'nickname':
case 'homepage':
for (const part of splitMultiValue(value)) {
const text = cleanText(part)
if (text) record.info.push({ kind: role.kind, value: text })
}
break
case 'birthday':
birthday ??= value
break
case 'birthdayCalendar':
lunar = isLunarMarker(value)
break
case 'anniversary':
if (role.part === 'name') anniversaryName ??= cleanText(value)
else anniversaryDate ??= value
break
case 'messenger':
if (role.part === 'service') {
messengerService ??= cleanText(value)
break
}
for (const address of splitMultiValue(value)) messengerAddresses.push(address)
break
case 'address': {
const parts = addresses.get(role.slot) ?? { ...emptyAddressParts(), label: null }
parts[role.part] ??= value
addresses.set(role.slot, parts)
break
}
case 'paired': {
const key = `${role.family}:${role.index}`
const entry = paired.get(key) ?? { family: role.family, parts: new Map<string, string>() }
entry.parts.set(role.part, value)
paired.set(key, entry)
break
}
default:
break
}
})
for (const { family, parts } of paired.values()) {
const label = pairedLabel(parts.get('label') ?? parts.get('type'))
const value = parts.get('value') ?? ''
if (family === 'email') {
for (const address of splitEmails(value)) record.emails.push({ address, label: emailLabelOfText(label) })
} else if (family === 'phone') {
addPhone(value, label === '' ? 'auto' : phoneLabelOfText(label))
} else if (family === 'website') {
for (const part of splitMultiValue(value)) {
const text = cleanText(part)
if (text) record.info.push({ kind: 'homepage', value: text })
}
} else if (family === 'event') {
const date = formatImportedDate(value)
const name = cleanText(parts.get('label') ?? parts.get('type'))
if (date) record.info.push({ kind: 'anniversary', value: name && !/anniversary/i.test(name) ? `${name} ${date}` : date })
} else if (family === 'im') {
const service = cleanText(parts.get('service'))
const address = cleanText(value)
if (address) record.info.push({ kind: 'messenger', value: service ? `${service} ${address}` : address })
} else if (family === 'organization') {
record.company ??= cleanText(parts.get('name'))
record.title ??= cleanText(parts.get('title'))
record.department ??= cleanText(parts.get('department'))
} else {
const slot: AddressSlot = /(work|회사)/.test(label) ? 'work' : /(home|집)/.test(label) ? 'home' : label ? 'other' : 'main'
const address = formatAddress({
...emptyAddressParts(),
full: parts.get('formatted') ?? null,
street: parts.get('street') ?? null,
extended: parts.get('extendedaddress') ?? null,
city: parts.get('city') ?? null,
region: parts.get('region') ?? null,
postal: parts.get('postalcode') ?? null,
country: parts.get('country') ?? null,
pobox: parts.get('pobox') ?? null,
})
if (address) record.info.push({ kind: addressKindOf(slot), value: address })
}
}
for (const [slot, parts] of addresses) {
const address = formatAddress(parts)
if (!address) continue
const label = cleanText(parts.label)
record.info.push({ kind: addressKindOf(slot as AddressSlot), value: label ? `${label} ${address}` : address })
}
const birthdayText = formatImportedDate(birthday, lunar)
if (birthdayText) record.info.push({ kind: 'birthday', value: birthdayText })
const anniversary = formatImportedDate(anniversaryDate)
if (anniversary) {
record.info.push({ kind: 'anniversary', value: anniversaryName ? `${anniversaryName} ${anniversary}` : anniversary })
}
for (const address of messengerAddresses) {
const text = cleanText(address)
if (text) record.info.push({ kind: 'messenger', value: messengerService ? `${messengerService} ${text}` : text })
}
record.name = cleanText(names.name) ?? composeName(names.family, names.given, names.middle)
record.note = notes.length > 0 ? notes.join('\n') : null
return record
}
export interface TableReadResult {
layout: TableLayout
/** 이름이나 이메일 열이 있다 */
recognized: boolean
records: ImportedRecord[]
}
/** 표의 줄들 → 연락처 — 첫 번째로 값이 있는 줄이 머리, 그 뒤의 빈 줄은 건너뛴다 */
export function tableRecords(rows: readonly TableRow[]): TableReadResult {
const headerIndex = rows.findIndex((row) => row.cells.some((cell) => cell.trim() !== ''))
const header = rows[headerIndex]
if (!header) return { layout: 'other', recognized: false, records: [] }
const headers = header.cells.map(normalizeHeader)
const layout = detectTableLayout(headers)
const hasFamily = headers.some((value) => FAMILY_COLUMN.test(value))
const roles = headers.map((value) => classifyHeader(value, layout, hasFamily))
if (!hasContactColumns(roles)) return { layout, recognized: false, records: [] }
const body = rows.slice(headerIndex + 1)
const stripQuotes = quotesEveryValue(body, layout)
const records = body
.map((row) => recordOfRow(row, roles, stripQuotes))
.filter((record): record is ImportedRecord => record !== null)
return { layout, recognized: true, records }
}
@@ -0,0 +1,43 @@
// 연락처 파일의 글자 풀기 — 순수 함수 (16단계 가져오기 · 디자인 원본 Contacts16 "6 받는 파일")
// 차례: BOM(UTF-8 · UTF-16) → BOM 이 없으면 UTF-8(틀린 바이트가 있으면 실패) → 그것도 아니면 EUC-KR.
// EUC-KR 라벨은 WHATWG 인코딩 표에서 windows-949(CP949 전체 — 한국어 엑셀의 "CSV(쉼표로 분리)")다.
// 'cp949' 라벨은 브라우저 · Node 모두 RangeError 라 쓰지 않는다 (바깥 조사 2026-11-10)
const UTF8_BOM = [0xef, 0xbb, 0xbf] as const
const UTF16LE_BOM = [0xff, 0xfe] as const
const UTF16BE_BOM = [0xfe, 0xff] as const
function startsWith(bytes: Uint8Array, prefix: readonly number[]): boolean {
return prefix.every((value, index) => bytes[index] === value)
}
/** 파일의 바이트 → 글. BOM 은 떼고 돌려준다 */
export function decodeText(bytes: Uint8Array): string {
if (startsWith(bytes, UTF8_BOM)) return new TextDecoder('utf-8').decode(bytes.subarray(UTF8_BOM.length))
if (startsWith(bytes, UTF16LE_BOM)) return new TextDecoder('utf-16le').decode(bytes.subarray(UTF16LE_BOM.length))
if (startsWith(bytes, UTF16BE_BOM)) return new TextDecoder('utf-16be').decode(bytes.subarray(UTF16BE_BOM.length))
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
return new TextDecoder('euc-kr').decode(bytes)
}
}
/**
* 글자 이름(vCard 2.1 의 CHARSET)으로 바이트를 푼다 — 모르는 이름이면 UTF-8.
* 'cp949' · 'ks_c_5601-1987' 처럼 브라우저가 모르는 한국어 이름은 EUC-KR 로 읽는다
*/
export function decodeWithCharset(bytes: Uint8Array, charset: string | null): string {
const label = (charset ?? '').trim().toLowerCase()
const korean = label === 'cp949' || label === 'ms949' || label === 'uhc' || label === 'ks_c_5601-1987'
try {
return new TextDecoder(korean ? 'euc-kr' : label || 'utf-8').decode(bytes)
} catch {
return new TextDecoder('utf-8').decode(bytes)
}
}
/** 글 안에 NUL 이 있다 — 글 파일이 아니다 (그림 · 문서 같은 바이너리) */
export function looksBinary(text: string): boolean {
return text.includes('\u0000')
}
@@ -0,0 +1,71 @@
// 연락처 가져오기의 공통 타입 — 파일(CSV · 엑셀 · vCard)을 읽은 결과와 주소록 칸으로 맞춘 결과 (16단계)
import type { ContactEmailLabel, ContactPhoneLabel } from '@/shared/types/contacts'
/** 표의 한 줄 — position 은 엑셀에서 보이는 행 번호(머리 줄이 1) */
export interface TableRow {
position: number
cells: string[]
}
/** 주소록에 칸이 없는 정보 — 메모 끝 "가져온 정보" 줄이 된다 */
export type ImportedInfoKind =
| 'workAddress'
| 'homeAddress'
| 'otherAddress'
| 'address'
| 'birthday'
| 'anniversary'
| 'homepage'
| 'nickname'
| 'messenger'
| 'phone'
export interface ImportedInfo {
kind: ImportedInfoKind
value: string
}
/**
* 파일에서 읽은 연락처 하나 — 아직 검사 · 정리 전(파일에 적힌 그대로에 가깝다).
* position 은 CSV · 엑셀은 행 번호, vCard 는 몇 번째 연락처(1부터)
*/
export interface ImportedRecord {
position: number
name: string | null
emails: { address: string; label: ContactEmailLabel }[]
phones: { number: string; label: ContactPhoneLabel }[]
company: string | null
department: string | null
title: string | null
note: string | null
/** 파일에 적힌 그룹 이름 (시스템 라벨은 뺐다) */
groups: string[]
info: ImportedInfo[]
}
/** 파일의 꼴 — 줄 번호를 "N번째 줄"(표) · "N번째 연락처"(vCard)로 적는다 */
export type ImportedFileKind = 'table' | 'vcard'
/** 읽지 못한 까닭 — 칸 아래 글 (CONTACT_IMPORT_ERROR_TEXT) */
export type ContactFileProblem = 'unsupported' | 'xls' | 'tooLarge' | 'notFound' | 'unreadable'
export type ContactFileReadResult =
| { ok: true; kind: ImportedFileKind; records: ImportedRecord[] }
| { ok: false; problem: ContactFileProblem; kind: ImportedFileKind | null }
/** 빈 연락처 하나 — 읽는 쪽이 칸을 채운다 */
export function emptyImportedRecord(position: number): ImportedRecord {
return {
position,
name: null,
emails: [],
phones: [],
company: null,
department: null,
title: null,
note: null,
groups: [],
info: [],
}
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import {
cleanGroupName,
composeName,
emptyAddressParts,
formatAddress,
formatImportedDate,
formatKoreanPhone,
guessPhoneLabel,
normalizePhone,
phoneLabelOfText,
splitEmails,
splitMultiValue,
} from './values'
// 가져온 값 다듬기 (16단계 — 디자인 원본 Contacts16 "6 칸 맞추기")
describe('composeName — 성과 이름을 하나로', () => {
it('한글끼리는 붙여 쓴다 (강 + 민지 = 강민지, 남궁 + 서연)', () => {
expect(composeName('강', '민지')).toBe('강민지')
expect(composeName('남궁', '서연')).toBe('남궁서연')
expect(composeName(' 강 ', null)).toBe('강')
expect(composeName(null, '민지')).toBe('민지')
})
it('영문은 이름 · 중간 이름 · 성 차례로 띄운다', () => {
expect(composeName('Smith', 'John')).toBe('John Smith')
expect(composeName('Smith', 'John', 'Q.')).toBe('John Q. Smith')
})
it('모두 비면 null', () => {
expect(composeName('', ' ', null)).toBeNull()
})
})
describe('전화번호', () => {
it('숫자만 적힌 국내 번호에 하이픈을 넣는다', () => {
expect(formatKoreanPhone('01012345678')).toBe('010-1234-5678')
expect(formatKoreanPhone('0111234567')).toBe('011-123-4567')
expect(formatKoreanPhone('0255501470')).toBe('02-5550-1470')
expect(formatKoreanPhone('025550147')).toBe('02-555-0147')
expect(formatKoreanPhone('0311234567')).toBe('031-123-4567')
expect(formatKoreanPhone('07012345678')).toBe('070-1234-5678')
expect(formatKoreanPhone('15881234')).toBe('1588-1234')
})
it('엑셀이 앞의 0 을 뺀 휴대전화(1012345678)는 0 을 되살린다', () => {
expect(formatKoreanPhone('1012345678')).toBe('010-1234-5678')
})
it('하이픈 · 국가 번호가 있거나 모르는 꼴이면 그대로', () => {
expect(formatKoreanPhone('010-4120-3387')).toBe('010-4120-3387')
expect(formatKoreanPhone('+82 10-1234-5678')).toBe('+82 10-1234-5678')
expect(formatKoreanPhone('12345')).toBe('12345')
})
it('tel: 을 떼고 전각 숫자 · 여러 가지 줄표를 보통 글자로', () => {
expect(normalizePhone('tel:+82-10-1234-5678;ext=12')).toBe('+82-10-1234-5678')
expect(normalizePhone('010-1234‐5678')).toBe('010-1234-5678')
})
it('종류 글 · 번호로 종류를 정한다 — 팩스는 기타, 모르는 칸의 휴대전화 번호는 휴대전화', () => {
expect(phoneLabelOfText('Work Fax')).toBe('other')
expect(phoneLabelOfText('Mobile')).toBe('mobile')
expect(phoneLabelOfText('직장')).toBe('work')
expect(phoneLabelOfText('Home')).toBe('home')
expect(phoneLabelOfText('')).toBe('other')
expect(guessPhoneLabel('010-1234-5678')).toBe('mobile')
expect(guessPhoneLabel('02-555-0147')).toBe('other')
})
})
describe('formatImportedDate', () => {
it('여러 꼴의 날짜를 "1988년 3월 14일"로', () => {
expect(formatImportedDate('19880314')).toBe('1988년 3월 14일')
expect(formatImportedDate('1988-03-14')).toBe('1988년 3월 14일')
expect(formatImportedDate('1988-03-14T00:00:00Z')).toBe('1988년 3월 14일')
expect(formatImportedDate('3/14/1988')).toBe('1988년 3월 14일')
expect(formatImportedDate('1988. 3. 14.')).toBe('1988년 3월 14일')
})
it('해를 모르면 "3월 14일" (vCard 의 --0314 · 아이폰의 1604년)', () => {
expect(formatImportedDate('--0314')).toBe('3월 14일')
expect(formatImportedDate('1604-03-14')).toBe('3월 14일')
})
it('음력이면 뒤에 (음력), 읽지 못하면 적힌 그대로, 비었거나 0 뿐이면 null', () => {
expect(formatImportedDate('19880314', true)).toBe('1988년 3월 14일 (음력)')
expect(formatImportedDate('봄 무렵')).toBe('봄 무렵')
expect(formatImportedDate('0/0/00')).toBeNull()
expect(formatImportedDate(' ')).toBeNull()
})
})
describe('formatAddress', () => {
it('한 줄 주소는 그대로, 우편번호는 뒤에 괄호로', () => {
expect(formatAddress({ ...emptyAddressParts(), full: '서울 중구 세종대로 110', postal: '04524' })).toBe(
'서울 중구 세종대로 110 (04524)',
)
})
it('한글 조각은 큰 곳부터 띄어 쓰고 우리나라 이름은 쓰지 않는다', () => {
const parts = { ...emptyAddressParts(), street: '세종대로 110', city: '중구', region: '서울', country: '대한민국' }
expect(formatAddress(parts)).toBe('서울 중구 세종대로 110')
})
it('영문 조각은 작은 곳부터 쉼표로, 다른 나라 이름은 끝에', () => {
const parts = {
...emptyAddressParts(),
street: '1 Infinite Loop',
city: 'Cupertino',
region: 'CA',
postal: '95014',
country: 'United States',
}
expect(formatAddress(parts)).toBe('1 Infinite Loop, Cupertino, CA, United States (95014)')
})
it('아무 조각도 없으면 null', () => {
expect(formatAddress(emptyAddressParts())).toBeNull()
})
})
describe('여러 값 · 그룹 이름', () => {
it('Google 의 " ::: " · 우리 내보내기의 "; " 로 나눈다', () => {
expect(splitMultiValue('* myContacts ::: 거래처')).toEqual(['* myContacts', '거래처'])
expect(splitMultiValue('010-1111-2222; 010-3333-4444')).toEqual(['010-1111-2222', '010-3333-4444'])
expect(splitEmails('a@x.kr, b@x.kr; c@x.kr ::: d@x.kr')).toEqual(['a@x.kr', 'b@x.kr', 'c@x.kr', 'd@x.kr'])
expect(splitMultiValue('a::::b ;; c')).toEqual(['a', ':b', 'c'])
})
// 옛 정규식(\s*:::\s*)은 긴 빈칸에서 자리마다 다시 훑어 길이의 제곱이라 이 시험이 시간 한도 안에 끝나지 않는다
it('긴 빈칸이 있어도 길이에 비례하게 나눈다 — 구분자 둘레의 빈칸은 나눈 뒤 뺀다', () => {
const spaces = ' '.repeat(400_000)
expect(splitMultiValue(`a${spaces}b${spaces}:::${spaces}c;d`)).toEqual([`a${spaces}b`, 'c', 'd'])
expect(splitEmails(`a@x.kr${spaces}:::${spaces}b@x.kr`)).toEqual(['a@x.kr', 'b@x.kr'])
})
it('Google 시스템 라벨(* myContacts · Starred)과 빈 이름은 뺀다, 50자까지', () => {
expect(cleanGroupName('* myContacts', 50)).toBeNull()
expect(cleanGroupName('Starred', 50)).toBeNull()
expect(cleanGroupName(' ', 50)).toBeNull()
expect(cleanGroupName(' 거래처 ', 50)).toBe('거래처')
expect(cleanGroupName('가'.repeat(60), 50)).toBe('가'.repeat(50))
})
})
@@ -0,0 +1,216 @@
// 가져온 값 다듬기 — 순수 함수 (16단계 가져오기 · 디자인 원본 Contacts16 "6 칸 맞추기")
// - 이름: 성 + 이름을 하나로 — 한글 성이면 성 다음 이름(한글끼리는 붙여 쓴다 — 강 + 민지 = 강민지, 엔진 카드 읽기와 같은 규칙), 그 밖은 이름 · 중간 이름 · 성을 띄어 쓴다
// - 전화: 숫자만 적힌 국내 번호에 하이픈을 넣는다(01012345678 → 010-1234-5678). 앞의 tel: 은 떼고, 전각 숫자 · 여러 가지 줄표는 보통 글자로
// - 날짜: 생일 · 기념일을 "1988년 3월 14일"로 (해를 모르면 "3월 14일", 읽지 못하면 적힌 그대로)
// - 여러 값이 든 칸: Google 의 " ::: " · 우리 내보내기의 "; "
import { CONTACT_IMPORT_INFO_TEXT } from '@/shared/constants/contacts'
import type { ContactPhoneLabel } from '@/shared/types/contacts'
import { singleLineText } from '@/shared/utils/contacts'
const HANGUL_SYLLABLE_START = 0xac00
const HANGUL_SYLLABLE_END = 0xd7a3
/** 한글 · 한자 · 가나가 든 이름 — 성을 앞에 쓰는 이름으로 본다 */
const EAST_ASIAN = /[ᄀ-ᇿ぀-ヿ㄰-㆏㐀-鿿가-힣豈-﫿]/
function isHangulSyllable(char: string | undefined): boolean {
const code = char?.charCodeAt(0) ?? 0
return code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END
}
/** 한 줄 글로 — 제어 문자 · 줄바꿈은 띄어쓰기, 이어진 빈칸은 하나로, 앞뒤 공백을 뺀다. 빈 글은 null */
export function cleanText(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null
const text = singleLineText(value).replace(/\s+/g, ' ').trim()
return text === '' ? null : text
}
/** 성 · 중간 이름 · 이름 → 이름 하나. 모두 비면 null */
export function composeName(family: string | null, given: string | null, middle: string | null = null): string | null {
const last = cleanText(family)
const first = cleanText(given)
const between = cleanText(middle)
if (!last && !first && !between) return null
if (last && EAST_ASIAN.test(last)) {
const rest = [between, first].filter((part): part is string => part !== null).join(' ')
if (!rest) return last
const joiner = isHangulSyllable(last.at(-1)) && isHangulSyllable(rest[0]) ? '' : ' '
return `${last}${joiner}${rest}`
}
return [first, between, last].filter((part): part is string => part !== null).join(' ')
}
// ── 여러 값이 든 칸 ───────────────────────────────────────
// 구분자 둘레의 빈칸은 나눈 뒤 trim 으로 뺀다 — 정규식에 \s*:::\s* 로 넣으면 긴 빈칸에서 자리마다 다시 훑어 길이의 제곱이다 (2026-11-10 리뷰)
/** Google 의 " ::: " · 우리 내보내기 · Outlook 의 ";" 로 나눈다 — 빈 조각은 뺀다 */
export function splitMultiValue(value: string): string[] {
return value
.split(/:::|;/)
.map((part) => part.trim())
.filter((part) => part !== '')
}
/** 이메일 칸 — 위에 더해 쉼표 · 빈칸으로도 나눈다 (주소에는 들어가지 않는 글자) */
export function splitEmails(value: string): string[] {
return value
.split(/:::|[;,\s]+/)
.map((part) => part.trim())
.filter((part) => part !== '')
}
// ── 전화 ──────────────────────────────────────────────
/** 여러 가지 줄표 — 하이픈으로 */
const DASHES = /[‐-―−﹘﹣-]/g
/** 국내 휴대전화 앞자리 */
const KOREAN_MOBILE = /^01[016789]/
/** 지역 번호 셋자리 (031 ~ 064) */
const KOREAN_AREA_CODE = /^0(?:3[1-3]|4[1-4]|5[1-5]|6[1-4])/
/** 대표 번호 (1588 · 1577 · 1544 · 1600 · 1661 …) — 여덟 자리 */
const KOREAN_REPRESENTATIVE = /^1[5-9]\d{2}/
/** 번호 하나를 다듬는다 — tel: 을 떼고 전각 숫자 · 줄표를 보통 글자로. 빈 글은 '' */
export function normalizePhone(value: string): string {
let text = value.normalize('NFKC').replace(DASHES, '-').trim()
if (/^tel:/i.test(text)) text = text.slice('tel:'.length).split(';')[0] ?? ''
return text.replace(/\s+/g, ' ').trim()
}
/** 숫자만 적힌 국내 번호에 하이픈을 넣는다 — 모르는 꼴이면 그대로 */
export function formatKoreanPhone(value: string): string {
if (!/^\d+$/.test(value)) return value
const digits = value
const length = digits.length
const split = (...sizes: number[]) => {
const parts: string[] = []
let at = 0
for (const size of sizes) {
parts.push(digits.slice(at, at + size))
at += size
}
return parts.join('-')
}
if (KOREAN_MOBILE.test(digits) && (length === 10 || length === 11)) return split(3, length - 7, 4)
if (digits.startsWith('02') && (length === 9 || length === 10)) return split(2, length - 6, 4)
if ((KOREAN_AREA_CODE.test(digits) || /^0(?:70|80)/.test(digits)) && (length === 10 || length === 11)) {
return split(3, length - 7, 4)
}
if (KOREAN_REPRESENTATIVE.test(digits) && length === 8) return split(4, 4)
// 엑셀이 숫자로 바꿔 앞의 0 이 빠진 휴대전화 (1012345678 → 010-1234-5678)
if (/^1[016789]\d{8}$/.test(digits)) return formatKoreanPhone(`0${digits}`)
return value
}
/** 번호로 종류를 짐작한다 — 종류를 모르는 칸(전화번호 · 연락처)의 국내 휴대전화는 휴대전화, 그 밖은 기타 */
export function guessPhoneLabel(number: string): ContactPhoneLabel {
return KOREAN_MOBILE.test(number.replace(/[^\d]/g, '')) ? 'mobile' : 'other'
}
/** 종류 글로 전화 종류를 정한다 — 팩스 · 호출기는 기타, 모르면 fallback */
export function phoneLabelOfText(text: string, fallback: ContactPhoneLabel = 'other'): ContactPhoneLabel {
const words = text.toLowerCase()
if (/(fax|팩스|pager|호출)/.test(words)) return 'other'
if (/(mobile|cell|iphone|휴대|핸드폰|모바일)/.test(words)) return 'mobile'
if (/(work|business|company|office|회사|직장|업무)/.test(words)) return 'work'
if (/(home|집|자택)/.test(words)) return 'home'
if (/(other|기타|main|대표|voice)/.test(words)) return 'other'
return fallback
}
// ── 날짜 ──────────────────────────────────────────────
const YEAR_UNKNOWN = new Set([0, 1604])
const MONTHS = 12
const DAYS = 31
function dateParts(value: string): { year: number | null; month: number; day: number } | null {
const text = value.trim()
const iso = /^(\d{4})-?(\d{2})-?(\d{2})(?:$|T)/.exec(text)
const noYear = /^--(\d{2})-?(\d{2})$/.exec(text)
const slashed = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text)
const dotted = /^(\d{4})\s*[./]\s*(\d{1,2})\s*[./]\s*(\d{1,2})\.?$/.exec(text)
let year: number | null = null
let month = 0
let day = 0
if (iso) [year, month, day] = [Number(iso[1]), Number(iso[2]), Number(iso[3])]
else if (noYear) [month, day] = [Number(noYear[1]), Number(noYear[2])]
else if (slashed) [month, day, year] = [Number(slashed[1]), Number(slashed[2]), Number(slashed[3])]
else if (dotted) [year, month, day] = [Number(dotted[1]), Number(dotted[2]), Number(dotted[3])]
else return null
if (month < 1 || month > MONTHS || day < 1 || day > DAYS) return null
return { year: year !== null && YEAR_UNKNOWN.has(year) ? null : year, month, day }
}
/** 날짜 글 → "1988년 3월 14일" — 읽지 못하면 적힌 그대로, 비었거나 0 뿐이면(Outlook 의 "0/0/00") null */
export function formatImportedDate(value: string | null | undefined, lunar = false): string | null {
const text = cleanText(value)
if (!text || /^[0/.\-\s]+$/.test(text)) return null
const parts = dateParts(text)
const formatted = parts ? CONTACT_IMPORT_INFO_TEXT.date(parts.year, parts.month, parts.day) : text
return lunar ? CONTACT_IMPORT_INFO_TEXT.lunar(formatted) : formatted
}
/** 양력 · 음력 칸의 값이 음력인가 — "음력" · "L" · "lunar" */
export function isLunarMarker(value: string | null | undefined): boolean {
const text = (value ?? '').trim().toLowerCase()
return text.includes('음') || text === 'l' || text.includes('lunar')
}
// ── 주소 ──────────────────────────────────────────────
export interface AddressParts {
/** 이미 한 줄로 적힌 주소 (네이버 주소 · Google Formatted) */
full: string | null
street: string | null
extended: string | null
city: string | null
region: string | null
postal: string | null
country: string | null
pobox: string | null
}
export function emptyAddressParts(): AddressParts {
return { full: null, street: null, extended: null, city: null, region: null, postal: null, country: null, pobox: null }
}
/** 나라 이름이 우리나라면 쓰지 않는다 */
const HOME_COUNTRY = /^(대한민국|한국|south korea|korea|republic of korea|kr|kor)$/i
/** 주소 조각 → 한 줄. 한글 주소는 큰 곳부터(서울 중구 세종대로 110), 그 밖은 작은 곳부터 쉼표로. 우편번호는 뒤에 괄호로 */
export function formatAddress(parts: AddressParts): string | null {
const postal = cleanText(parts.postal)
const country = cleanText(parts.country)
const foreignCountry = country && !HOME_COUNTRY.test(country) ? country : null
const full = cleanText(parts.full)
let body: string | null
if (full) {
body = full
} else {
const pieces = [parts.street, parts.extended, parts.city, parts.region, parts.pobox].map(cleanText)
const korean = pieces.some((piece) => piece !== null && EAST_ASIAN.test(piece))
const [street, extended, city, region, pobox] = pieces
const ordered = korean ? [region, city, street, extended, pobox] : [pobox, street, extended, city, region]
const present = ordered.filter((piece): piece is string => piece !== null)
body = present.length > 0 ? present.join(korean ? ' ' : ', ') : null
if (body && foreignCountry) body = korean ? `${foreignCountry} ${body}` : `${body}, ${foreignCountry}`
}
if (!body) return postal
return postal && !body.includes(postal) ? `${body} (${postal})` : body
}
// ── 그룹 ──────────────────────────────────────────────
/** Google 의 시스템 라벨 — 가져오지 않는다 ("* myContacts" · "* starred" 처럼 별표로 시작하거나 그 이름) */
const SYSTEM_GROUP_NAMES = new Set(['mycontacts', 'my contacts', 'starred', 'friends', 'family', 'coworkers'])
/** 파일에 적힌 그룹 이름을 다듬는다 — 시스템 라벨 · 빈 이름은 null, 50자까지 */
export function cleanGroupName(value: string, maxLength: number): string | null {
const text = cleanText(value)
if (!text) return null
if (text.startsWith('*')) return null
if (SYSTEM_GROUP_NAMES.has(text.toLowerCase())) return null
return text.slice(0, maxLength).trim() || null
}
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest'
import { looksLikeVCard, parseVCards, unfoldVCardLines } from './vcard'
// vCard 읽기 — 2.1 · 3.0 · 4.0, 여러 명 (16단계 가져오기 · 바깥 조사: 안드로이드(삼성) 2.1 QP 는 한글이 줄을 넘어 갈린다)
// 표본의 사람 · 주소는 지어낸 것이다
/** "강민지" 를 UTF-8 QP 로 — 한 글자(=EB=AF=BC)를 두 줄에 걸쳐 나눈다 */
const ANDROID_21 = [
'BEGIN:VCARD',
'VERSION:2.1',
'N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=EA=B0=95;=EB=AF=BC=EC=A7=80;;;',
'FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=EA=B0=95=EB=',
'=AF=BC=EC=A7=80',
'TEL;CELL;PREF:010-4120-3387',
'TEL;WORK;FAX:02-555-0148',
'EMAIL;HOME:minji.kang@example.com',
'NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=EA=B2=AC=EC=A0=81=20=EB=AC=B8=EC=9D=98=EB=8A=94=20=EC=9B=94=EB=A7=90=',
'=20=EC=A0=84=EC=97=90',
'PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
' AAAAAAAAAAAAAAAAAAAAAAAAAAA',
'',
'END:VCARD',
].join('\r\n')
const IPHONE_30 = [
'BEGIN:VCARD',
'VERSION:3.0',
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
'N:박;지훈;;;',
'FN:박지훈',
'ORG:한빛상사;영업팀;',
'TITLE:팀장',
'item1.EMAIL;type=INTERNET;type=pref:jihoon.park@example.com',
'item1.X-ABLabel:_$!<Other>!$_',
'EMAIL;type=INTERNET;type=WORK:jihoon@hanbit.example',
'TEL;type=CELL;type=VOICE;type=pref:010-3192-5540',
'item2.TEL:02-555-0147',
'item2.X-ABLabel:회사 대표',
'item3.ADR;type=WORK;type=pref:;;세종대로 110;중구;서울;04524;대한민국',
'item4.URL;type=pref:www.example.com',
'item4.X-ABLabel:_$!<HomePage>!$_',
'BDAY;value=date:1988-03-14',
'NICKNAME:지훈이',
'NOTE:견적 문의는 박지훈 팀장 →\\n강서윤 과장 차례로\\, 월말 마감',
'CATEGORIES:거래처,VIP',
'X-SOCIALPROFILE;type=twitter:x-apple:jihoon',
'END:VCARD',
'BEGIN:VCARD',
'VERSION:3.0',
'FN:김도현',
'N:김;도현;;;',
'EMAIL;type=INTERNET:dohyun.kim@example.net',
'NOTE:아주 긴 메모가 접혀서 다음 줄로',
' 이어집니다',
'END:VCARD',
].join('\n')
const VCARD_40 = [
'BEGIN:VCARD',
'VERSION:4.0',
'FN:Jane Doe',
'N:Doe;Jane;;;',
'TEL;VALUE=uri;TYPE="voice,home":tel:+1-555-555-5555;ext=5555',
'TEL;TYPE="cell,voice";VALUE=uri:tel:+82-10-2222-3333',
'EMAIL;TYPE=work:jane@example.org',
'ANNIVERSARY:20150502',
'IMPP;PREF=1:xmpp:jane@example.org',
'END:VCARD',
].join('\r\n')
describe('unfoldVCardLines', () => {
it('빈칸 접기는 앞 칸 하나를 떼고 잇는다 (3.0 · 4.0)', () => {
expect(unfoldVCardLines('NOTE:ab\r\n cd\r\n\tef\r\nFN:x')).toEqual(['NOTE:abcdef', 'FN:x'])
})
it('QUOTED-PRINTABLE 의 = 접힘은 끝의 = 를 떼고 다음 줄을 잇는다 — QUOTED-PRINTABLE 이 아닌 줄의 끝 = 는 그대로', () => {
expect(unfoldVCardLines('NOTE;ENCODING=QUOTED-PRINTABLE:=EA=\n=B0=95\nFN:a=\nN:b')).toEqual([
'NOTE;ENCODING=QUOTED-PRINTABLE:=EA=B0=95',
'FN:a=',
'N:b',
])
})
it('머리가 접혀 있어도 첫 ":" 앞을 다 모은 뒤 QUOTED-PRINTABLE 인지 본다', () => {
expect(unfoldVCardLines('NOTE;ENCODING=QUOTED-\r\n PRINTABLE:=EA=\r\n=B0=95\r\nFN:x')).toEqual([
'NOTE;ENCODING=QUOTED-PRINTABLE:=EA=B0=95',
'FN:x',
])
})
it('쓰지 않는 속성(PHOTO · LOGO · SOUND · KEY)은 이어지는 줄째 빼고, 다음 속성은 그대로 읽는다', () => {
const text = [
'FN:a',
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ',
' AAAA',
' BBBB',
'item1.LOGO:x',
' y',
'EMAIL:a@example.com',
'KEY;ENCODING=QUOTED-PRINTABLE:=41=',
'=42',
'sound:z',
'NOTE:b',
].join('\r\n')
expect(unfoldVCardLines(text)).toEqual(['FN:a', 'EMAIL:a@example.com', 'NOTE:b'])
})
// 옛 방식(이어 붙인 글을 물리 줄마다 다시 봄)은 줄 수의 제곱이라 이 시험이 시간 한도 안에 끝나지 않는다 — 벽시계 대신 "끝나고 맞는가"로 본다
it('이어지는 줄이 20만 개여도 끝나고 결과가 맞다 — 빈칸 접기 · = 접기 모두 조각을 모아 한 번에 잇는다', () => {
const count = 200_000
const digits = Array.from({ length: count }, (_, index) => String(index % 10))
const folded = ['NOTE:시작', ...digits.map((digit) => ` ${digit}`), 'FN:끝'].join('\r\n')
expect(unfoldVCardLines(folded)).toEqual([`NOTE:시작${digits.join('')}`, 'FN:끝'])
const soft = ['NOTE;ENCODING=QUOTED-PRINTABLE:=41=', ...Array.from({ length: count }, () => '=42='), '=43', 'FN:끝'].join('\n')
expect(unfoldVCardLines(soft)).toEqual([`NOTE;ENCODING=QUOTED-PRINTABLE:=41${'=42'.repeat(count)}=43`, 'FN:끝'])
})
})
describe('parseVCards', () => {
it('2.1 QUOTED-PRINTABLE — 접힌 줄을 바이트로 이은 뒤 CHARSET 대로 푼다 (한 글자가 두 줄에 나뉘어도)', () => {
const [card] = parseVCards(ANDROID_21)
expect(card?.name).toBe('강민지')
expect(card?.note).toBe('견적 문의는 월말 전에')
expect(card?.phones).toEqual([
{ number: '010-4120-3387', label: 'mobile' },
{ number: '02-555-0148', label: 'other' },
])
expect(card?.emails).toEqual([{ address: 'minji.kang@example.com', label: 'personal' }])
})
it('3.0 — 여러 명, 아이폰 묶음 라벨(item1.X-ABLabel), 이스케이프, 그룹, 칸 없는 정보', () => {
const cards = parseVCards(IPHONE_30)
expect(cards.map((card) => card.position)).toEqual([1, 2])
const [jihoon, dohyun] = cards
expect(jihoon).toMatchObject({
name: '박지훈',
company: '한빛상사',
department: '영업팀',
title: '팀장',
note: '견적 문의는 박지훈 팀장 →\n강서윤 과장 차례로, 월말 마감',
groups: ['거래처', 'VIP'],
})
expect(jihoon?.emails).toEqual([
{ address: 'jihoon.park@example.com', label: 'other' },
{ address: 'jihoon@hanbit.example', label: 'work' },
])
// item2 의 라벨 "회사 대표" — 사용자가 지은 라벨도 낱말로 종류를 정한다
expect(jihoon?.phones).toEqual([
{ number: '010-3192-5540', label: 'mobile' },
{ number: '02-555-0147', label: 'work' },
])
expect(jihoon?.info).toEqual([
{ kind: 'workAddress', value: '서울 중구 세종대로 110 (04524)' },
{ kind: 'homepage', value: 'www.example.com' },
{ kind: 'birthday', value: '1988년 3월 14일' },
{ kind: 'nickname', value: '지훈이' },
])
expect(dohyun).toMatchObject({ name: '김도현', note: '아주 긴 메모가 접혀서 다음 줄로 이어집니다' })
expect(dohyun?.emails).toEqual([{ address: 'dohyun.kim@example.net', label: 'other' }])
})
it('4.0 — tel: 주소 · 따옴표로 감싼 TYPE · 기념일 · 메신저', () => {
const [card] = parseVCards(VCARD_40)
expect(card?.name).toBe('Jane Doe')
expect(card?.phones).toEqual([
{ number: '+1-555-555-5555', label: 'home' },
{ number: '+82-10-2222-3333', label: 'mobile' },
])
expect(card?.emails).toEqual([{ address: 'jane@example.org', label: 'work' }])
expect(card?.info).toEqual([
{ kind: 'anniversary', value: '2015년 5월 2일' },
{ kind: 'messenger', value: 'xmpp:jane@example.org' },
])
})
it('FN 이 없으면 N 으로 이름을 만든다 (한글은 붙여 쓴다)', () => {
const [card] = parseVCards('BEGIN:VCARD\nVERSION:3.0\nN:남궁;서연;;;\nEND:VCARD')
expect(card?.name).toBe('남궁서연')
})
it('안에 든 vCard(2.1 AGENT)는 건너뛰고, 꼴이 틀린 줄은 무시한다', () => {
const text = [
'BEGIN:VCARD',
'FN:바깥',
'AGENT:',
'BEGIN:VCARD',
'FN:안쪽',
'END:VCARD',
'잘못된 줄',
'EMAIL:out@example.com',
'END:VCARD',
].join('\n')
const cards = parseVCards(text)
expect(cards).toHaveLength(1)
expect(cards[0]).toMatchObject({ name: '바깥', emails: [{ address: 'out@example.com', label: 'other' }] })
})
it('사진(PHOTO)의 이어지는 줄이 20만 개여도 끝나고, 사진 뒤의 칸까지 읽는다', () => {
const photo = Array.from({ length: 200_000 }, () => ` ${'A'.repeat(75)}`)
const text = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:강민지',
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ',
...photo,
'TEL;TYPE=CELL:010-4120-3387',
'EMAIL;TYPE=WORK:minji.kang@example.com',
'END:VCARD',
].join('\r\n')
const cards = parseVCards(text)
expect(cards).toHaveLength(1)
expect(cards[0]).toMatchObject({
name: '강민지',
phones: [{ number: '010-4120-3387', label: 'mobile' }],
emails: [{ address: 'minji.kang@example.com', label: 'work' }],
})
})
it('매개변수가 20만 개인 줄 · 그룹 이름이 20만 개인 CATEGORIES 도 끝나고 맞게 읽는다', () => {
const tel = `TEL${';TYPE=CELL'.repeat(200_000)}:010-4120-3387`
const names = Array.from({ length: 200_000 }, (_, index) => `g${index}`)
const [card] = parseVCards(['BEGIN:VCARD', 'FN:a', tel, `CATEGORIES:${names.join(',')}`, 'END:VCARD'].join('\n'))
expect(card?.phones).toEqual([{ number: '010-4120-3387', label: 'mobile' }])
expect(card?.groups).toEqual(names)
})
it('첫 줄이 BEGIN:VCARD 면 vCard 다', () => {
expect(looksLikeVCard('\n\nbegin:vcard\nFN:x')).toBe(true)
expect(looksLikeVCard('이름,이메일\n')).toBe(false)
})
})
@@ -0,0 +1,430 @@
// vCard(.vcf) 읽기 — 2.1 · 3.0 · 4.0, 한 파일에 여러 명 (16단계 가져오기 · 디자인 원본 Contacts16 "6 받는 파일")
// - 접힌 줄: 줄 머리의 빈칸 · 탭 하나를 떼고 앞 줄에 잇는다(3.0 · 4.0)
// - 2.1 QUOTED-PRINTABLE: 줄 끝 "=" 로 접힌 줄을 먼저 바이트로 이은 뒤 CHARSET(없으면 UTF-8)대로 푼다 — 안드로이드(삼성)는 한글 한 글자를
// 두 줄에 나눠 쓴다(=EA= / =B0=91). 줄마다 풀면 글자가 깨진다
// - 값의 이스케이프(\n \, \; \\), 구조 값(N · ADR · ORG 의 ;), 목록 값(CATEGORIES · NICKNAME 의 ,)
// - 아이폰의 묶음 이름(item1.EMAIL + item1.X-ABLabel:_$!<Other>!$_)으로 종류를 정한다
// - 사진(PHOTO) · 로고 · 소리 · 열쇠는 읽지 않는다 (주소록에 칸이 없다) — 이어지는 줄도 모으지 않고 건너뛴다
// - 줄 수 · 글 길이에 비례하게 읽는다 (사진 든 vCard 는 이어지는 줄이 수만 개다)
import type { ContactEmailLabel, ContactPhoneLabel } from '@/shared/types/contacts'
import { decodeWithCharset } from './text'
import { emptyImportedRecord, type ImportedInfoKind, type ImportedRecord } from './types'
import {
cleanText,
composeName,
emptyAddressParts,
formatAddress,
formatImportedDate,
normalizePhone,
phoneLabelOfText,
} from './values'
interface VCardProperty {
/** 묶음 이름 (item1) — 없으면 null */
group: string | null
/** 대문자 */
name: string
/** 대문자 이름 → 값들 (2.1 의 이름 없는 값 — WORK · CELL — 은 TYPE 으로) */
params: Map<string, string[]>
/** 풀어 둔 값 — 이스케이프는 아직 그대로 */
value: string
}
const BEGIN = /^BEGIN:VCARD$/i
const END = /^END:VCARD$/i
const QUOTED_PRINTABLE = /QUOTED-PRINTABLE/i
/** 2.1 의 이름 없는 인코딩 값 */
const BARE_ENCODINGS = new Set(['QUOTED-PRINTABLE', 'BASE64', '8BIT', '7BIT', 'B'])
/** 메신저 칸 (아이폰 · 옛 프로그램) — 서비스 이름 */
const MESSENGER_PROPERTIES: Readonly<Record<string, string>> = {
'X-AIM': 'AIM',
'X-ICQ': 'ICQ',
'X-JABBER': 'Jabber',
'X-MSN': 'MSN',
'X-YAHOO': 'Yahoo',
'X-SKYPE': 'Skype',
'X-SKYPE-USERNAME': 'Skype',
'X-GOOGLE-TALK': 'Google Talk',
'X-GTALK': 'Google Talk',
'X-QQ': 'QQ',
'X-WECHAT': 'WeChat',
'X-LINE': 'LINE',
'X-KAKAOTALK': '카카오톡',
}
/** 아이폰 라벨 "_$!<Mobile>!$_" 의 안쪽 */
const APPLE_LABEL = /^_\$!<(.*)>!\$_$/
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/
// ── 줄 ────────────────────────────────────────────────
/** 쓰지 않는 속성 — 이어지는 줄(사진 · 소리의 base64 — 수만 줄)을 모으지 않고 통째로 건너뛴다 */
const SKIPPED_PROPERTIES = new Set(['PHOTO', 'LOGO', 'SOUND', 'KEY'])
/** 속성 이름의 끝 — 첫 ";" 또는 ":" */
const NAME_END = /[;:]/
/** 논리 줄 첫 물리 줄의 속성 이름(대문자 — 묶음 이름 item1. 은 뗀다). 이 줄에서 알 수 없으면 null */
function propertyNameOf(line: string): string | null {
const end = line.search(NAME_END)
if (end < 0) return null
const head = line.slice(0, end)
return head.slice(head.lastIndexOf('.') + 1).trim().toUpperCase()
}
/**
* 물리 줄 → 논리 줄 — QUOTED-PRINTABLE 의 "=" 접힘과 빈칸 접힘을 잇는다
* - 조각은 배열에 모았다가 한 번에 잇는다 — 이어 붙인 글을 물리 줄마다 다시 보면 줄 수의 제곱이다(사진 든 vCard, 이어지는 줄 2만 개에 16.8초 — 2026-11-10 리뷰)
* - "=" 접힘은 마지막 물리 줄의 끝으로 가른다. QUOTED-PRINTABLE 인지는 논리 줄의 첫 ":" 앞(머리)을 다 모았을 때 한 번 본다
* - 쓰지 않는 속성(PHOTO · LOGO · SOUND · KEY)은 이어지는 줄을 모으지 않고 논리 줄째 뺀다
*/
export function unfoldVCardLines(text: string): string[] {
const lines: string[] = []
/** 지금 논리 줄의 조각 — null 이면 아직 줄이 없다 */
let pieces: string[] | null = null
/** 지금 논리 줄의 마지막 물리 조각 */
let last = ''
/** 첫 ":" 앞까지 모은 머리 — 다 모았으면 null */
let head: string | null = null
let quotedPrintable = false
let skipped = false
const add = (piece: string) => {
if (head !== null) {
const colon = piece.indexOf(':')
if (colon < 0) {
head += piece
} else {
const whole = head + piece.slice(0, colon)
quotedPrintable = whole !== '' && QUOTED_PRINTABLE.test(whole)
head = null
}
}
if (!skipped) pieces?.push(piece)
last = piece
}
const finish = () => {
if (pieces !== null && !skipped) lines.push(pieces.join(''))
}
for (const raw of text.split(/\r\n|\r|\n/)) {
if (pieces !== null && quotedPrintable && last.endsWith('=')) {
// "=" 접힘 — 앞 조각 끝의 "=" 를 떼고 이 줄을 그대로 잇는다(줄 머리의 빈칸도 값이다)
if (!skipped) pieces[pieces.length - 1] = last.slice(0, -1)
add(raw)
continue
}
if (pieces !== null && (raw.startsWith(' ') || raw.startsWith('\t'))) {
add(raw.slice(1))
continue
}
finish()
pieces = []
head = ''
quotedPrintable = false
skipped = SKIPPED_PROPERTIES.has(propertyNameOf(raw) ?? '')
add(raw)
}
finish()
return lines
}
/** 따옴표 밖의 첫 구분자 자리 */
function indexOutsideQuotes(text: string, separator: string): number {
let quoted = false
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (char === '"') quoted = !quoted
else if (char === separator && !quoted) return index
}
return -1
}
/** 따옴표 밖의 구분자로 나눈다 */
function splitOutsideQuotes(text: string, separator: string): string[] {
const parts: string[] = []
let rest = text
for (;;) {
const at = indexOutsideQuotes(rest, separator)
if (at < 0) {
parts.push(rest)
return parts
}
parts.push(rest.slice(0, at))
rest = rest.slice(at + 1)
}
}
function unquote(value: string): string {
return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
}
/** QUOTED-PRINTABLE → 바이트 (이미 이은 줄) */
function decodeQuotedPrintable(value: string): Uint8Array {
const bytes: number[] = []
const encoder = new TextEncoder()
for (let index = 0; index < value.length; index += 1) {
const char = value[index] ?? ''
const hex = value.slice(index + 1, index + 3)
if (char === '=' && HEX_PAIR.test(hex)) {
bytes.push(Number.parseInt(hex, 16))
index += 2
continue
}
if (char === '=') continue
bytes.push(...encoder.encode(char))
}
return Uint8Array.from(bytes)
}
/** 논리 줄 하나 → 속성. 꼴이 틀리면 null */
function parseProperty(line: string): VCardProperty | null {
const colon = indexOutsideQuotes(line, ':')
if (colon <= 0) return null
const [head = '', ...paramParts] = splitOutsideQuotes(line.slice(0, colon), ';')
const dot = head.lastIndexOf('.')
const group = dot > 0 ? head.slice(0, dot) : null
const name = (dot > 0 ? head.slice(dot + 1) : head).trim().toUpperCase()
if (name === '') return null
const params = new Map<string, string[]>()
// 있던 목록에 더한다 — 매번 새 배열로 베끼면 매개변수 수의 제곱이다
const add = (key: string, values: readonly string[]) => {
const list = params.get(key)
if (list) for (const value of values) list.push(value)
else params.set(key, [...values])
}
for (const part of paramParts) {
const equals = part.indexOf('=')
if (equals < 0) {
const bare = part.trim().toUpperCase()
if (BARE_ENCODINGS.has(bare)) add('ENCODING', [bare])
else if (bare !== '') add('TYPE', [bare])
continue
}
const key = part.slice(0, equals).trim().toUpperCase()
// TYPE=WORK,VOICE · TYPE="work,voice"(4.0) 모두 낱낱이
const values = splitOutsideQuotes(part.slice(equals + 1), ',')
.flatMap((value) => unquote(value.trim()).split(','))
.map((value) => value.trim())
.filter((value) => value !== '')
add(key, key === 'TYPE' ? values.map((value) => value.toUpperCase()) : values)
}
let value = line.slice(colon + 1)
const encoding = (params.get('ENCODING') ?? []).map((item) => item.toUpperCase())
if (encoding.includes('QUOTED-PRINTABLE')) {
value = decodeWithCharset(decodeQuotedPrintable(value), params.get('CHARSET')?.[0] ?? null)
}
return { group, name, params, value }
}
// ── 값 ────────────────────────────────────────────────
/** 이스케이프 밖의 구분자로 나눈다 (이스케이프는 그대로 둔다 — unescapeValue 가 푼다) */
function splitEscaped(value: string, separator: ';' | ','): string[] {
const parts: string[] = []
let current = ''
for (let index = 0; index < value.length; index += 1) {
const char = value[index] ?? ''
if (char === '\\' && index + 1 < value.length) {
current += char + (value[index + 1] ?? '')
index += 1
continue
}
if (char === separator) {
parts.push(current)
current = ''
continue
}
current += char
}
parts.push(current)
return parts
}
function unescapeValue(value: string): string {
return value.replace(/\\([nN,;:\\])/g, (_match, char: string) => (char === 'n' || char === 'N' ? '\n' : char))
}
function textOf(property: VCardProperty): string {
return unescapeValue(property.value)
}
function structured(property: VCardProperty): string[] {
return splitEscaped(property.value, ';').map(unescapeValue)
}
function listOf(property: VCardProperty): string[] {
return splitEscaped(property.value, ',')
.map((part) => unescapeValue(part).trim())
.filter((part) => part !== '')
}
function typesOf(property: VCardProperty): string[] {
return property.params.get('TYPE') ?? []
}
/** 묶음의 아이폰 라벨 — "_$!<Mobile>!$_" 는 "Mobile", 사용자가 지은 라벨은 그대로 */
function appleLabel(labels: ReadonlyMap<string, string>, property: VCardProperty): string {
const label = property.group ? (labels.get(property.group.toUpperCase()) ?? '') : ''
return APPLE_LABEL.exec(label)?.[1] ?? label
}
function emailLabelOf(property: VCardProperty, labels: ReadonlyMap<string, string>): ContactEmailLabel {
const types = typesOf(property)
if (types.includes('WORK')) return 'work'
if (types.includes('HOME')) return 'personal'
const label = appleLabel(labels, property).toLowerCase()
if (/(work|회사|직장)/.test(label)) return 'work'
if (/(home|개인|집)/.test(label)) return 'personal'
return 'other'
}
function phoneLabelOf(property: VCardProperty, labels: ReadonlyMap<string, string>): ContactPhoneLabel {
const types = typesOf(property)
if (types.some((type) => type === 'FAX' || type === 'PAGER')) return 'other'
if (types.some((type) => type === 'CELL' || type === 'MOBILE' || type === 'IPHONE')) return 'mobile'
if (types.includes('WORK')) return 'work'
if (types.includes('HOME')) return 'home'
return phoneLabelOfText(appleLabel(labels, property))
}
function addressKindOf(property: VCardProperty, labels: ReadonlyMap<string, string>): ImportedInfoKind {
const types = typesOf(property)
if (types.includes('WORK')) return 'workAddress'
if (types.includes('HOME')) return 'homeAddress'
const label = appleLabel(labels, property).toLowerCase()
if (/(work|회사|직장)/.test(label)) return 'workAddress'
if (/(home|집)/.test(label)) return 'homeAddress'
if (/(other|기타)/.test(label)) return 'otherAddress'
return 'address'
}
/** 기념일 이름 — 아이폰 라벨이 "Anniversary" 가 아니면 그 이름을 앞에 */
function anniversaryOf(property: VCardProperty, labels: ReadonlyMap<string, string>): string | null {
const date = formatImportedDate(textOf(property))
if (!date) return null
const label = cleanText(appleLabel(labels, property))
return label && !/anniversary/i.test(label) ? `${label} ${date}` : date
}
// ── 연락처 하나 ──────────────────────────────────────────
function recordOf(position: number, properties: readonly VCardProperty[]): ImportedRecord {
const record = emptyImportedRecord(position)
const labels = new Map<string, string>()
for (const property of properties) {
if (property.name === 'X-ABLABEL' && property.group) labels.set(property.group.toUpperCase(), textOf(property).trim())
}
let formatted: string | null = null
let parts: string[] | null = null
const notes: string[] = []
for (const property of properties) {
switch (property.name) {
case 'FN':
formatted ??= cleanText(textOf(property))
break
case 'N':
parts ??= structured(property)
break
case 'NICKNAME':
for (const nickname of listOf(property)) record.info.push({ kind: 'nickname', value: nickname })
break
case 'EMAIL': {
const address = textOf(property).trim().replace(/^mailto:/i, '')
if (address) record.emails.push({ address, label: emailLabelOf(property, labels) })
break
}
case 'TEL': {
const number = normalizePhone(textOf(property))
if (number) record.phones.push({ number, label: phoneLabelOf(property, labels) })
break
}
case 'ORG': {
const [company = '', department = ''] = structured(property)
record.company ??= cleanText(company)
record.department ??= cleanText(department)
break
}
case 'TITLE':
record.title ??= cleanText(textOf(property))
break
case 'NOTE':
if (textOf(property).trim()) notes.push(textOf(property).trim())
break
case 'CATEGORIES':
// 펼쳐 넘기지 않는다(push(...) 는 인자 수 한도 — 이름이 수십만 개면 RangeError)
for (const name of listOf(property)) record.groups.push(name)
break
case 'ADR': {
const [pobox = '', extended = '', street = '', city = '', region = '', postal = '', country = ''] = structured(property)
const value = formatAddress({ ...emptyAddressParts(), pobox, extended, street, city, region, postal, country })
if (value) record.info.push({ kind: addressKindOf(property, labels), value })
break
}
case 'BDAY': {
const value = formatImportedDate(textOf(property))
if (value) record.info.push({ kind: 'birthday', value })
break
}
case 'ANNIVERSARY':
case 'X-ANNIVERSARY':
case 'X-ABDATE': {
const value = anniversaryOf(property, labels)
if (value) record.info.push({ kind: 'anniversary', value })
break
}
case 'URL': {
const value = cleanText(textOf(property))
if (value) record.info.push({ kind: 'homepage', value })
break
}
case 'IMPP': {
const value = cleanText(textOf(property))
if (value) record.info.push({ kind: 'messenger', value })
break
}
default: {
const service = MESSENGER_PROPERTIES[property.name]
const value = service ? cleanText(textOf(property)) : null
if (service && value) record.info.push({ kind: 'messenger', value: `${service} ${value}` })
}
}
}
const [family = '', given = '', middle = ''] = parts ?? []
record.name = formatted ?? composeName(family, given, middle)
record.note = notes.length > 0 ? notes.join('\n') : null
return record
}
/** vCard 글 → 연락처들 (BEGIN:VCARD 마다 하나 — 안에 든 vCard(2.1 AGENT)는 건너뛴다) */
export function parseVCards(text: string): ImportedRecord[] {
const records: ImportedRecord[] = []
let depth = 0
let position = 0
let properties: VCardProperty[] = []
for (const line of unfoldVCardLines(text)) {
const trimmed = line.trim()
if (BEGIN.test(trimmed)) {
depth += 1
if (depth === 1) {
position += 1
properties = []
}
continue
}
if (END.test(trimmed)) {
if (depth === 1) records.push(recordOf(position, properties))
depth = Math.max(0, depth - 1)
continue
}
if (depth !== 1) continue
const property = parseProperty(line)
if (property) properties.push(property)
}
return records
}
/** 글이 vCard 인가 — 빈 줄을 빼고 첫 줄이 BEGIN:VCARD */
export function looksLikeVCard(text: string): boolean {
const first = text.split(/\r\n|\r|\n/).find((line) => line.trim() !== '')
return first !== undefined && BEGIN.test(first.trim())
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { CONTACT_IMPORT_XLSX_CELLS_MAX } from '@/shared/constants/contacts'
import { buildXlsx, buildZip } from '@/test-utils/zip'
import { NotSpreadsheetError, readXlsxRows } from './xlsx'
import { readZipDirectory, readZipEntry, ZipFormatError, ZipLimitError } from './zip'
// 엑셀(.xlsx) 읽기 — zip 가운데 목록 → DecompressionStream('deflate-raw') → DOMParser (16단계 가져오기 · 새 의존성 없음)
// 표본은 시험 안에서 만든 작은 xlsx 다 (test-utils/zip.ts)
describe('zip', () => {
it('가운데 목록으로 항목을 찾고 deflate · 그대로 둔 항목을 푼다', async () => {
const bytes = await buildZip([
{ name: 'a.txt', data: '가나다'.repeat(100) },
{ name: 'b.txt', data: 'stored', method: 'store' },
])
const entries = readZipDirectory(bytes)
expect([...entries.keys()]).toEqual(['a.txt', 'b.txt'])
const decoder = new TextDecoder()
expect(decoder.decode(await readZipEntry(bytes, entries.get('a.txt')!, 10_000))).toBe('가나다'.repeat(100))
expect(decoder.decode(await readZipEntry(bytes, entries.get('b.txt')!, 10_000))).toBe('stored')
})
it('푼 크기가 상한을 넘으면 그 순간 멈춘다 — 목록에 적힌 크기를 믿지 않는다(압축 폭탄)', async () => {
const bytes = await buildZip([{ name: 'bomb.xml', data: '0'.repeat(200_000) }])
const entry = readZipDirectory(bytes).get('bomb.xml')!
await expect(readZipEntry(bytes, { ...entry, size: 10 }, 50_000)).rejects.toBeInstanceOf(ZipLimitError)
})
it('암호를 건 항목 · 꼴이 틀린 zip 은 읽지 않는다', async () => {
const encrypted = await buildZip([{ name: 'x.xml', data: '<a/>', encrypted: true }])
expect(() => readZipDirectory(encrypted)).toThrow(ZipFormatError)
expect(() => readZipDirectory(Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 0, 0]))).toThrow(ZipFormatError)
})
})
describe('readXlsxRows', () => {
it('칸 안 문자열(네이버 내보내기) — 줄 번호 · 빈 칸 자리를 맞춘다', async () => {
const bytes = await buildXlsx([
['성', '이름', '이메일', '메모'],
['강', '민지', 'minji.kang@example.com', ''],
['박', '', 'jihoon@example.com', '줄바꿈이\n있는 메모'],
])
expect(await readXlsxRows(bytes)).toEqual([
{ position: 1, cells: ['성', '이름', '이메일', '메모'] },
{ position: 2, cells: ['강', '민지', 'minji.kang@example.com'] },
{ position: 3, cells: ['박', '', 'jihoon@example.com', '줄바꿈이\n있는 메모'] },
])
})
it('공유 문자열(엑셀이 저장한 파일)과 시트 파일 이름을 workbook 관계로 찾는다', async () => {
const bytes = await buildXlsx(
[
['Name', 'E-mail'],
['Kim & Lee <Co>', 'kim@example.com'],
],
{ strings: 'shared', sheetFile: 'contacts.xml' },
)
expect(await readXlsxRows(bytes)).toEqual([
{ position: 1, cells: ['Name', 'E-mail'] },
{ position: 2, cells: ['Kim & Lee <Co>', 'kim@example.com'] },
])
})
it('xl/workbook.xml 이 없는 zip 은 엑셀이 아니다', async () => {
const bytes = await buildZip([{ name: 'word/document.xml', data: '<w:document/>' }])
await expect(readXlsxRows(bytes)).rejects.toBeInstanceOf(NotSpreadsheetError)
})
it('DOCTYPE 이 든 XML 은 읽지 않는다 (엔티티 부풀리기)', async () => {
const bytes = await buildZip([
{ name: 'xl/workbook.xml', data: '<!DOCTYPE x [<!ENTITY a "aaaa">]><workbook/>' },
])
await expect(readXlsxRows(bytes)).rejects.toBeInstanceOf(ZipFormatError)
})
/** 시트 XML 을 손으로 쓴 작은 xlsx — 관계 파일이 없으면 xl/worksheets 의 첫 시트를 읽는다 */
function sheetOnly(rowsXml: string): Promise<Uint8Array> {
return buildZip([
{ name: 'xl/workbook.xml', data: '<workbook><sheets><sheet name="a" sheetId="1"/></sheets></workbook>' },
{ name: 'xl/worksheets/sheet1.xml', data: `<worksheet><sheetData>${rowsXml}</sheetData></worksheet>` },
])
}
const inline = (ref: string, text: string) => `<c r="${ref}" t="inlineStr"><is><t>${text}</t></is></c>`
it('열은 XFD(16,384번째)까지 — 넘는 칸(XFE · 글자가 긴 열)은 버리고, 끝에 붙는 빈 칸은 늘어놓지 않는다', async () => {
const bytes = await sheetOnly(
`<row r="1">${inline('A1', '이름')}${inline('XFD1', '끝')}${inline('XFE1', '넘침')}${inline(`${'Z'.repeat(40)}1`, '넘침')}</row>` +
`<row r="2">${inline('A2', '강민지')}<c r="B2" s="3"/><c r="Z2" s="3"/></row>` +
'<row r="3"><c r="A3" s="3"/><c r="XFD3" s="3"/></row>',
)
const [first, second, third] = await readXlsxRows(bytes)
expect(first?.cells).toHaveLength(16_384)
expect(first?.cells[0]).toBe('이름')
expect(first?.cells[16_383]).toBe('끝')
expect(second).toEqual({ position: 2, cells: ['강민지'] })
expect(third).toEqual({ position: 3, cells: [] })
})
it('늘어놓는 칸 수의 합에 상한 — 먼 열에 칸 하나씩 둔 줄이 많으면 열지 않는다', async () => {
// 줄마다 16,384칸 — 상한을 넘는 만큼의 줄
const count = Math.ceil(CONTACT_IMPORT_XLSX_CELLS_MAX / 16_384) + 1
const rows = Array.from({ length: count }, (_, index) => `<row r="${index + 1}">${inline(`XFD${index + 1}`, 'x')}</row>`)
await expect(readXlsxRows(await sheetOnly(rows.join('')))).rejects.toBeInstanceOf(ZipFormatError)
})
})
@@ -0,0 +1,185 @@
// 엑셀(.xlsx) 읽기 — 첫 시트의 줄을 글로 꺼낸다 (16단계 가져오기 · 새 의존성 없음)
// .xlsx 는 zip 안의 XML 이다: xl/workbook.xml 의 첫 <sheet>(탭 차례) → xl/_rels/workbook.xml.rels 로 그 시트 파일 →
// 칸의 글은 공유 문자열(xl/sharedStrings.xml — t="s"), 칸 안 문자열(t="inlineStr" — 네이버 내보내기), 그 밖은 <v> 값 그대로.
// XML 은 브라우저의 DOMParser 로 읽는다. DOCTYPE · ENTITY 가 든 XML 은 읽지 않는다(엔티티 부풀리기 — 엑셀은 쓰지 않는다)
// 칸 자리는 칸의 r 속성(B2 — 빈 칸은 빠져 있다)으로 맞추고, 줄 번호는 줄의 r 속성(엑셀에서 보이는 행 번호)이다.
// 열은 XFD(16,384번째)까지만 읽고, 늘어놓는 칸 수의 합에 상한을 둔다(CONTACT_IMPORT_XLSX_CELLS_MAX — 넘으면 열지 않는다)
import {
CONTACT_IMPORT_XLSX_CELLS_MAX,
CONTACT_IMPORT_XLSX_ENTRY_MAX_BYTES,
CONTACT_IMPORT_XLSX_TOTAL_MAX_BYTES,
} from '@/shared/constants/contacts'
import type { TableRow } from './types'
import { readZipDirectory, readZipEntry, ZipFormatError, type ZipEntry } from './zip'
/** 엑셀 파일이 아니다 — zip 이지만 xl/workbook.xml 이 없다 (워드 · 그냥 zip 등) */
export class NotSpreadsheetError extends Error {
constructor() {
super('엑셀 파일이 아닙니다')
this.name = 'NotSpreadsheetError'
}
}
const WORKBOOK_PATH = 'xl/workbook.xml'
const WORKBOOK_RELS_PATH = 'xl/_rels/workbook.xml.rels'
const SHARED_STRINGS_PATH = 'xl/sharedStrings.xml'
const RELATIONSHIPS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
const SHARED_STRINGS_TYPE = /\/sharedStrings$/
const SHEET_PATH = /^xl\/worksheets\/[^/]+\.xml$/
const UNSAFE_XML = /<!DOCTYPE|<!ENTITY/i
/** 칸 자리 "AB12" — 열 글자 */
const CELL_COLUMN = /^([A-Za-z]+)/
const ALPHABET_SIZE = 26
const CHAR_CODE_A = 64
/** 엑셀의 열은 XFD(16,384번째)까지 — 넘는 칸은 버린다. 상한이 없으면 r="ZZZZZZZ1" 하나로 빈 칸 채우기가 끝나지 않는다 */
const MAX_COLUMNS = 16_384
/** 열 글자는 세 자(XFD)까지 */
const MAX_COLUMN_LETTERS = 3
/** 풀어 읽은 XML 의 크기 합 — 압축 폭탄을 막는다 */
interface ReadBudget {
used: number
}
async function readXml(
bytes: Uint8Array,
entries: Map<string, ZipEntry>,
path: string,
budget: ReadBudget,
): Promise<Document | null> {
const entry = entries.get(path)
if (!entry) return null
const limit = Math.min(CONTACT_IMPORT_XLSX_ENTRY_MAX_BYTES, CONTACT_IMPORT_XLSX_TOTAL_MAX_BYTES - budget.used)
const data = await readZipEntry(bytes, entry, limit)
budget.used += data.byteLength
const text = new TextDecoder('utf-8').decode(data)
if (UNSAFE_XML.test(text)) throw new ZipFormatError('DOCTYPE 이 든 XML')
const doc = new DOMParser().parseFromString(text, 'application/xml')
if (doc.getElementsByTagName('parsererror').length > 0) throw new ZipFormatError('XML 을 읽지 못했습니다')
return doc
}
/** 이름(localName)으로 찾는다 — 엑셀 · 다른 프로그램이 접두어(x:)를 붙여도 */
function elementsNamed(root: Document | Element, localName: string): Element[] {
return Array.from(root.getElementsByTagNameNS('*', localName))
}
function childrenNamed(parent: Element, localName: string): Element[] {
return Array.from(parent.children).filter((child) => child.localName === localName)
}
/** <si> · <is> 안의 글 — 글 조각(<t>)을 잇는다. 읽는 법 조각(<rPh> — 일본어 후리가나)은 뺀다 */
function richText(element: Element): string {
let text = ''
for (const t of elementsNamed(element, 't')) {
if (t.parentElement?.localName === 'rPh') continue
text += t.textContent ?? ''
}
return text
}
/** 상대 경로("worksheets/sheet1.xml") · 절대 경로("/xl/worksheets/sheet1.xml")를 zip 안 이름으로 */
function resolveTarget(target: string): string {
if (target.startsWith('/')) return target.slice(1)
const parts = ['xl', ...target.split('/')]
const resolved: string[] = []
for (const part of parts) {
if (part === '' || part === '.') continue
if (part === '..') resolved.pop()
else resolved.push(part)
}
return resolved.join('/')
}
interface WorkbookParts {
sheetPath: string | null
sharedStringsPath: string | null
}
async function workbookParts(
bytes: Uint8Array,
entries: Map<string, ZipEntry>,
budget: ReadBudget,
): Promise<WorkbookParts> {
const workbook = await readXml(bytes, entries, WORKBOOK_PATH, budget)
if (!workbook) throw new NotSpreadsheetError()
const rels = await readXml(bytes, entries, WORKBOOK_RELS_PATH, budget)
const targets = new Map<string, { type: string; target: string }>()
for (const relationship of rels ? elementsNamed(rels, 'Relationship') : []) {
const id = relationship.getAttribute('Id')
const target = relationship.getAttribute('Target')
if (id && target) targets.set(id, { type: relationship.getAttribute('Type') ?? '', target })
}
const firstSheet = elementsNamed(workbook, 'sheet')[0]
const relationId = firstSheet?.getAttributeNS(RELATIONSHIPS_NS, 'id') ?? firstSheet?.getAttribute('r:id') ?? null
const sheetTarget = relationId ? targets.get(relationId)?.target : undefined
const fallbackSheet = [...entries.keys()].filter((name) => SHEET_PATH.test(name)).sort()[0] ?? null
const sharedTarget = [...targets.values()].find((relation) => SHARED_STRINGS_TYPE.test(relation.type))?.target
return {
sheetPath: sheetTarget ? resolveTarget(sheetTarget) : fallbackSheet,
sharedStringsPath: sharedTarget ? resolveTarget(sharedTarget) : SHARED_STRINGS_PATH,
}
}
/** "AB" → 27 (0 부터). 세 자를 넘는 열(XFD 뒤)은 MAX_COLUMNS — 버릴 칸 */
function columnIndexOf(reference: string | null): number | null {
const letters = reference ? CELL_COLUMN.exec(reference)?.[1] : undefined
if (!letters) return null
if (letters.length > MAX_COLUMN_LETTERS) return MAX_COLUMNS
let index = 0
for (const letter of letters.toUpperCase()) {
index = index * ALPHABET_SIZE + (letter.charCodeAt(0) - CHAR_CODE_A)
}
return index - 1
}
function cellText(cell: Element, sharedStrings: readonly string[]): string {
const type = cell.getAttribute('t')
if (type === 'inlineStr') {
const inline = childrenNamed(cell, 'is')[0]
return inline ? richText(inline) : ''
}
const value = childrenNamed(cell, 'v')[0]?.textContent ?? ''
if (type === 's') return sharedStrings[Number(value)] ?? ''
if (type === 'b') return value === '1' ? 'TRUE' : value === '0' ? 'FALSE' : value
if (type === 'e') return ''
return value
}
/** 첫 시트의 줄 — 줄마다 칸 글(빈 칸은 ''). 빈 줄은 빠진다(엑셀 XML 에 없다) */
export async function readXlsxRows(bytes: Uint8Array): Promise<TableRow[]> {
const entries = readZipDirectory(bytes)
const budget: ReadBudget = { used: 0 }
const parts = await workbookParts(bytes, entries, budget)
if (!parts.sheetPath) return []
const shared = parts.sharedStringsPath ? await readXml(bytes, entries, parts.sharedStringsPath, budget) : null
const sharedStrings = shared ? elementsNamed(shared, 'si').map(richText) : []
const sheet = await readXml(bytes, entries, parts.sheetPath, budget)
if (!sheet) return []
const rows: TableRow[] = []
let previousRow = 0
let totalCells = 0
for (const row of elementsNamed(sheet, 'row')) {
const declared = Number(row.getAttribute('r'))
const position = Number.isInteger(declared) && declared > previousRow ? declared : previousRow + 1
previousRow = position
const cells: string[] = []
let nextColumn = 0
for (const cell of childrenNamed(row, 'c')) {
const column = columnIndexOf(cell.getAttribute('r')) ?? nextColumn
nextColumn = column + 1
if (column >= MAX_COLUMNS) continue
const text = cellText(cell, sharedStrings)
// 끝에 붙는 빈 칸은 늘어놓지 않는다 — 없는 칸도 빈 글로 읽으니 같은 값이다(서식만 건 빈 칸이 줄마다 있어도 부풀지 않게)
if (text === '' && column >= cells.length) continue
while (cells.length < column) cells.push('')
cells[column] = text
}
totalCells += cells.length
if (totalCells > CONTACT_IMPORT_XLSX_CELLS_MAX) throw new ZipFormatError('칸이 너무 많은 시트')
rows.push({ position, cells })
}
return rows
}
@@ -0,0 +1,167 @@
// zip 읽기 — 엑셀(.xlsx) 안의 XML 을 꺼낸다 (16단계 가져오기 · 새 의존성 없음)
// 끝의 가운데 목록(central directory)으로 파일 자리 · 크기 · 압축 방식을 읽고, deflate 는 브라우저의 DecompressionStream('deflate-raw')로 푼다.
// 압축 폭탄을 막는다 — 푼 크기가 상한을 넘는 순간 읽기를 멈추고 ZipLimitError (목록의 크기는 꾸밀 수 있어 믿지 않는다).
// 받지 않는 것: 암호를 건 항목 · ZIP64(4GB 넘는 파일 — 20MB 파일에는 없다) · deflate 밖의 압축 → ZipFormatError
/** 꼴이 틀렸거나 읽지 않는 zip */
export class ZipFormatError extends Error {
constructor(message: string) {
super(message)
this.name = 'ZipFormatError'
}
}
/** 풀었더니 상한보다 크다 */
export class ZipLimitError extends Error {
constructor() {
super('압축을 푼 크기가 상한을 넘었습니다')
this.name = 'ZipLimitError'
}
}
export interface ZipEntry {
name: string
/** 0 그대로 · 8 deflate */
method: number
compressedSize: number
/** 목록에 적힌 푼 크기 — 꾸밀 수 있어 상한 판단에는 쓰지 않는다 */
size: number
/** 이 항목의 머리(local file header) 자리 */
offset: number
}
const LOCAL_HEADER_SIGNATURE = 0x04034b50
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
const END_SIGNATURE = 0x06054b50
const END_RECORD_SIZE = 22
/** 끝 기록 뒤의 설명 글 최대 길이 */
const MAX_COMMENT_LENGTH = 0xffff
const LOCAL_HEADER_SIZE = 30
const CENTRAL_HEADER_SIZE = 46
const ZIP64_MARKER = 0xffffffff
const ENCRYPTED_FLAG = 0x1
const METHOD_STORED = 0
const METHOD_DEFLATE = 8
/** zip 의 처음 네 바이트 "PK\x03\x04" — 빈 zip 은 "PK\x05\x06" */
export function isZip(bytes: Uint8Array): boolean {
return (
bytes.length >= 4 &&
bytes[0] === 0x50 &&
bytes[1] === 0x4b &&
((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06))
)
}
function viewOf(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
}
function findEndRecord(view: DataView): number {
const last = view.byteLength - END_RECORD_SIZE
const first = Math.max(0, last - MAX_COMMENT_LENGTH)
for (let offset = last; offset >= first; offset -= 1) {
if (view.getUint32(offset, true) === END_SIGNATURE) return offset
}
throw new ZipFormatError('zip 끝 기록이 없습니다')
}
/** 가운데 목록 — 이름 → 항목 */
export function readZipDirectory(bytes: Uint8Array): Map<string, ZipEntry> {
if (bytes.length < END_RECORD_SIZE) throw new ZipFormatError('zip 이 너무 짧습니다')
const view = viewOf(bytes)
const end = findEndRecord(view)
const count = view.getUint16(end + 10, true)
const directorySize = view.getUint32(end + 12, true)
const directoryOffset = view.getUint32(end + 16, true)
if (directorySize === ZIP64_MARKER || directoryOffset === ZIP64_MARKER) throw new ZipFormatError('ZIP64')
if (directoryOffset + directorySize > bytes.length) throw new ZipFormatError('가운데 목록이 파일 밖입니다')
const decoder = new TextDecoder('utf-8')
const entries = new Map<string, ZipEntry>()
let offset = directoryOffset
for (let index = 0; index < count; index += 1) {
if (offset + CENTRAL_HEADER_SIZE > bytes.length || view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE) {
throw new ZipFormatError('가운데 목록 항목이 틀렸습니다')
}
const flags = view.getUint16(offset + 8, true)
const method = view.getUint16(offset + 10, true)
const compressedSize = view.getUint32(offset + 20, true)
const size = view.getUint32(offset + 24, true)
const nameLength = view.getUint16(offset + 28, true)
const extraLength = view.getUint16(offset + 30, true)
const commentLength = view.getUint16(offset + 32, true)
const localOffset = view.getUint32(offset + 42, true)
const nameStart = offset + CENTRAL_HEADER_SIZE
const name = decoder.decode(bytes.subarray(nameStart, nameStart + nameLength))
if ((flags & ENCRYPTED_FLAG) !== 0) throw new ZipFormatError('암호를 건 항목')
if (compressedSize === ZIP64_MARKER || size === ZIP64_MARKER || localOffset === ZIP64_MARKER) {
throw new ZipFormatError('ZIP64')
}
entries.set(name, { name, method, compressedSize, size, offset: localOffset })
offset = nameStart + nameLength + extraLength + commentLength
}
return entries
}
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
const result = new Uint8Array(total)
let at = 0
for (const chunk of chunks) {
result.set(chunk, at)
at += chunk.byteLength
}
return result
}
/** deflate 를 푼다 — 상한을 넘는 순간 멈춘다 */
async function inflateRaw(data: Uint8Array, maxBytes: number): Promise<Uint8Array> {
const stream = new DecompressionStream('deflate-raw')
const writer = stream.writable.getWriter()
// 쓰기와 읽기를 함께 한다 — 쓰기만 기다리면 읽지 않은 출력이 쌓여 멈춘다. 쓰기의 실패(읽기를 그만둠 · 꼴이 틀림)는 읽기 쪽이 알린다.
// 넘기는 바이트는 따로 떼어 낸 사본이다(slice) — 스트림이 파일 전체의 버퍼를 붙잡지 않게
const writing = writer
.write(data.slice())
.then(() => writer.close())
.catch(() => undefined)
const reader = stream.readable.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => undefined)
throw new ZipLimitError()
}
chunks.push(value)
}
} catch (caught) {
if (caught instanceof ZipLimitError) throw caught
throw new ZipFormatError('압축을 풀지 못했습니다')
}
await writing
return concatChunks(chunks, total)
}
/** 항목 하나를 푼다 — maxBytes 를 넘으면 ZipLimitError */
export async function readZipEntry(bytes: Uint8Array, entry: ZipEntry, maxBytes: number): Promise<Uint8Array> {
const view = viewOf(bytes)
if (entry.offset + LOCAL_HEADER_SIZE > bytes.length || view.getUint32(entry.offset, true) !== LOCAL_HEADER_SIGNATURE) {
throw new ZipFormatError('항목 머리가 틀렸습니다')
}
const nameLength = view.getUint16(entry.offset + 26, true)
const extraLength = view.getUint16(entry.offset + 28, true)
const start = entry.offset + LOCAL_HEADER_SIZE + nameLength + extraLength
const end = start + entry.compressedSize
if (end > bytes.length) throw new ZipFormatError('항목이 파일 밖입니다')
const data = bytes.subarray(start, end)
if (entry.method === METHOD_STORED) {
if (data.byteLength > maxBytes) throw new ZipLimitError()
return data
}
if (entry.method === METHOD_DEFLATE) return inflateRaw(data, maxBytes)
throw new ZipFormatError('읽지 않는 압축 방식')
}
+3 -3
View File
@@ -32,15 +32,15 @@ export function buildForwardSubject(subject: string): string {
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
}
/** 전달 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
/** 전달 초안 — 제목은 255자에 맞춘다. 서명 자리(signatureBlock — mail-signature.ts 의 signatureBlockHtml)가 있으면 인용 위에 넣는다 */
export function buildForwardDraft(
message: MessageDetail,
timeZone?: string,
signatureHtml: string | null = null,
signatureBlock: string | null = null,
): ForwardDraft {
return {
subject: fitDraftSubject(buildForwardSubject(message.subject)),
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message), signatureBlock),
attachments: [...message.attachments],
}
}
+6 -5
View File
@@ -136,17 +136,18 @@ export function forwardQuoteRows(message: HeaderSource, timeZone?: string): Quot
}
/**
* 내가 쓸 빈 문단 + (서명) + "원본 메일" 머리 + 원문을 담은 인용.
* 서명이 있으면 인용 위에 둔다 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명").
* signatureHtml 은 서버가 정화해 준 서명 HTML 이다 (편집기가 자기 스키마로 다시 읽는다).
* 내가 쓸 빈 문단 + (서명 자리) + "원본 메일" 머리 + 원문을 담은 인용.
* 서명이 있으면 인용 위에 둔다 — 쓸 줄 · 빈 줄 하나 · 서명 자리 · 인용 (디자인 README "2단계 › 서명" · "16단계").
* signatureBlock 은 서명 자리로 감싼 서명이다 (mail-signature.ts 의 signatureBlockHtml — 안은 서버가 정화해 준 서명 HTML,
* 편집기가 자기 스키마로 다시 읽어 [서명 ▾] 가 바꿀 서명 자리로 둔다).
*/
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string, signatureHtml: string | null = null): string {
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string, signatureBlock: 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}` : '') +
(signatureBlock ? `<p></p>${signatureBlock}` : '') +
'<blockquote data-mail-quote="">' +
`<p data-quote-header=""><strong>${escapeHtml(MAIL_QUOTE_HEADING)}</strong></p>` +
`<table data-quote-header=""><tbody>${header}</tbody></table>` +
+3 -3
View File
@@ -83,20 +83,20 @@ export function buildReplyRecipients(
return { to, cc }
}
/** 답장 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
/** 답장 초안 — 제목은 255자에 맞춘다. 서명 자리(signatureBlock — mail-signature.ts 의 signatureBlockHtml)가 있으면 인용 위에 넣는다 */
export function buildReplyDraft(
message: MessageDetail,
myEmail: string,
mode: ReplyMode,
timeZone?: string,
signatureHtml: string | null = null,
signatureBlock: 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), signatureHtml),
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message), signatureBlock),
inReplyTo: message.id,
}
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { MailSignature } from '@/shared/types/mail'
import type { MailSignature } from '@/shared/types/mail-signature'
import {
imageSizeOf,
imageWidthFor,
@@ -8,35 +8,91 @@ import {
newMailHtmlWithSignature,
originalImageWidth,
precheckSignatureImage,
signatureHtmlFor,
sameSignatureDefaults,
signatureBlockHtml,
signatureDeleteMessage,
signatureHtmlOf,
signatureImageUploadName,
signatureNameKey,
signatureNameProblem,
signatureUsesOf,
signatureUsesText,
withoutSignatureDefault,
} from './mail-signature'
const BASE: MailSignature = {
id: 's1',
name: '사내',
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>')
describe('signatureHtmlOf — 쓰기 화면에 넣을 서명', () => {
it('서식 서명을 먼저, 없으면 텍스트 서명을 문단으로, 둘 다 비었거나 넣지 않음(null)이면 null', () => {
expect(signatureHtmlOf(BASE)).toBe('<p>김아라</p>')
expect(signatureHtmlOf({ ...BASE, bodyHtml: null, body: '김아라\n인프라팀\n' })).toBe('<p>김아라</p><p>인프라팀</p>')
expect(signatureHtmlOf({ ...BASE, bodyHtml: ' ', body: ' ' })).toBeNull()
expect(signatureHtmlOf(null)).toBeNull()
})
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('서명 자리 — 편집기가 알아보는 표시를 단 div, 새 메일은 쓸 줄 · 빈 줄 하나 뒤에', () => {
expect(signatureBlockHtml('<p>김아라</p>')).toBe('<div data-mail-signature=""><p>김아라</p></div>')
expect(newMailHtmlWithSignature('<p>김아라</p>')).toBe('<p></p><p></p><div data-mail-signature=""><p>김아라</p></div>')
})
})
describe('서명 이름 — 앞뒤 공백을 뺀 1~30자, 같은 이름은 영문 대소문자를 가리지 않는다', () => {
it('견줄 값은 앞뒤 공백을 빼고 영문만 작게 — 한글 · 다른 글자는 그대로', () => {
expect(signatureNameKey(' Sales Team ')).toBe('sales team')
expect(signatureNameKey('대외')).toBe('대외')
expect(signatureNameKey('ÄRA')).toBe('Ära')
})
it('새 메일은 쓸 줄 · 빈 줄 하나 뒤에', () => {
expect(newMailHtmlWithSignature('<p>김아라</p>')).toBe('<p></p><p></p><p>김아라</p>')
it.each([
['', 'empty'],
[' ', 'empty'],
['대외', 'duplicate'],
[' 대외 ', 'duplicate'],
['ENGLISH', 'duplicate'],
['영문', null],
] as const)('%j → %s', (name, expected) => {
expect(signatureNameProblem(name, ['대외', 'English'])).toBe(expected)
})
})
describe('기본 둘 — 메일에 넣을 서명', () => {
const defaults = { newMessageId: 'out', replyId: 'in' }
it('그 서명을 기본으로 넣는 메일과 목록 줄의 회색 글', () => {
expect(signatureUsesOf('out', defaults)).toBe('new')
expect(signatureUsesOf('in', defaults)).toBe('reply')
expect(signatureUsesOf('en', defaults)).toBeNull()
expect(signatureUsesOf('in', { newMessageId: 'in', replyId: 'in' })).toBe('both')
expect(signatureUsesText('both')).toBe('새 메일, 답장과 전달')
expect(signatureUsesText('reply')).toBe('답장과 전달')
expect(signatureUsesText(null)).toBe('')
})
it('지운 서명을 기본으로 넣던 곳은 넣지 않음 — 다른 서명이 저절로 기본이 되지 않는다', () => {
expect(withoutSignatureDefault(defaults, 'out')).toEqual({ newMessageId: null, replyId: 'in' })
expect(withoutSignatureDefault({ newMessageId: 'in', replyId: 'in' }, 'in')).toEqual({ newMessageId: null, replyId: null })
expect(sameSignatureDefaults(defaults, { ...defaults })).toBe(true)
expect(sameSignatureDefaults(defaults, { ...defaults, replyId: null })).toBe(false)
})
it('지우기 확인 창의 글 — 기본으로 넣던 곳마다 (디자인 원본 Signature16 4)', () => {
expect(signatureDeleteMessage('new')).toBe(
'새 메일에 넣는 서명이라 지우면 앞으로 새 메일에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
)
expect(signatureDeleteMessage('reply')).toBe(
'답장과 전달에 넣는 서명이라 지우면 앞으로 답장과 전달에는 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
)
expect(signatureDeleteMessage('both')).toBe(
'새 메일에도, 답장과 전달에도 넣는 서명이라 지우면 앞으로 어느 메일에도 서명을 넣지 않습니다. 지운 서명은 되돌릴 수 없습니다.',
)
expect(signatureDeleteMessage(null)).toBe('지운 서명은 되돌릴 수 없습니다.')
})
})
+68 -11
View File
@@ -1,40 +1,97 @@
// 메일 서명 — 순수 함수 (계약 2절 "서명" · 디자인 README "2단계 › 서명")
// 메일 서명 — 순수 함수 (계약 2절 "서명" · 디자인 README "2단계 › 서명" · "16단계 — 여러 서명")
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import {
MAIL_SIGNATURE_MARK_ATTRIBUTE,
SIGNATURE_DELETE_TEXT,
SIGNATURE_IMAGE_MAX_BYTES,
SIGNATURE_IMAGE_MAX_COUNT,
SIGNATURE_IMAGE_SIZES,
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
SIGNATURE_USES_TEXT,
type SignatureImageSize,
} from '@/shared/constants/mail-signature'
import type { MailSignature } from '@/shared/types/mail'
import type { MailSignature, MailSignatureDefaults } from '@/shared/types/mail-signature'
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.
* 쓰기 화면에 넣을 서명 HTML — 서식 서명(bodyHtml)이 있으면 그것, 없으면 텍스트 서명(body)을 문단으로.
* 서명이 없거나(넣지 않음) 둘 다 비었으면 null.
*/
export function signatureHtmlFor(signature: MailSignature | null, use: SignatureUse): string | null {
export function signatureHtmlOf(signature: MailSignature | null): 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)
}
/** 새 메일 본문 — 쓸 줄 · 빈 줄 하나 · 서명 (서명은 본문 끝, 빈 줄 하나 뒤) */
/**
* 서명 자리 — 편집기가 서명 자리(서명 경계 노드)로 읽는 표시를 단 div 로 감싼다 (16단계).
* 보낼 때는 표시 없는 <div> 로 나간다 — 앱과 같은 경계(빈 문단 뒤 속성 없는 div — 계약 2절 "앱이 만드는 모양").
* 서명 앞의 빈 줄은 부르는 쪽이 둔다
*/
export function signatureBlockHtml(signatureHtml: string): string {
return `<div ${MAIL_SIGNATURE_MARK_ATTRIBUTE}="">${signatureHtml}</div>`
}
/** 새 메일 본문 — 쓸 줄 · 빈 줄 하나 · 서명 자리 (서명은 본문 끝, 빈 줄 하나 뒤) */
export function newMailHtmlWithSignature(signatureHtml: string): string {
return `<p></p><p></p>${signatureHtml}`
return `<p></p><p></p>${signatureBlockHtml(signatureHtml)}`
}
/** 기본 둘이 같은가 */
export function sameSignatureDefaults(a: MailSignatureDefaults, b: MailSignatureDefaults): boolean {
return a.newMessageId === b.newMessageId && a.replyId === b.replyId
}
/** 지운 서명을 기본으로 넣던 곳은 넣지 않음으로 — 다른 서명이 저절로 기본이 되지 않는다 */
export function withoutSignatureDefault(defaults: MailSignatureDefaults, id: string): MailSignatureDefaults {
return {
newMessageId: defaults.newMessageId === id ? null : defaults.newMessageId,
replyId: defaults.replyId === id ? null : defaults.replyId,
}
}
/** 이 서명을 기본으로 넣는 메일 — 새 메일 · 답장과 전달 · 둘 다 · 없음 */
export type SignatureUses = 'new' | 'reply' | 'both' | null
export function signatureUsesOf(id: string, defaults: MailSignatureDefaults): SignatureUses {
const forNew = defaults.newMessageId === id
const forReply = defaults.replyId === id
if (forNew && forReply) return 'both'
if (forNew) return 'new'
return forReply ? 'reply' : null
}
/** 목록 줄의 이름 아래 회색 글 — 그 서명을 기본으로 넣는 메일 (없으면 빈 글) */
export function signatureUsesText(uses: SignatureUses): string {
return uses ? SIGNATURE_USES_TEXT[uses] : ''
}
/** 지우기 확인 창의 글 — 기본으로 넣던 곳마다 다르다 */
export function signatureDeleteMessage(uses: SignatureUses): string {
if (uses === 'both') return SIGNATURE_DELETE_TEXT.messageBoth
if (uses === 'new') return SIGNATURE_DELETE_TEXT.messageNew
if (uses === 'reply') return SIGNATURE_DELETE_TEXT.messageReply
return SIGNATURE_DELETE_TEXT.message
}
/** 이름을 견줄 값 — 앞뒤 공백을 빼고 영문(ASCII) 대소문자를 가리지 않는다 (서버와 같다 — 한글 · 다른 글자는 그대로) */
export function signatureNameKey(name: string): string {
return name.trim().replace(/[A-Z]/g, (letter) => letter.toLowerCase())
}
/** 이름 검사 — 비었으면 empty, 다른 서명과 같으면 duplicate, 괜찮으면 null (30자는 칸이 막는다 — maxlength) */
export function signatureNameProblem(name: string, otherNames: readonly string[]): 'empty' | 'duplicate' | null {
const key = signatureNameKey(name)
if (key === '') return 'empty'
return otherNames.some((other) => signatureNameKey(other) === key) ? 'duplicate' : null
}
/** 그림 파일 사전 검사에 쓰는 모양 — File 과 같은 이름 · 형식 · 크기 */
+213 -26
View File
@@ -1,24 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { MailVacation } from '@/shared/types/mail'
import type { MailVacation, MailVacationExternal } from '@/shared/types/mail'
import {
emailDomainOf,
endOfDayIso,
firstVacationProblem,
fromDayOf,
hasVacationFormatting,
sameVacationForm,
startOfDayIso,
toDayOf,
vacationExternalOf,
vacationFormOf,
vacationNoticeText,
vacationPayloadOf,
vacationPreviewNote,
vacationPreviewSendsNothing,
vacationPreviewUsesExternal,
vacationProblems,
vacationSavedText,
type VacationContent,
type VacationDocMark,
type VacationDocNode,
type VacationForm,
} from './mail-vacation'
// 부재중 자동 응답 — 날짜 ↔ 시각 (계약 2절: 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로)
// 시간대마다 결과가 달라지므로 기대값도 같은 브라우저 시간대(로컬 Date)로 만든다
// 16단계: 내용은 편집기에서 꺼낸 것(VacationContent — 보이는 글 · 서식 본문)을 함께 받는다 · 회사 밖에서 온 메일에 보낼 응답
const SAME: MailVacationExternal = { mode: 'same', subject: null, textBody: null, htmlBody: null }
const OFF: MailVacation = { isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' }
const BODY: VacationContent = { text: '복귀 후 답장 드리겠습니다.', html: '<div><p>복귀 후 답장 드리겠습니다.</p></div>' }
const EMPTY: VacationContent = { text: '', html: null }
function form(partial: Partial<VacationForm> = {}): VacationForm {
return {
@@ -27,11 +41,17 @@ function form(partial: Partial<VacationForm> = {}): VacationForm {
fromDay: '',
toDay: '',
subject: '부재중 안내',
textBody: '복귀 후 답장 드리겠습니다.',
externalMode: 'same',
externalSubject: '',
...partial,
}
}
/** 보이는 글만 바꾼 내용 */
function text(value: string, html: string | null = null): VacationContent {
return { text: value, html }
}
describe('날짜 ↔ 시각', () => {
it('시작일은 그날 0시, 끝나는 날은 다음 날 0시 — 브라우저 시간대 기준을 UTC ISO 로', () => {
expect(startOfDayIso('2026-10-30')).toBe(new Date(2026, 9, 30).toISOString())
@@ -55,33 +75,36 @@ describe('날짜 ↔ 시각', () => {
})
describe('칸 ↔ 서버 값', () => {
it('설정한 적 없는 계정(꺼짐 · 빈 값)은 끌 때까지 · 빈 칸', () => {
it('설정한 적 없는 계정(꺼짐 · 빈 값)은 끌 때까지 · 빈 칸 · 사내와 같은 응답', () => {
expect(vacationFormOf(OFF)).toEqual({
isEnabled: false,
period: 'untilOff',
fromDay: '',
toDay: '',
subject: '',
textBody: '',
externalMode: 'same',
externalSubject: '',
})
})
it('날짜가 하나라도 있으면 기간 정하기 — 저장하면 같은 시각이 돌아간다', () => {
it('날짜가 하나라도 있으면 기간 정하기 — 저장하면 같은 시각이 돌아간다 (일곱 칸)', () => {
const vacation: MailVacation = {
isEnabled: true,
fromDate: startOfDayIso('2026-10-30'),
toDate: endOfDayIso('2026-11-03'),
subject: '부재중 안내',
textBody: '휴가입니다.',
htmlBody: '<div><p>휴가입니다.</p></div>',
external: SAME,
}
const filled = vacationFormOf(vacation)
expect(filled).toMatchObject({ period: 'range', fromDay: '2026-10-30', toDay: '2026-11-03' })
expect(vacationPayloadOf(filled)).toEqual(vacation)
expect(vacationPayloadOf(filled, text('휴가입니다.', '<div><p>휴가입니다.</p></div>'), EMPTY)).toEqual(vacation)
})
it('끌 때까지 계속이면 숨은 날짜 칸이 남아 있어도 두 날짜 모두 null', () => {
expect(vacationPayloadOf(form({ fromDay: '2026-10-30', toDay: '2026-11-03' }))).toMatchObject({
expect(vacationPayloadOf(form({ fromDay: '2026-10-30', toDay: '2026-11-03' }), BODY, EMPTY)).toMatchObject({
fromDate: null,
toDate: null,
})
@@ -89,45 +112,209 @@ describe('칸 ↔ 서버 값', () => {
it('꺼 둔 채 저장하면 맞지 않는 기간은 비운다 — 끄기는 늘 저장된다 (빈 제목 · 내용도)', () => {
const payload = vacationPayloadOf(
form({ isEnabled: false, period: 'range', fromDay: '2026-11-05', toDay: '2026-11-03', subject: '', textBody: '' }),
form({ isEnabled: false, period: 'range', fromDay: '2026-11-05', toDay: '2026-11-03', subject: '' }),
EMPTY,
EMPTY,
)
expect(payload).toEqual({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '' })
expect(payload).toEqual({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
external: SAME,
})
})
it('저장하지 않은 것이 있는지 — 끌 때까지 계속이면 숨은 날짜는 보지 않는다', () => {
it('회사 밖 응답 — 고르기와 상관없이 따로 쓴 제목 · 내용을 함께 보낸다 (다른 고르기로 바꿔도 남긴다), 쓴 것이 없으면 null', () => {
const outside = text('부재중 안내드립니다.', '<div><p>부재중 안내드립니다.</p></div>')
const external: MailVacationExternal = {
mode: 'none',
subject: '부재중 안내',
textBody: '부재중 안내드립니다.',
htmlBody: '<div><p>부재중 안내드립니다.</p></div>',
}
expect(vacationPayloadOf(form({ externalMode: 'none', externalSubject: '부재중 안내' }), BODY, outside).external).toEqual(external)
// 다시 읽으면 칸도 그대로 — 따로 쓰기를 다시 고르면 그 글이 나온다
expect(vacationFormOf({ ...OFF, external })).toMatchObject({ externalMode: 'none', externalSubject: '부재중 안내' })
expect(vacationPayloadOf(form({ externalMode: 'separate' }), BODY, EMPTY).external).toEqual({ ...SAME, mode: 'separate' })
})
it('서버의 회사 밖 응답 읽기 — 없거나(16단계 전 서버) 모양이 틀리면 사내와 같은 응답 · 글 없음', () => {
expect(vacationExternalOf(undefined)).toEqual(SAME)
expect(vacationExternalOf(null)).toEqual(SAME)
expect(vacationExternalOf('separate')).toEqual(SAME)
expect(vacationExternalOf({ mode: 'later', subject: 1, textBody: '글', htmlBody: null })).toEqual({
...SAME,
textBody: '글',
})
expect(vacationExternalOf({ mode: 'separate', subject: '제목', textBody: '글', htmlBody: '<p>글</p>' })).toEqual({
mode: 'separate',
subject: '제목',
textBody: '글',
htmlBody: '<p>글</p>',
})
// 새 객체 — 받은 값의 모르는 칸은 싣지 않는다
expect(Object.keys(vacationExternalOf({ ...SAME, extra: true }))).toEqual(['mode', 'subject', 'textBody', 'htmlBody'])
})
it('저장하지 않은 것이 있는지 — 끌 때까지 계속이면 숨은 날짜는 보지 않는다 · 회사 밖 고르기와 제목도 본다', () => {
expect(sameVacationForm(form(), form({ fromDay: '2026-10-30' }))).toBe(true)
expect(sameVacationForm(form({ period: 'range' }), form({ period: 'range', fromDay: '2026-10-30' }))).toBe(false)
expect(sameVacationForm(form(), form({ subject: '다른 제목' }))).toBe(false)
expect(sameVacationForm(form(), form({ externalMode: 'none' }))).toBe(false)
expect(sameVacationForm(form(), form({ externalSubject: '회사 밖 제목' }))).toBe(false)
})
})
describe('저장하기 전 검사 — 켜 둘 때만', () => {
it('꺼 두면 아무것도 막지 않는다', () => {
expect(vacationProblems(form({ isEnabled: false, subject: '', textBody: '', period: 'range' }))).toEqual([])
expect(vacationProblems(form({ isEnabled: false, subject: '', period: 'range', externalMode: 'separate' }), EMPTY, EMPTY)).toEqual({})
})
it('켜 두면 제목 · 내용이 필요하다 (공백뿐이면 빈 것)', () => {
expect(vacationProblems(form({ subject: ' ', textBody: '\n' }))).toEqual(['subject', 'textBody'])
expect(vacationProblems(form())).toEqual([])
expect(vacationProblems(form({ subject: ' ' }), text(' '), EMPTY)).toEqual({ subject: 'invalid', textBody: 'invalid' })
expect(vacationProblems(form(), BODY, EMPTY)).toEqual({})
})
it('제목은 150자 · 내용은 600자까지 — 한글로 채워도 엔진의 바이트 한도 안이다 (계약 2절 "길이 한도의 근거")', () => {
expect(vacationProblems(form({ subject: '가'.repeat(150), textBody: '가'.repeat(600) }))).toEqual([])
expect(vacationProblems(form({ subject: '가'.repeat(151), textBody: '가'.repeat(601) }))).toEqual([
'subject',
'textBody',
])
// 길이는 JS 문자열 길이 — 이모지 하나는 2자다
expect(vacationProblems(form({ subject: '🌴'.repeat(75) }))).toEqual([])
expect(vacationProblems(form({ subject: `${'🌴'.repeat(75)}가` }))).toEqual(['subject'])
it('제목은 150자 · 내용은 보이는 글 600자까지 — 넘으면 내용은 전용 글(tooLong), 길이는 JS 문자열 길이 (이모지 하나는 2자)', () => {
expect(vacationProblems(form({ subject: '가'.repeat(150) }), text('가'.repeat(600)), EMPTY)).toEqual({})
expect(vacationProblems(form({ subject: '가'.repeat(151) }), text('가'.repeat(601)), EMPTY)).toEqual({
subject: 'invalid',
textBody: 'tooLong',
})
expect(vacationProblems(form({ subject: '🌴'.repeat(75) }), BODY, EMPTY)).toEqual({})
expect(vacationProblems(form({ subject: `${'🌴'.repeat(75)}가` }), BODY, EMPTY)).toEqual({ subject: 'invalid' })
expect(vacationProblems(form(), text('🌴'.repeat(301)), EMPTY)).toEqual({ textBody: 'tooLong' })
})
it('글은 600자 안인데 서식 본문이 10,000자를 넘으면 사전 문구(invalid) — 서명과 같은 한도', () => {
expect(vacationProblems(form(), text('짧은 글', 'x'.repeat(10_000)), EMPTY)).toEqual({})
expect(vacationProblems(form(), text('짧은 글', 'x'.repeat(10_001)), EMPTY)).toEqual({ textBody: 'invalid' })
})
it('기간 정하기 — 날짜가 하나도 없거나 시작이 끝보다 늦으면 막는다 (같은 날은 하루)', () => {
expect(vacationProblems(form({ period: 'range' }))).toEqual(['period'])
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-04', toDay: '2026-11-03' }))).toEqual(['period'])
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-03', toDay: '2026-11-03' }))).toEqual([])
expect(vacationProblems(form({ period: 'range', toDay: '2026-11-03' }))).toEqual([])
expect(vacationProblems(form({ period: 'range' }), BODY, EMPTY)).toEqual({ period: 'invalid' })
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-04', toDay: '2026-11-03' }), BODY, EMPTY)).toEqual({
period: 'invalid',
})
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-03', toDay: '2026-11-03' }), BODY, EMPTY)).toEqual({})
expect(vacationProblems(form({ period: 'range', toDay: '2026-11-03' }), BODY, EMPTY)).toEqual({})
})
it('회사 밖 두 칸은 따로 쓰기일 때만 — 같은 한도, 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다', () => {
expect(vacationProblems(form({ externalMode: 'same' }), BODY, EMPTY)).toEqual({})
expect(vacationProblems(form({ externalMode: 'none' }), BODY, EMPTY)).toEqual({})
expect(vacationProblems(form({ externalMode: 'separate' }), BODY, EMPTY)).toEqual({
externalSubject: 'invalid',
externalTextBody: 'invalid',
})
expect(
vacationProblems(form({ externalMode: 'separate', externalSubject: '안내' }), BODY, text('가'.repeat(601))),
).toEqual({ externalTextBody: 'tooLong' })
expect(vacationProblems(form({ externalMode: 'separate', externalSubject: '안내' }), BODY, BODY)).toEqual({})
})
it('보는 차례 — 기간 · 제목 · 내용 · 회사 밖에 보낼 제목 · 회사 밖에 보낼 내용의 첫 칸', () => {
expect(firstVacationProblem({})).toBeNull()
expect(firstVacationProblem({ externalTextBody: 'tooLong', subject: 'invalid' })).toBe('subject')
expect(firstVacationProblem({ externalTextBody: 'invalid', externalSubject: 'invalid' })).toBe('externalSubject')
expect(firstVacationProblem({ textBody: 'tooLong', period: 'invalid' })).toBe('period')
})
})
describe('서식이 있는가 (16단계 리뷰 — 없으면 서식 본문을 보내지 않는다)', () => {
/** 편집기 문서 흉내 — 글자 노드마다 표시(marks) */
function doc(...marks: VacationDocMark[][]): VacationDocNode {
const children: VacationDocNode[] = marks.map((list) => ({ marks: list, descendants: () => undefined }))
return {
marks: [],
descendants(callback) {
for (const child of children) {
if (callback(child) === false) return
}
},
}
}
const mark = (name: string, attrs: Record<string, unknown> = {}): VacationDocMark => ({ type: { name }, attrs })
it('글만이면 없음 — 굵게 · 기울임 · 밑줄 · 링크 · 글자 색이 하나라도 있으면 있음', () => {
expect(hasVacationFormatting(doc([], []))).toBe(false)
expect(hasVacationFormatting(doc([], [mark('bold')]))).toBe(true)
expect(hasVacationFormatting(doc([mark('italic')]))).toBe(true)
expect(hasVacationFormatting(doc([mark('underline')]))).toBe(true)
expect(hasVacationFormatting(doc([mark('link', { href: 'https://arasystem.it.kr' })]))).toBe(true)
expect(hasVacationFormatting(doc([mark('textStyle', { color: '#0E7C86' })]))).toBe(true)
})
it('색을 지운 뒤 남은 빈 글자 스타일은 서식이 아니다', () => {
expect(hasVacationFormatting(doc([mark('textStyle', { color: null })]))).toBe(false)
expect(hasVacationFormatting(doc([mark('textStyle', { color: '' })]))).toBe(false)
expect(hasVacationFormatting(doc([mark('textStyle')]))).toBe(false)
})
it('찾으면 더 훑지 않는다', () => {
const seen: number[] = []
const children = [[], [mark('bold')], []].map((list, index) => ({
marks: list,
descendants: () => undefined,
index,
}))
const root: VacationDocNode = {
marks: [],
descendants(callback) {
for (const child of children) {
seen.push(child.index)
if (callback(child) === false) return
}
},
}
expect(hasVacationFormatting(root)).toBe(true)
expect(seen).toEqual([0, 1])
})
})
describe('미리 보기 (16단계 — [사내 | 회사 밖])', () => {
it('아래 글 — 사내와 같은 응답 · 보내지 않기 · 따로 쓰기, 꺼져 있으면 두 쪽 모두 꺼짐 글', () => {
expect(vacationPreviewNote(true, 'same', 'inside')).toBe(
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
expect(vacationPreviewNote(true, 'same', 'outside')).toBe(
'회사 밖에서 온 메일에도 같은 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
expect(vacationPreviewNote(true, 'separate', 'inside')).toBe(
'사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
expect(vacationPreviewNote(true, 'none', 'inside')).toBe('사내에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.')
expect(vacationPreviewNote(true, 'separate', 'outside')).toBe(
'회사 밖에서 온 메일에 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
// 보내지 않기의 회사 밖 쪽은 회색 상자만 — 아래 글이 없다
expect(vacationPreviewNote(true, 'none', 'outside')).toBeNull()
for (const mode of ['same', 'none', 'separate'] as const) {
for (const side of ['inside', 'outside'] as const) {
expect(vacationPreviewNote(false, mode, side)).toBe('꺼져 있어 자동으로 답하지 않습니다.')
}
}
})
it('답장 대신 회색 상자는 보내지 않기의 회사 밖 쪽, 따로 쓴 글은 따로 쓰기의 회사 밖 쪽만', () => {
expect(vacationPreviewSendsNothing('none', 'outside')).toBe(true)
expect(vacationPreviewSendsNothing('none', 'inside')).toBe(false)
expect(vacationPreviewSendsNothing('separate', 'outside')).toBe(false)
expect(vacationPreviewUsesExternal('separate', 'outside')).toBe(true)
expect(vacationPreviewUsesExternal('separate', 'inside')).toBe(false)
expect(vacationPreviewUsesExternal('same', 'outside')).toBe(false)
})
it('회사 밖 설명의 도메인은 내 주소의 것 (마지막 @ 뒤)', () => {
expect(emailDomainOf('ara.kim@arasystem.it.kr')).toBe('arasystem.it.kr')
expect(emailDomainOf('"a@b"@arasystem.test')).toBe('arasystem.test')
expect(emailDomainOf('')).toBe('')
})
})
+199 -25
View File
@@ -1,26 +1,122 @@
// 부재중 자동 응답 — 화면의 날짜 ↔ 서버의 시각, 폼 검사, 안내 문구 (계약 2절 "부재중 자동 응답" — 순수 함수만 둔다)
// 화면은 날짜만 고른다. 서버에는 브라우저 시간대의 "시작일 0시"와 "끝나는 날 다음 날 0시"를 UTC(ISO 8601)로 보낸다 —
// 끝 시각은 그 시각 직전까지 답한다는 뜻이라, 읽을 때는 1ms 앞의 날짜가 끝나는 날이다.
// 16단계: 내용은 서식 편집기(서명 칸과 같은 것)에 있다 — 칸(VacationForm)에는 없고, 저장 · 검사 때 편집기에서 꺼낸 내용(VacationContent)을 함께 받는다.
// 회사 밖에서 온 메일에 보낼 응답(사내와 같은 응답 · 보내지 않기 · 따로 쓰기)을 고르고, 다른 고르기로 바꿔도 따로 쓴 글은 남겨 함께 저장한다.
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation } from '@/shared/types/mail'
import {
VACATION_BODY_MAX_LENGTH,
VACATION_EXTERNAL_MODES,
VACATION_HTML_MAX_LENGTH,
VACATION_SUBJECT_MAX_LENGTH,
VACATION_TEXT,
} from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationExternal, MailVacationExternalMode } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
/** 화면의 기간 고르기 — 끌 때까지 계속 · 기간 정하기 */
export type VacationPeriod = 'untilOff' | 'range'
/** 화면의 칸 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
/**
* 화면의 칸 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 ''). 내용(사내 · 회사 밖)은 편집기 안에 있다(16단계).
* 회사 밖 제목은 다른 고르기로 바꿔도 남긴다 — 다시 따로 쓰기를 고르면 그대로 나온다
*/
export interface VacationForm {
isEnabled: boolean
period: VacationPeriod
fromDay: string
toDay: string
subject: string
textBody: string
externalMode: MailVacationExternalMode
externalSubject: string
}
/** 저장하기 전에 막는 문제 — 칸마다 하나 (켜 둘 때만 본다) */
export type VacationProblem = 'period' | 'subject' | 'textBody'
/** 편집기에서 꺼낸 내용 — 보이는 글(서버의 textBody · 글자 수) · 서식 본문(비었으면 null) */
export interface VacationContent {
text: string
html: string | null
}
/** 저장하기 전에 막는 칸 (켜 둘 때만 본다) — 회사 밖 두 칸은 따로 쓰기일 때만 */
export type VacationProblemField = 'period' | 'subject' | 'textBody' | 'externalSubject' | 'externalTextBody'
/** 걸린 까닭 — invalid: 비었거나 맞지 않음 · 서식 본문이 너무 김(사전 문구 VAL_001) / tooLong: 내용이 600자를 넘음(전용 글 · 빨간 글자 수) */
export type VacationProblemKind = 'invalid' | 'tooLong'
export type VacationProblems = Partial<Record<VacationProblemField, VacationProblemKind>>
/** 칸을 보는 차례 — 첫 칸으로 초점을 옮긴다 (기간 → 제목 → 내용 → 회사 밖에 보낼 제목 → 회사 밖에 보낼 내용) */
export const VACATION_PROBLEM_ORDER: readonly VacationProblemField[] = [
'period',
'subject',
'textBody',
'externalSubject',
'externalTextBody',
]
/** 미리 보기의 쪽 — 사내에서 온 메일 · 회사 밖에서 온 메일에 가는 답장 */
export type VacationPreviewSide = 'inside' | 'outside'
/** 서식을 찾는 데 쓰는 편집기 문서의 모양 — ProseMirror 노드의 일부만 적는다 (편집기 청크를 불러오지 않게) */
export interface VacationDocMark {
readonly type: { readonly name: string }
readonly attrs: Readonly<Record<string, unknown>>
}
export interface VacationDocNode {
readonly marks: readonly VacationDocMark[]
descendants(callback: (node: VacationDocNode) => boolean | void): void
}
/** 글자 스타일(글자 색이 붙는 표시) — 값이 있을 때만 서식이다 (색을 지운 뒤 남은 빈 스타일은 서식이 아니다) */
const TEXT_STYLE_MARK = 'textStyle'
function isFormattingMark(mark: VacationDocMark): boolean {
if (mark.type.name !== TEXT_STYLE_MARK) return true
return Object.values(mark.attrs).some((value) => value !== null && value !== undefined && value !== '')
}
/**
* 편집 중인 내용에 서식(굵게 · 기울임 · 밑줄 · 글자 색 · 링크 — 글자에 붙은 표시)이 있는가.
* 없으면 서식 본문을 보내지 않는다(htmlBody null — 사내 · 회사 밖 모두). 서버는 서식 본문 · 회사 밖 응답을 쓴 직원만 새 원본 판으로 저장한다 —
* 글만 쓴 응답에도 서식 본문을 실으면 거의 모든 직원이 새 판이 되어 되돌리기 계획의 전제가 깨진다 (16단계 리뷰).
* 문단 · 줄바꿈은 글자 본문에도 그대로 남아 서식으로 보지 않는다
*/
export function hasVacationFormatting(doc: VacationDocNode): boolean {
let found = false
doc.descendants((node) => {
if (found) return false
if (node.marks.some(isFormattingMark)) found = true
return !found
})
return found
}
/** 회사 밖 응답의 처음 값 — 사내와 같은 응답 · 쓴 글 없음 (16단계 전 서버 · 설정한 적 없는 계정) */
const DEFAULT_EXTERNAL: MailVacationExternal = { mode: 'same', subject: null, textBody: null, htmlBody: null }
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function isExternalMode(value: unknown): value is MailVacationExternalMode {
return VACATION_EXTERNAL_MODES.includes(value as MailVacationExternalMode)
}
/**
* 서버가 준 회사 밖 응답 → 화면의 값. 없거나 모양이 틀리면(16단계 전 서버 · 응답을 바꾼 게이트웨이) 사내와 같은 응답 —
* 옛 화면이 저장한 것과 같게 읽는다. 모르는 mode 도 사내와 같은 응답이다
*/
export function vacationExternalOf(value: unknown): MailVacationExternal {
if (typeof value !== 'object' || value === null) return { ...DEFAULT_EXTERNAL }
const external = value as Record<string, unknown>
return {
mode: isExternalMode(external.mode) ? external.mode : DEFAULT_EXTERNAL.mode,
subject: stringOrNull(external.subject),
textBody: stringOrNull(external.textBody),
htmlBody: stringOrNull(external.htmlBody),
}
}
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
/** 끝 시각 바로 앞 — 끝나는 날을 구한다 */
@@ -65,17 +161,19 @@ export function toDayOf(toDate: string | null): string {
return instant ? formatDate(new Date(instant.getTime() - END_OFFSET_MS)) : ''
}
/** 서버 값 → 화면의 칸. 날짜가 하나라도 있으면 "기간 정하기" */
/** 서버 값 → 화면의 칸. 날짜가 하나라도 있으면 "기간 정하기". 내용은 편집기가 따로 연다(VacationForm 의 bodyHtmlOf) */
export function vacationFormOf(vacation: MailVacation): VacationForm {
const fromDay = fromDayOf(vacation.fromDate)
const toDay = toDayOf(vacation.toDate)
const external = vacationExternalOf(vacation.external)
return {
isEnabled: vacation.isEnabled,
period: fromDay || toDay ? 'range' : 'untilOff',
fromDay,
toDay,
subject: vacation.subject ?? '',
textBody: vacation.textBody ?? '',
externalMode: external.mode,
externalSubject: external.subject ?? '',
}
}
@@ -89,35 +187,78 @@ function rangeOf(form: Pick<VacationForm, 'fromDay' | 'toDay'>): { fromDate: str
return { fromDate, toDate }
}
/**
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도, 4단계에 저장한 긴 글도 저장된다 — 계약 2절).
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~150자 · 내용 1~600자 (공백뿐이면 빈 것)
*/
export function vacationProblems(form: VacationForm): VacationProblem[] {
if (!form.isEnabled) return []
const problems: VacationProblem[] = []
if (form.period === 'range' && !rangeOf(form)) problems.push('period')
if (form.subject.trim() === '' || form.subject.length > VACATION_SUBJECT_MAX_LENGTH) problems.push('subject')
if (form.textBody.trim() === '' || form.textBody.length > VACATION_BODY_MAX_LENGTH) problems.push('textBody')
return problems
/** 제목 — 1~150자 (공백뿐이면 빈 것) */
function subjectProblem(subject: string): VacationProblemKind | null {
return subject.trim() === '' || subject.length > VACATION_SUBJECT_MAX_LENGTH ? 'invalid' : null
}
/**
* 화면의 칸 → PUT /mail-vacation 본문 (전체 교체). 끌 때까지 계속이면 두 날짜 모두 null.
* 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비운다 — 끄기는 늘 저장되게
* 내용 — 보이는 글 1~600자(공백뿐이면 빈 것 — 넘으면 전용 글) · 서식 본문 10,000자(서명과 같은 한도 — 글은 600자 안인데 서식이 많을 때, 사전 문구).
* 길이는 JS 문자열 길이다 — 서버도 같은 값(textBody)을 같은 길이로 잰다
*/
export function vacationPayloadOf(form: VacationForm): MailVacation {
function bodyProblem(content: VacationContent): VacationProblemKind | null {
if (content.text.trim() === '') return 'invalid'
if (content.text.length > VACATION_BODY_MAX_LENGTH) return 'tooLong'
return (content.html?.length ?? 0) > VACATION_HTML_MAX_LENGTH ? 'invalid' : null
}
/**
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도, 4단계에 저장한 긴 글도 저장된다 — 계약 2절).
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~150자 · 내용(위 bodyProblem).
* 회사 밖 두 칸은 따로 쓰기를 골랐을 때만 — 사내와 같은 응답 · 보내지 않기면 비어 있어도 저장한다
*/
export function vacationProblems(
form: VacationForm,
inside: VacationContent,
outside: VacationContent,
): VacationProblems {
if (!form.isEnabled) return {}
const problems: VacationProblems = {}
if (form.period === 'range' && !rangeOf(form)) problems.period = 'invalid'
const subject = subjectProblem(form.subject)
if (subject) problems.subject = subject
const body = bodyProblem(inside)
if (body) problems.textBody = body
if (form.externalMode !== 'separate') return problems
const externalSubject = subjectProblem(form.externalSubject)
if (externalSubject) problems.externalSubject = externalSubject
const externalBody = bodyProblem(outside)
if (externalBody) problems.externalTextBody = externalBody
return problems
}
/** 걸린 칸 가운데 보는 차례로 첫 칸 — 없으면 null */
export function firstVacationProblem(problems: VacationProblems): VacationProblemField | null {
return VACATION_PROBLEM_ORDER.find((field) => problems[field] !== undefined) ?? null
}
/**
* 화면의 칸 + 편집기의 내용 → PUT /mail-vacation 본문 (전체 교체 — 일곱 칸). 끌 때까지 계속이면 두 날짜 모두 null.
* 꺼 둔 채 저장할 때 기간이 맞지 않으면(흐린 칸이라 고칠 수 없다) 날짜를 비운다 — 끄기는 늘 저장되게.
* 회사 밖 제목 · 내용은 고르기와 상관없이 함께 보낸다(다른 고르기로 바꿔도 남긴다 — 보드 결정). 쓴 것이 없으면 null
*/
export function vacationPayloadOf(form: VacationForm, inside: VacationContent, outside: VacationContent): MailVacation {
const range = form.period === 'range' ? rangeOf(form) : null
return {
isEnabled: form.isEnabled,
fromDate: range?.fromDate ?? null,
toDate: range?.toDate ?? null,
subject: form.subject,
textBody: form.textBody,
textBody: inside.text,
htmlBody: inside.html,
external: {
mode: form.externalMode,
subject: form.externalSubject === '' ? null : form.externalSubject,
textBody: outside.text === '' ? null : outside.text,
htmlBody: outside.html,
},
}
}
/** 두 폼이 같은 내용인가 — 저장하지 않은 것이 있는지 본다. 끌 때까지 계속이면 숨은 날짜 칸은 보지 않는다 */
/**
* 두 폼의 칸이 같은 내용인가 — 저장하지 않은 것이 있는지 본다(내용은 편집기가 따로 잰다 — isChanged).
* 끌 때까지 계속이면 숨은 날짜 칸은 보지 않는다
*/
export function sameVacationForm(a: VacationForm, b: VacationForm): boolean {
const sameRange = a.period === 'untilOff' || (a.fromDay === b.fromDay && a.toDay === b.toDay)
return (
@@ -125,10 +266,43 @@ export function sameVacationForm(a: VacationForm, b: VacationForm): boolean {
a.period === b.period &&
sameRange &&
a.subject === b.subject &&
a.textBody === b.textBody
a.externalMode === b.externalMode &&
a.externalSubject === b.externalSubject
)
}
/**
* 미리 보기의 아래 글 (디자인 원본 Settings16 3) — 꺼져 있으면 두 쪽 모두 꺼짐 글.
* 사내와 같은 응답: 사내 쪽은 16단계 전의 글 · 회사 밖 쪽은 "…에도 같은 글로". 보내지 않기 · 따로 쓰기: 사내 쪽은 "사내에서 온 메일에",
* 따로 쓰기의 회사 밖 쪽은 "회사 밖에서 온 메일에", 보내지 않기의 회사 밖 쪽은 null(회색 상자만 — 아래 글 없음)
*/
export function vacationPreviewNote(
enabled: boolean,
mode: MailVacationExternalMode,
side: VacationPreviewSide,
): string | null {
if (!enabled) return VACATION_TEXT.previewNoteOff
if (mode === 'same') return side === 'inside' ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteSameOutside
if (side === 'inside') return VACATION_TEXT.previewNoteInside
return mode === 'none' ? null : VACATION_TEXT.previewNoteOutside
}
/** 미리 보기가 답장 대신 "자동으로 답하지 않습니다" 회색 상자를 보이는가 — 보내지 않기의 회사 밖 쪽 */
export function vacationPreviewSendsNothing(mode: MailVacationExternalMode, side: VacationPreviewSide): boolean {
return mode === 'none' && side === 'outside'
}
/** 미리 보기가 그 쪽에 보일 답장이 회사 밖에 따로 쓴 글인가 — 따로 쓰기의 회사 밖 쪽만 (그 밖은 위의 제목 · 내용) */
export function vacationPreviewUsesExternal(mode: MailVacationExternalMode, side: VacationPreviewSide): boolean {
return mode === 'separate' && side === 'outside'
}
/** 내 주소의 도메인 — "보낸 사람 주소가 회사 메일 주소(@…)가 아닌 메일입니다." (마지막 @ 뒤) */
export function emailDomainOf(email: string): string {
const at = email.lastIndexOf('@')
return at >= 0 ? email.slice(at + 1) : email
}
const WEEKDAY = new Intl.DateTimeFormat('ko-KR', { weekday: 'short' })
/** "11월 3일" — 올해가 아니면 앞에 연도 ("2027년 1월 3일") */
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from './auth.store'
import { useContactImportStore } from './contact-import.store'
// 가져오기 · 되돌리기의 차례 (16단계 — 이 탭에서 한 번에 하나, 도는 동안 누른 [되돌리기] · [다시 시도]는 끝난 뒤에)
describe('contact-import.store', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
it('앞의 일이 끝난 뒤에 다음 일을 한다 — 도는 동안 busy 는 그 일의 종류', async () => {
const store = useContactImportStore()
const order: string[] = []
let finish: () => void = () => undefined
const first = store.enqueue(
'import',
() =>
new Promise<void>((resolve) => {
order.push('넣기 시작')
finish = () => {
order.push('넣기 끝')
resolve()
}
}),
)
const second = store.enqueue('undo', async () => {
order.push('되돌리기')
})
await Promise.resolve()
await Promise.resolve()
expect(store.busy).toBe('import')
expect(order).toEqual(['넣기 시작'])
finish()
await Promise.all([first, second])
expect(order).toEqual(['넣기 시작', '넣기 끝', '되돌리기'])
expect(store.busy).toBeNull()
})
it('넣은 뒤 세션이 바뀌었으면 건너뛴다 — 다른 직원의 주소록을 건드리지 않게, 앞의 실패는 차례를 막지 않는다', async () => {
const store = useContactImportStore()
const order: string[] = []
let finish: () => void = () => undefined
const first = store.enqueue(
'import',
() =>
new Promise<void>((_resolve, reject) => {
finish = () => reject(new Error('멈춤'))
}),
)
const stale = store.enqueue('undo', async () => {
order.push('옛 세션의 되돌리기')
})
await Promise.resolve()
useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
const fresh = store.enqueue('import', async () => {
order.push('새 세션의 넣기')
})
finish()
await expect(first).rejects.toThrow('멈춤')
await stale
await fresh
expect(order).toEqual(['새 세션의 넣기'])
expect(store.busy).toBeNull()
})
})
@@ -0,0 +1,38 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
// 주소록 가져오기 · 되돌리기가 이 탭에서 도는 중인가 (16단계 — 디자인 원본 Contacts16 "1" · "4")
// 한 번에 하나만 돈다 — 도는 동안 「전체 연락처」 머리의 [가져오기]는 바쁜 표시(aria-busy)이고 눌러도 아무 일이 없다.
// 도는 동안 누른 [다시 시도] · [되돌리기]는 버리지 않고 차례를 기다린다(그 알림은 눌러 닫혔다) — 단, 누른 뒤 세션이 바뀌었으면 하지 않는다
// (다른 직원의 주소록에 넣거나 지우지 않게). 도는 일 자체는 composables/useContactImport.ts 가 한다(다른 화면으로 가도 이어 한다)
export type ContactImportBusy = 'import' | 'undo'
export const useContactImportStore = defineStore('contact-import', () => {
const authStore = useAuthStore()
const busy = ref<ContactImportBusy | null>(null)
let tail: Promise<void> = Promise.resolve()
/**
* 차례에 넣는다 — 앞의 일이 끝나면 task 를 한다. 넣은 때의 세션이 그때도 그대로일 때만(아니면 건너뛴다).
* task 의 실패는 차례를 막지 않는다
*/
function enqueue(kind: ContactImportBusy, task: () => Promise<void>): Promise<void> {
const session = authStore.generation
const next = tail.then(async () => {
if (authStore.generation !== session) return
busy.value = kind
try {
await task()
} finally {
busy.value = null
}
})
tail = next.catch(() => undefined)
return next
}
return { busy, enqueue }
})
+13
View File
@@ -612,6 +612,18 @@ export const useContactsStore = defineStore('contacts', () => {
return added
}
/**
* 여러 연락처가 한꺼번에 바뀐 뒤(16단계 가져오기 · 그 [되돌리기]) — 응답으로 자리를 알 수 없어 연락처 수 · 그룹 · 보는 목록의 첫 쪽을 다시 받는다.
* removedIds 는 지운 연락처 — 고른 채 남은 주소가 다시 받지 않고 "찾을 수 없음"을 그리게
*/
function afterBulkChange(removedIds: readonly string[] = []): void {
for (const id of removedIds) goneIds.add(id)
if (detail.value && goneIds.has(detail.value.id)) closeDetail()
void loadContactTotal()
void loadGroups()
void reloadFirstPage()
}
function reset() {
groupsSeq += 1
totalSeq += 1
@@ -707,6 +719,7 @@ export const useContactsStore = defineStore('contacts', () => {
forgetGroup,
deleteGroup,
addMembers,
afterBulkChange,
reset,
}
})
+203 -30
View File
@@ -2,23 +2,48 @@ 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 type { MailSignature, MailSignatureDefaults } from '@/shared/types/mail-signature'
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 {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailSignatureStore } from './mail-signature.store'
// 여러 서명 스토어 — 계약 2절 "서명" (16단계)
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',
function signature(id: string, name: string): MailSignature {
return { id, name, bodyHtml: `<p>${name}</p>`, body: name, images: [], updatedAt: '2026-11-10T00:00:00Z' }
}
describe('mail-signature.store (계약 2절 "서명")', () => {
const IN = signature('in', '사내')
const OUT = signature('out', '대외')
const DEFAULTS: MailSignatureDefaults = { newMessageId: 'out', replyId: 'in' }
function page(items: MailSignature[]) {
return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
}
/** 목록 · 기본 둘을 주는 서버 — 다른 요청은 extra 가 먼저 본다 */
function server(options: { list?: MailSignature[]; defaults?: MailSignatureDefaults; extra?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined } = {}) {
return (request: MockRequest) => {
const extra = options.extra?.(request)
if (extra) return extra
if (request.method === 'GET' && request.url === '/mail-signatures') return page(options.list ?? [IN, OUT])
if (request.method === 'GET' && request.url === '/mail-signature-defaults') return ok(options.defaults ?? DEFAULTS)
return ok(null)
}
}
describe('mail-signature.store (계약 2절 "서명" — 여러 서명)', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
@@ -29,44 +54,192 @@ describe('mail-signature.store (계약 2절 "서명")', () => {
restoreApiAdapter()
})
it('쓰기 화면은 받아 둔 서명을 쓴다 — 받는 중이면 그 결과를 기다리고, 다시 묻지 않는다', async () => {
const requests = installMockApi(() => ok(SIGNATURE))
it('목록과 기본 둘을 함께 받는다 — 받는 중이면 그 결과를 기다리고 다시 묻지 않는다, 기본 서명을 찾는다', async () => {
const requests = installMockApi(server())
const store = useMailSignatureStore()
const [first, second] = await Promise.all([store.ensureSignature(), store.ensureSignature()])
await store.ensureSignature()
const [first, second] = await Promise.all([store.ensureLoaded(), store.ensureLoaded()])
await store.ensureLoaded()
expect(first).toEqual(SIGNATURE)
expect(second).toEqual(SIGNATURE)
expect(requests).toHaveLength(1)
expect([first, second]).toEqual([true, true])
expect(requests.map((request) => request.url).sort()).toEqual(['/mail-signature-defaults', '/mail-signatures'])
expect(store.signatures.map((item) => item.name)).toEqual(['사내', '대외'])
expect(store.defaultSignature('new')?.name).toBe('대외')
expect(store.defaultSignature('reply')?.name).toBe('사내')
})
it('못 받으면 알리지 않고 null — 다음에 다시 받아 본다', async () => {
let result: MockResult = fail(503, 'MAIL_001')
const requests = installMockApi(() => result)
it('못 받으면 알리지 않고 false — 다음에 다시 받아 본다', async () => {
let failing = true
const requests = installMockApi(
server({ extra: (request) => (failing && request.url === '/mail-signatures' ? fail(503, 'MAIL_001') : undefined) }),
)
const store = useMailSignatureStore()
await expect(store.ensureSignature()).resolves.toBeNull()
await expect(store.ensureLoaded()).resolves.toBe(false)
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)
failing = false
await expect(store.ensureLoaded()).resolves.toBe(true)
expect(requests.filter((request) => request.url === '/mail-signatures')).toHaveLength(2)
})
it('저장한 값이 곧 다음 쓰기 화면의 서명이다 — 사용자가 바뀌면 비운다', async () => {
const requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...SIGNATURE, isForReply: true }) : ok(SIGNATURE)))
it('응답 모양이 틀린 줄은 빼고, 모르는 기본 값은 넣지 않음으로 본다', async () => {
installMockApi((request) =>
request.url === '/mail-signatures'
? ok({ items: [IN, { id: 7, name: '숫자 id' }, null], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
: ok({ newMessageId: 3, replyId: 'in' }),
)
const store = useMailSignatureStore()
await store.ensureLoaded()
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'])
expect(store.signatures.map((item) => item.id)).toEqual(['in'])
expect(store.defaults).toEqual({ newMessageId: null, replyId: 'in' })
})
it('첫 서명을 만들면 두 곳의 기본이 그 서명이다 — 그다음 서명은 기본을 바꾸지 않는다', async () => {
let created = 0
installMockApi(
server({
list: [],
defaults: { newMessageId: null, replyId: null },
extra: (request) => {
if (request.method !== 'POST') return undefined
created += 1
const body = request.data as { name: string }
return ok(signature(`s${created}`, body.name), 201)
},
}),
)
const store = useMailSignatureStore()
await store.ensureLoaded()
await store.create({ name: '기본 서명', bodyHtml: '<p>김아라</p>', body: '김아라' })
expect(store.defaults).toEqual({ newMessageId: 's1', replyId: 's1' })
await store.create({ name: '영문', bodyHtml: null, body: null })
expect(store.signatures.map((item) => item.name)).toEqual(['기본 서명', '영문'])
expect(store.defaults).toEqual({ newMessageId: 's1', replyId: 's1' })
})
it('지우면 목록에서 빼고 그 서명을 기본으로 넣던 곳은 넣지 않음 — 이미 없으면(RES_001) 알리지 않고 지운 것으로 본다', async () => {
installMockApi(
server({
defaults: { newMessageId: 'out', replyId: 'out' },
extra: (request) => {
if (request.method !== 'DELETE') return undefined
return request.url === '/mail-signatures/out' ? { status: 204 } : fail(404, 'RES_001')
},
}),
)
const store = useMailSignatureStore()
await store.ensureLoaded()
await store.remove('out')
expect(store.signatures.map((item) => item.id)).toEqual(['in'])
expect(store.defaults).toEqual({ newMessageId: null, replyId: null })
await store.remove('in')
expect(store.signatures).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
describe('기본 둘 고르기 — 고르는 대로 저장, 한 번에 하나', () => {
it('화면을 먼저 바꾸고 PUT — 보내는 동안 다시 고르면 끝난 뒤 마지막 것만 보낸다', async () => {
const held: ((result: MockResult) => void)[] = []
const requests = installMockApi(
server({
extra: (request) =>
request.method === 'PUT'
? new Promise<MockResult>((resolve) => {
held.push(resolve)
})
: undefined,
}),
)
const store = useMailSignatureStore()
await store.ensureLoaded()
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data)
const firstSave = store.chooseDefault('reply', 'out')
expect(store.defaults).toEqual({ newMessageId: 'out', replyId: 'out' })
const secondSave = store.chooseDefault('new', null)
const thirdSave = store.chooseDefault('new', 'in')
expect(store.defaults).toEqual({ newMessageId: 'in', replyId: 'out' })
await flushPromises()
expect(puts()).toEqual([{ newMessageId: 'out', replyId: 'out' }])
held.shift()?.(ok({ newMessageId: 'out', replyId: 'out' }))
await flushPromises()
// 앞 저장이 끝났다 — 마지막으로 고른 것만 이어 보낸다 (그사이 고른 null 은 보내지 않는다)
expect(puts()).toEqual([
{ newMessageId: 'out', replyId: 'out' },
{ newMessageId: 'in', replyId: 'out' },
])
held.shift()?.(ok({ newMessageId: 'in', replyId: 'out' }))
await expect(Promise.all([firstSave, secondSave, thirdSave])).resolves.toEqual([true, true, true])
expect(store.defaults).toEqual({ newMessageId: 'in', replyId: 'out' })
expect(store.defaultsSaving).toBe(false)
})
it('저장하지 못하면 서버가 확인해 준 값으로 되돌린다 (알림은 인터셉터)', async () => {
installMockApi(server({ extra: (request) => (request.method === 'PUT' ? fail(503, 'MAIL_001') : undefined) }))
const store = useMailSignatureStore()
await store.ensureLoaded()
await expect(store.chooseDefault('new', 'in')).resolves.toBe(false)
expect(store.defaults).toEqual(DEFAULTS)
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
})
it('받기 전에는 고르지 않는다', async () => {
const requests = installMockApi(server())
const store = useMailSignatureStore()
await expect(store.chooseDefault('new', 'in')).resolves.toBe(false)
expect(requests).toEqual([])
})
})
it('조용히 다시 받기 — 받는 사이 이 탭에서 바꿨으면 받은 값을 버린다', async () => {
let list = [IN, OUT]
/** 목록 응답을 내기 전에 할 일 — 그 일이 끝난 뒤에 목록을 낸다 */
let beforeList: (() => Promise<unknown>) | null = null
installMockApi(async (request) => {
if (request.method === 'GET' && request.url === '/mail-signatures') {
const reply = page(list)
const before = beforeList
beforeList = null
if (before) await before()
return reply
}
if (request.method === 'GET') return ok(DEFAULTS)
if (request.method === 'PUT') return ok(signature('in', '사내 2'))
return ok(null)
})
const store = useMailSignatureStore()
await store.ensureLoaded()
// 다른 창에서 서명을 하나 더 만들었다 — 다시 받으면 보인다
list = [IN, OUT, signature('en', '영문')]
await expect(store.refresh()).resolves.toBe(true)
expect(store.signatures.map((item) => item.name)).toEqual(['사내', '대외', '영문'])
// 받는 사이 이 탭에서 고쳤다 — 늦게 온 옛 목록이 고친 것을 덮지 않는다
list = [IN, OUT]
beforeList = () => store.update('in', { name: '사내 2', bodyHtml: null, body: null })
await expect(store.refresh()).resolves.toBe(false)
expect(store.signatures.map((item) => item.name)).toEqual(['사내 2', '대외', '영문'])
})
it('사용자가 바뀌면 비운다 — 이전 사용자의 서명이 남지 않게', async () => {
installMockApi(server())
const store = useMailSignatureStore()
await store.ensureLoaded()
useAuthStore().clearAuth()
await nextTick()
expect(store.signature).toBeNull()
expect(store.signatures).toEqual([])
expect(store.defaults).toEqual({ newMessageId: null, replyId: null })
expect(store.status).toBe('idle')
})
})
+234 -37
View File
@@ -2,75 +2,256 @@ 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 { useMailSignatures, type MailSignatureRequestOptions } from '@/composables/useMailSignatures'
import type {
MailSignature,
MailSignatureDefaults,
MailSignaturePayload,
SignatureUse,
} from '@/shared/types/mail-signature'
import { sameSignatureDefaults, withoutSignatureDefault } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 메일 서명 스토어 — 계약 2절 "서명" (사용자마다 하나)
// - 쓰기 화면은 열 때 서명을 넣는다 — 메일 화면에 들어올 때 미리 받아 두고(ensureSignature), 쓰기 화면은 그 값을 쓴다.
// 못 받았으면 서명 없이 연다 (알리지 않는다 — 서명은 쓰기를 막을 일이 아니다)
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창에서 고쳤을 수 있다. 오류는 화면이 직접 그린다
// - 저장(save)한 값이 곧 다음 쓰기 화면의 서명이다
const NO_DEFAULTS: MailSignatureDefaults = { newMessageId: null, replyId: null }
// 여러 서명 스토어 — 계약 2절 "서명" (16단계 — 직원마다 서명 10개 · 기본 둘, 디자인 원본 Signature16)
// - 받기: 목록(GET /mail-signatures)과 기본 둘(GET /mail-signature-defaults)을 함께 받는다 — 둘 다 와야 ready, 하나라도 실패하면 error(알리지 않는다).
// 메일 화면은 들어올 때 받아 두고(ensureLoaded — 쓰기 화면이 기다리지 않게), 쓰기 화면은 그 값을 쓴다. 못 받았으면 서명 없이 연다
// (서명은 쓰기를 막을 일이 아니다). 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창에서 고쳤을 수 있다. 오류는 화면이 그린다
// - 만들기 · 고치기 · 지우기는 성공하면 받아 둔 목록을 그 자리에서 고친다(다시 받지 않는다). 서명이 하나도 없던 직원의 첫 서명은
// 서버가 기본 둘로 정한다 — 같게 맞춘다. 지운 서명을 기본으로 넣던 곳은 넣지 않음(null)
// - 기본 둘(chooseDefault): 고르는 대로 PUT — 화면을 먼저 바꾸고, **저장은 한 번에 하나**(보내기 · 읽기 설정과 같은 결):
// 보내는 동안 다시 고르면 마지막으로 고른 것만 기억했다가 앞 저장이 끝난 뒤 서버의 답과 다르면 보낸다.
// 마지막으로 고른 것의 저장이 실패하면 서버가 마지막으로 확인해 준 값으로 되돌린다(오류 알림은 인터셉터)
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) · 다시 받은 뒤(load) 늦게 온 응답은 쓰지 않는다 (authStore.generation · 순번)
export const useMailSignatureStore = defineStore('mail-signature', () => {
const mailApi = useMail()
const signatureApi = useMailSignatures()
const authStore = useAuthStore()
const signature = ref<MailSignature | null>(null)
/** 내 서명 — 만든 차례 */
const signatures = ref<MailSignature[]>([])
/** 보이는 기본 둘 — 고르면 저장이 끝나기 전에 먼저 바뀐다 (마지막으로 고른 값) */
const defaults = ref<MailSignatureDefaults>({ ...NO_DEFAULTS })
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
/** 기본 둘을 저장하는 중 */
const defaultsSaving = ref<boolean>(false)
let seq = 0
let pending: Promise<MailSignature | null> | null = null
let loadSeq = 0
let pending: Promise<boolean> | null = null
/** 받은 뒤 이 탭에서 바꾼 횟수 — 조용히 다시 받는 사이(refresh) 바꿨으면 받은 값을 버린다 */
let changeSeq = 0
/** 서버가 마지막으로 확인해 준 기본 둘 — 마지막으로 고른 것의 저장이 실패하면 이 값으로 되돌린다 */
let confirmedDefaults: MailSignatureDefaults = { ...NO_DEFAULTS }
/** 마지막으로 고른 기본 둘 — 도는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
let wantedDefaults: MailSignatureDefaults | null = null
/** 도는 기본 둘 저장 — 한 번에 하나 */
let defaultsChain: Promise<boolean> | null = null
/** 서명을 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
function load(): Promise<MailSignature | null> {
seq += 1
const current = seq
function apply(list: MailSignature[], chosen: MailSignatureDefaults) {
signatures.value = list
defaults.value = { ...chosen }
confirmedDefaults = { ...chosen }
}
/** 기다리던 기본 둘 저장을 잊는다 — 새로 받기 전(load) · 세션이 바뀔 때(reset). 늦게 끝나는 저장은 순번으로 버린다 */
function forgetDefaultsSave() {
wantedDefaults = null
defaultsChain = null
defaultsSaving.value = false
}
/** 서명 — 없으면 null */
function signatureById(id: string | null): MailSignature | null {
if (id === null) return null
return signatures.value.find((signature) => signature.id === id) ?? null
}
/** 그 쓰기 종류에 넣을 서명 — 넣지 않음이거나 그 서명이 없으면 null */
function defaultSignature(use: SignatureUse): MailSignature | null {
return signatureById(use === 'new' ? defaults.value.newMessageId : defaults.value.replyId)
}
/** 새로 받는다 — 목록과 기본 둘을 함께. 실패하면 false (알림 없이 상태에 담는다) */
function load(): Promise<boolean> {
loadSeq += 1
const current = loadSeq
const session = authStore.generation
status.value = 'loading'
error.value = null
forgetDefaultsSave()
const stale = () => current !== loadSeq || session !== authStore.generation
const request = (async () => {
try {
const result = await mailApi.getSignature({ silent: true })
if (current !== seq) return signature.value
signature.value = result
const [list, chosen] = await Promise.all([
signatureApi.listSignatures({ silent: true }),
signatureApi.getDefaults({ silent: true }),
])
if (stale()) return status.value === 'ready'
apply(list, chosen)
status.value = 'ready'
return result
return true
} catch (caught) {
if (current !== seq) return signature.value
if (stale()) return status.value === 'ready'
status.value = 'error'
error.value = toApiError(caught)
return null
return false
} finally {
if (current === seq) pending = null
if (current === loadSeq) pending = null
}
})()
pending = request
return request
}
/** 받아 둔 서명 — 아직 없거나 실패했으면 받는다 (받는 중이면 그 결과를 기다린다) */
function ensureSignature(): Promise<MailSignature | null> {
if (status.value === 'ready') return Promise.resolve(signature.value)
/** 받아 둔 것을 쓴다 — 아직 없거나 실패했으면 받는다 (받는 중이면 그 결과를 기다린다). 받았으면 true */
function ensureLoaded(): Promise<boolean> {
if (status.value === 'ready') return Promise.resolve(true)
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
/**
* 조용히 다시 받는다 — 받는 동안에도 지금 값 그대로(loading 으로 바꾸지 않는다). 받는 사이 이 탭에서 바꿨거나(만들기 · 고치기 · 지우기 · 기본 고르기)
* 다시 받았거나 세션이 바뀌었으면 받은 값을 버린다. 서명 화면이 저장 거절(VAL_001)의 까닭을 가를 때 쓴다(다른 창에서 같은 이름을 만들었는지)
*/
async function refresh(): Promise<boolean> {
if (status.value !== 'ready') return false
const session = authStore.generation
const sessionLoad = loadSeq
const changes = changeSeq
try {
const [list, chosen] = await Promise.all([
signatureApi.listSignatures({ silent: true }),
signatureApi.getDefaults({ silent: true }),
])
const stale =
session !== authStore.generation || sessionLoad !== loadSeq || changes !== changeSeq || defaultsChain !== null
if (stale) return false
apply(list, chosen)
return true
} catch {
return false
}
}
/**
* 새 서명 — 실패하면 예외가 그대로 올라간다(칸 안 안내는 화면이 한다). 목록 끝에 붙이고,
* 서명이 하나도 없던 직원이면 기본 둘을 이 서명으로 맞춘다 (서버가 그렇게 정한다)
*/
async function create(payload: MailSignaturePayload, options?: MailSignatureRequestOptions): Promise<MailSignature> {
const session = authStore.generation
const wasEmpty = signatures.value.length === 0
const created = await signatureApi.createSignature(payload, options)
if (session !== authStore.generation) return created
changeSeq += 1
signatures.value = [...signatures.value.filter((signature) => signature.id !== created.id), created]
if (wasEmpty) {
const both = { newMessageId: created.id, replyId: created.id }
defaults.value = both
confirmedDefaults = { ...both }
}
return created
}
/** 서명 고치기 — 이름 · 본문 전체. 실패하면 예외가 그대로 올라간다 */
async function update(
id: string,
payload: MailSignaturePayload,
options?: MailSignatureRequestOptions,
): Promise<MailSignature> {
const session = authStore.generation
const updated = await signatureApi.updateSignature(id, payload, options)
if (session !== authStore.generation) return updated
changeSeq += 1
signatures.value = signatures.value.map((signature) => (signature.id === id ? updated : signature))
return updated
}
/**
* 서명 지우기 — 그 서명을 기본으로 넣던 곳은 넣지 않음. 이미 없으면(RES_001 — 다른 창에서 지웠다) 지운 것으로 본다(알리지 않는다).
* 그 밖의 실패는 예외가 그대로 올라간다(알림은 인터셉터)
*/
async function remove(id: string): Promise<void> {
const session = authStore.generation
try {
await signatureApi.deleteSignature(id, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') throw caught
}
if (session !== authStore.generation) return
changeSeq += 1
signatures.value = signatures.value.filter((signature) => signature.id !== id)
defaults.value = withoutSignatureDefault(defaults.value, id)
confirmedDefaults = withoutSignatureDefault(confirmedDefaults, id)
if (wantedDefaults) wantedDefaults = withoutSignatureDefault(wantedDefaults, id)
}
/** 기본 둘 저장 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
async function flushDefaults(): Promise<boolean> {
const session = authStore.generation
const sessionLoad = loadSeq
const stillCurrent = () => session === authStore.generation && sessionLoad === loadSeq
defaultsSaving.value = true
let succeeded = false
try {
let sending = wantedDefaults ?? defaults.value
for (;;) {
let next: MailSignatureDefaults | null = null
try {
const result = await signatureApi.saveDefaults(sending)
if (!stillCurrent()) return false
succeeded = true
confirmedDefaults = { ...result }
const latest = wantedDefaults ?? sending
if (!sameSignatureDefaults(latest, sending) && !sameSignatureDefaults(latest, result)) next = latest
else defaults.value = { ...result }
} catch {
if (!stillCurrent()) return false
succeeded = false
const latest = wantedDefaults ?? sending
if (!sameSignatureDefaults(latest, sending) && !sameSignatureDefaults(latest, confirmedDefaults)) next = latest
else defaults.value = { ...confirmedDefaults }
}
if (!next) break
sending = next
}
return succeeded
} finally {
if (stillCurrent()) {
wantedDefaults = null
defaultsChain = null
defaultsSaving.value = false
}
}
}
/**
* 기본 하나를 고른다(서명 id · 넣지 않음 null) — 화면을 먼저 바꾸고 PUT. 저장이 이미 돌고 있으면 고른 값만 기억한다(앞 저장이 끝나면 이어 보낸다).
* 받기 전 · 받는 중이면 하지 않는다(false). 돌려주는 약속은 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
*/
function chooseDefault(use: SignatureUse, id: string | null): Promise<boolean> {
if (status.value !== 'ready') return Promise.resolve(false)
const next: MailSignatureDefaults =
use === 'new' ? { ...defaults.value, newMessageId: id } : { ...defaults.value, replyId: id }
defaults.value = next
wantedDefaults = next
changeSeq += 1
if (defaultsChain) return defaultsChain
const chain = flushDefaults()
defaultsChain = chain
return chain
}
function reset() {
seq += 1
loadSeq += 1
pending = null
signature.value = null
changeSeq += 1
forgetDefaultsSave()
signatures.value = []
defaults.value = { ...NO_DEFAULTS }
confirmedDefaults = { ...NO_DEFAULTS }
status.value = 'idle'
error.value = null
}
@@ -81,5 +262,21 @@ export const useMailSignatureStore = defineStore('mail-signature', () => {
() => reset(),
)
return { signature, status, error, load, ensureSignature, save, reset }
return {
signatures,
defaults,
status,
error,
defaultsSaving,
signatureById,
defaultSignature,
load,
ensureLoaded,
refresh,
create,
update,
remove,
chooseDefault,
reset,
}
})
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import type { MailVacation, MailVacationExternal, MailVacationState } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
@@ -13,7 +13,7 @@ import { useMailVacationStore } from './mail-vacation.store'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const OTHER = { email: 'minsu.park@arasystem.it.kr', name: '박민수' }
/** PUT 본문 — 다섯 칸 */
/** 4단계 칸 다섯 */
const BODY: MailVacation = {
isEnabled: true,
fromDate: null,
@@ -21,8 +21,12 @@ const BODY: MailVacation = {
subject: '부재중입니다',
textBody: '11월 3일까지 자리를 비웁니다.',
}
/** 16단계 칸 — 서식 본문 · 회사 밖 응답의 처음 값(글만 · 사내와 같은 응답) */
const SAME: MailVacationExternal = { mode: 'same', subject: null, textBody: null, htmlBody: null }
/** PUT 본문 — 일곱 칸 (16단계) */
const FULL: MailVacation = { ...BODY, htmlBody: null, external: SAME }
/** GET · PUT 응답 — 본문 + 엔진에서 동작하는지 (7단계) */
const ON: MailVacationState = { ...BODY, isActive: true }
const ON: MailVacationState = { ...FULL, isActive: true }
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꿔 본다 */
function deferred() {
@@ -107,13 +111,13 @@ describe('mail-vacation.store — 늦게 온 응답', () => {
await store.load()
expect(store.vacation?.isActive).toBe(false)
// 7단계 앞의 서버 — isActive 가 없다
// 7단계 앞의 서버 — isActive 가 없다 (16단계 칸도 없다 — 글만 · 사내와 같은 응답으로 읽는다)
reply = BODY
await store.load()
expect(store.vacation).toEqual({ ...BODY, isActive: true })
expect(store.vacation).toEqual({ ...FULL, isActive: true })
})
it('저장 본문에는 isActive 를 넣지 않는다 — 받은 값(isActive: false)을 그대로 넘겨도 다섯 칸만, 응답의 isActive 로 맞춘다', async () => {
it('저장 본문에는 isActive 를 넣지 않는다 — 받은 값(isActive: false)을 그대로 넘겨도 일곱 칸만, 응답의 isActive 로 맞춘다', async () => {
requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...ON, isActive: true }) : ok({ ...ON, isActive: false })))
const store = useMailVacationStore()
const loaded = await store.load()
@@ -121,7 +125,28 @@ describe('mail-vacation.store — 늦게 온 응답', () => {
await store.save(loaded as MailVacationState)
expect(requests[1]?.data).toEqual(BODY)
expect(requests[1]?.data).toEqual(FULL)
expect(store.vacation?.isActive).toBe(true)
})
it('16단계 칸 — 서식 본문 · 회사 밖 응답을 그대로 받고 보낸다, 모양이 틀린 칸은 글만 · 사내와 같은 응답으로', async () => {
const external: MailVacationExternal = {
mode: 'separate',
subject: '부재중 안내',
textBody: '11월 20일까지 자리를 비웁니다.',
htmlBody: '<div><p><strong>11월 20일</strong>까지 자리를 비웁니다.</p></div>',
}
const rich: MailVacationState = { ...ON, htmlBody: '<div><p>11월 3일까지 자리를 비웁니다.</p></div>', external }
let reply: unknown = rich
requests = installMockApi((request) => (request.method === 'PUT' ? ok(request.data) : ok(reply)))
const store = useMailVacationStore()
expect(await store.load()).toEqual(rich)
await store.save(rich)
// isActive 는 빼고 일곱 칸 — 회사 밖 응답의 모르는 칸도 싣지 않는다
expect(requests[1]?.data).toEqual({ ...BODY, htmlBody: rich.htmlBody, external })
reply = { ...BODY, isActive: true, htmlBody: 3, external: { mode: 'later' } }
expect(await store.load()).toEqual(ON)
})
})
+1 -1
View File
@@ -12,7 +12,7 @@ import type { LoadStatus } from '@/stores/mail.store'
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창 · 메일 프로그램에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
// - 저장(save)한 값이 곧 패널 안내의 값이다. 응답의 isActive(7단계 — 엔진에서 지금 동작하는지)도 함께 둔다 — 설정 화면의
// "켜 둔 자동 응답이 지금은 동작하지 않습니다" 줄은 저장한 값이 켜짐이고 isActive 가 false 일 때다(저장하면 응답의 값으로 내린다).
// PUT 본문에는 넣지 않는다 (useMail.saveVacation 이 다섯 칸만 보낸다)
// PUT 본문에는 넣지 않는다 (useMail.saveVacation 이 일곱 칸만 보낸다 — 16단계의 서식 본문 · 회사 밖 응답까지)
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset 이 올린다),
// 저장은 세션 세대(authStore.generation — 그사이 불러오기가 있어도 저장한 값이 더 새롭다)로 가린다
export const useMailVacationStore = defineStore('mail-vacation', () => {
+63
View File
@@ -296,4 +296,67 @@ describe('toast.store', () => {
wrapper.unmount()
})
})
describe('✕로 닫을 때까지 남는 알림 — 16단계 주소록 가져오기 결과 (디자인 원본 Contacts16 "4")', () => {
it('저절로 닫히지 않고, 넘쳐도 다른 알림부터 닫는다 — 지금 쓰는 알림의 시간은 그대로', () => {
const store = useToastStore()
store.show({ kind: 'success', message: '연락처 330명을 넣었습니다.', action: undo, sticky: true })
store.show({ kind: 'success', message: '하나' })
vi.advanceTimersByTime(10 * 60_000)
expect(messages()).toEqual(['연락처 330명을 넣었습니다.'])
store.show({ kind: 'success', message: '둘' })
store.show({ kind: 'success', message: '셋' })
store.show({ kind: 'success', message: '넷' })
expect(messages()).toEqual(['연락처 330명을 넣었습니다.', '셋', '넷'])
expect(store.toasts.map((toast) => toast.sticky)).toEqual([true, false, false])
})
it('진행 알림의 결과로 바꿀 수 있다 — succeed · fail 의 sticky (진행 알림 자체는 sticky 가 아니다)', () => {
const store = useToastStore()
const progress = useToast().progress('연락처를 넣는 중 · 0 / 330명')
expect(store.toasts[0]?.sticky).toBe(false)
progress.succeed('연락처 330명을 넣었습니다.', { label: '되돌리기', run: () => undefined }, { sticky: true })
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS * 10)
expect(messages()).toEqual(['연락처 330명을 넣었습니다.'])
const failing = useToast().progress('가져온 연락처를 지우는 중')
failing.fail('멈췄습니다.', { label: '다시 시도', run: () => undefined }, { sticky: true })
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS * 10)
expect(messages()).toEqual(['연락처 330명을 넣었습니다.', '멈췄습니다.'])
// sticky 를 주지 않은 결과는 지금처럼 10초
const plain = useToast().progress('필터 적용 중')
plain.succeed('적용했습니다.', { label: '보기', run: () => undefined })
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS)
expect(messages()).toEqual(['연락처 330명을 넣었습니다.', '멈췄습니다.'])
})
it('세션이 바뀌면 단추 알림처럼 닫는다', async () => {
useAuthStore().startSession({ email: 'ara.kim@arasystem.test', name: '김아라' })
useToast().success('연락처 330명을 넣었습니다.', undo, { sticky: true })
useAuthStore().clearAuth()
await nextTick()
expect(messages()).toEqual([])
})
it('ToastHost — 성공 알림에도 ✕(알림 닫기)를 단다, 누르면 닫힌다. sticky 가 아닌 성공 알림에는 ✕ 가 없다', async () => {
const router = createTestRouter()
const wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, router] } })
useToast().success('연락처 330명을 넣었습니다.', undo, { sticky: true })
useToast().success('연락처를 저장했습니다.')
await nextTick()
const [sticky, plain] = wrapper.findAll('.toast')
expect(plain?.find('[data-toast-close]').exists()).toBe(false)
const close = sticky?.get('[data-toast-close]')
expect(close?.attributes('aria-label')).toBe('알림 닫기')
expect(close?.classes()).toContain('toast__close--on-brand')
await close?.trigger('click')
expect(messages()).toEqual(['연락처를 저장했습니다.'])
wrapper.unmount()
})
})
})
+24 -8
View File
@@ -15,6 +15,8 @@ import { useAuthStore } from '@/stores/auth.store'
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
// 마감 시각이 있는 알림(expiresAt — 9단계 [보내기 취소]: 서버가 그 시각에 보낸다)은 그때 닫는다 — 마우스 · 초점이 있어도 멈추지 않는다
// (그 시각이 지나면 단추가 할 수 있는 일이 없다 — WCAG 2.2.1 의 실시간 예외). ToastHost 가 남은 시간 막대를 그린다.
// ✕로 닫을 때까지 남는 알림(sticky — 16단계 주소록 가져오기의 결과 [되돌리기] · 멈춤 [다시 시도])은 저절로 닫히지 않고 넘쳐도 다른 알림부터 닫는다.
// ToastHost 가 성공 알림에도 ✕ 를 그린다. 세션이 바뀌면 다른 단추 알림처럼 닫는다 (지금 쓰는 곳의 알림은 sticky 가 아니라 그대로다)
export type ToastKind = 'error' | 'success' | 'progress'
/** 정해진 시간 뒤에 닫히는 알림 */
@@ -35,6 +37,8 @@ export interface Toast {
shownAt: number
/** 닫히는 시각(ms — Date.now) — 정해 두면 그때 닫고 붙잡아도 멈추지 않는다 (보내기 취소). 없으면 null */
expiresAt: number | null
/** ✕로 닫을 때까지 남는다 — 저절로 닫히지 않는다 (16단계 가져오기 결과). 진행 알림에는 쓰지 않는다 */
sticky: boolean
}
export interface ToastInput {
@@ -44,6 +48,8 @@ export interface ToastInput {
action?: ToastAction | null
/** 닫히는 시각(ms — Date.now) — 진행 알림에는 쓰지 않는다 */
expiresAt?: number | null
/** ✕로 닫을 때까지 남긴다 — 진행 알림에는 쓰지 않는다 */
sticky?: boolean
}
/** 동시에 보이는 최대 개수 — 넘치면 오래된 것부터 닫는다 (진행 알림은 뒤로 미룬다) */
@@ -61,9 +67,9 @@ export const TOAST_ACTION_DURATION_MS = 10_000
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
export type ToastHoldReason = 'pointer' | 'focus'
/** 닫히기까지의 시간 — 진행 알림은 없다(null). 마감 시각이 있으면 그것이 앞선다(deadlineOf) */
function durationOf(toast: Pick<Toast, 'kind' | 'action'>): number | null {
if (toast.kind === 'progress') return null
/** 닫히기까지의 시간 — 진행 알림 · ✕까지 남는 알림은 없다(null). 마감 시각이 있으면 그것이 앞선다(deadlineOf) */
function durationOf(toast: Pick<Toast, 'kind' | 'action' | 'sticky'>): number | null {
if (toast.kind === 'progress' || toast.sticky) return null
return toast.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[toast.kind]
}
@@ -72,9 +78,12 @@ function deadlineOf(toast: Pick<Toast, 'kind' | 'expiresAt'>): number | null {
return toast.kind === 'progress' ? null : toast.expiresAt
}
/** 넘쳐도 뒤로 미루는 알림 — 진행 중인 알림 · 마감 시각이 있는 알림(보내기 취소 — 그 시각이 지나면 되돌릴 수 없다) */
/**
* 넘쳐도 뒤로 미루는 알림 — 진행 중인 알림 · 마감 시각이 있는 알림(보내기 취소 — 그 시각이 지나면 되돌릴 수 없다) ·
* ✕까지 남는 알림(가져오기 결과 — 여러 명을 한꺼번에 되돌리는 길이 그 알림뿐이다)
*/
function isLingering(toast: Toast): boolean {
return toast.kind === 'progress' || toast.expiresAt !== null
return toast.kind === 'progress' || toast.expiresAt !== null || toast.sticky
}
/** 넘친 만큼 닫을 알림 — 오래된 것부터, 진행 중 · 마감이 있는 알림은 다른 알림이 모자랄 때만. 방금 띄운 것은 닫지 않는다 */
@@ -148,7 +157,7 @@ export const useToastStore = defineStore('toast', () => {
* 닫히기까지의 시간을 정한다 — 붙잡혀 있지 않으면 바로 센다 (진행 알림은 시간이 없다).
* 마감 시각이 있으면 붙잡혀 있어도 그 시각까지 센다
*/
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action' | 'expiresAt'>) {
function setDuration(id: number, toast: Pick<Toast, 'kind' | 'action' | 'expiresAt' | 'sticky'>) {
const duration = durationOf(toast)
const deadline = deadlineOf(toast)
stopTimer(id)
@@ -198,6 +207,7 @@ export const useToastStore = defineStore('toast', () => {
action: input.action ?? null,
shownAt: Date.now(),
expiresAt: input.kind === 'progress' ? null : (input.expiresAt ?? null),
sticky: input.kind !== 'progress' && input.sticky === true,
}
const all = [...toasts.value, next]
const evicted = overflowOf(all, id)
@@ -210,7 +220,7 @@ export const useToastStore = defineStore('toast', () => {
/**
* 띄운 알림을 그 자리에서 바꾼다 — 진행 문구를 고치거나, 끝난 진행 알림을 결과(성공 · 오류)로 바꾼다.
* 종류 · 단추가 바뀌면 그에 맞는 시간을 처음부터 센다 (진행 알림이 되면 저절로 닫히지 않는다).
* 종류 · 단추 · ✕까지 남기기가 바뀌면 그에 맞는 시간을 처음부터 센다 (진행 알림이 되면 저절로 닫히지 않는다).
* 이미 닫힌 알림이면 false — 부르는 쪽이 새로 띄울지 정한다
*/
function update(id: number, patch: Partial<Omit<ToastInput, 'kind'>> & { kind?: ToastKind }): boolean {
@@ -224,9 +234,15 @@ export const useToastStore = defineStore('toast', () => {
traceId: patch.traceId === undefined ? current.traceId : patch.traceId,
action: patch.action === undefined ? current.action : patch.action,
expiresAt: kind === 'progress' ? null : patch.expiresAt === undefined ? current.expiresAt : patch.expiresAt,
sticky: kind !== 'progress' && (patch.sticky === undefined ? current.sticky : patch.sticky),
}
toasts.value = toasts.value.map((toast) => (toast.id === id ? next : toast))
if (next.kind !== current.kind || next.action !== current.action || next.expiresAt !== current.expiresAt) {
if (
next.kind !== current.kind ||
next.action !== current.action ||
next.expiresAt !== current.expiresAt ||
next.sticky !== current.sticky
) {
setDuration(id, next)
}
return true

Some files were not shown because too many files have changed in this diff Show More