feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면

- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰
  새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다
- 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다
- 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게
- 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다
- 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다
- 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:23:22 +09:00
parent a79f0791d0
commit 5710151c0b
88 changed files with 8717 additions and 418 deletions
+4
View File
@@ -60,6 +60,10 @@ describe('에러 코드 사전', () => {
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
// 웹메일 2단계
'MAIL_008', // 다른 곳에서 바뀐 임시 저장본 — 쓰기 화면이 새 저장본으로 남기고 정보 상태 글을 보인다
'MAIL_001', // 메일 서버 장애 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
@@ -411,4 +411,58 @@ describe('useAttachmentUploads', () => {
stop()
})
})
describe('임시 저장 뒤 첨부 id 바꾸기 (계약 2절 "임시 저장" — 저장할 때마다 첨부 id 가 바뀐다)', () => {
const SAVED = (id: string, name: string) => ({ id, name, type: 'application/pdf', size: 1 })
it('저장 본문에 실은 순서대로 응답의 id 로 바꾼다 — 화면의 이름 · 키는 그대로', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
uploads.addFiles([file('회의록.pdf')])
await flushPromises()
const [forward] = uploads.addExisting([SAVED('orig-1', '체크리스트.xlsx')])
const keys = uploads.items.value.map((item) => item.key)
uploads.applySavedAttachments(keys, [SAVED('up1-v1', '회의록.pdf'), SAVED('orig-1-v1', '체크리스트.xlsx')])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['up1-v1', 'orig-1-v1'])
expect(uploads.items.value.map((item) => item.key)).toEqual(keys)
expect(uploads.items.value[1]?.key).toBe(forward?.key)
stop()
})
it('이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이고 응답 순서가 달라도', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
const added = uploads.addExisting([
{ id: 'small', name: '보고서.pdf', type: 'application/pdf', size: 10 },
{ id: 'large', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
{ id: 'sheet', name: '표.xlsx', type: 'application/vnd.ms-excel', size: 5 },
])
const keys = added.map((item) => item.key)
uploads.applySavedAttachments(keys, [
{ id: 'large-v1', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
// 서버가 형식을 고쳐 돌려줬다 — 남은 것끼리 순서대로
{ id: 'sheet-v1', name: '표.xlsx', type: 'application/octet-stream', size: 5 },
{ id: 'small-v1', name: '보고서.pdf', type: 'application/pdf', size: 10 },
])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['small-v1', 'large-v1', 'sheet-v1'])
stop()
})
it('개수가 다르면 같은 자리 · 같은 이름끼리만 — 저장하는 동안 뺀 첨부는 건너뛴다', async () => {
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
const { uploads, stop } = withUploads()
const added = uploads.addExisting([SAVED('a', '가.pdf'), SAVED('b', '나.pdf')])
const keys = added.map((item) => item.key)
uploads.remove(keys[1] ?? -1)
uploads.applySavedAttachments(keys, [SAVED('a-v1', '가.pdf')])
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['a-v1'])
stop()
})
})
})
@@ -51,6 +51,11 @@ export interface AttachmentUploadOptions {
const COMPLETE = 100
/** 같은 파일로 보이는가 — 이름 · 크기 · 형식이 모두 같다 */
function isSameFile(a: Attachment, b: Attachment): boolean {
return a.name === b.name && a.size === b.size && a.type === b.type
}
/**
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
@@ -205,6 +210,43 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
return marked
}
/**
* 임시 저장이 끝났다 — 저장본의 첨부 id 로 바꿔 쓴다 (계약 2절 "임시 저장": 저장할 때마다 첨부 id 가 바뀐다).
* keys 는 저장 본문에 실은 첨부 항목의 키(실은 순서대로), saved 는 응답의 첨부다.
* 이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이어도 응답의 순서에만 기대지 않게.
* 남은 것은 순서대로 — 남은 수가 같으면 같은 자리끼리, 다르면 같은 자리 · 같은 이름끼리만 바꾼다.
* 저장하는 동안 뺀 항목은 이미 없고(그 자리는 건너뛴다), 새로 붙인 항목은 다음 저장에 실린다.
*/
function applySavedAttachments(keys: readonly number[], saved: readonly Attachment[]) {
const sent = keys.map((key) => items.value.find((candidate) => candidate.key === key))
const next = new Map<number, Attachment>()
const usedSaved = new Set<number>()
const matchedSent = new Set<number>()
sent.forEach((item, position) => {
const current = item?.attachment
if (!item || !current) return
const index = saved.findIndex((candidate, at) => !usedSaved.has(at) && isSameFile(current, candidate))
const attachment = saved[index]
if (!attachment) return
usedSaved.add(index)
matchedSent.add(position)
next.set(item.key, attachment)
})
const restSent = sent.filter((_, position) => !matchedSent.has(position))
const restSaved = saved.filter((_, at) => !usedSaved.has(at))
const sameCount = restSent.length === restSaved.length
restSent.forEach((item, index) => {
const attachment = restSaved[index]
if (!attachment || !item?.attachment) return
if (sameCount || item.attachment.name === attachment.name) next.set(item.key, attachment)
})
if (next.size === 0) return
items.value = items.value.map((item) => {
const attachment = next.get(item.key)
return attachment && item.status === 'done' ? { ...item, attachment } : item
})
}
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
function remove(key: number) {
controllers.get(key)?.abort()
@@ -233,6 +275,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachedCount,
addFiles,
addExisting,
applySavedAttachments,
markMissing,
remove,
reset,
+29
View File
@@ -0,0 +1,29 @@
import { inject, type InjectionKey } from 'vue'
// 시계 — 기다렸다 하는 일(임시 저장의 입력 멈춤 · 받는 사람 추천의 기다림)이 쓰는 시각과 타이머.
// 화면은 기본으로 브라우저 시계를 쓰고, 테스트는 손으로 돌리는 시계를 provide 해 시간을 정확히 넘긴다
// (test-utils/manual-clock.ts — 진짜 타이머를 기다리면 느리고, 부하에 따라 결과가 달라진다).
/** 타이머 손잡이 — 시계마다 모양이 다르므로 열어 보지 않는다 */
export type ClockTimer = ReturnType<typeof setTimeout> | number | symbol
export interface Clock {
/** 지금 시각 (밀리초) */
now: () => number
setTimeout: (callback: () => void, ms: number) => ClockTimer
clearTimeout: (timer: ClockTimer) => void
}
export const systemClock: Clock = {
now: () => Date.now(),
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
}
/** 화면 트리에 시계를 준다 — 주지 않으면 브라우저 시계 */
export const CLOCK: InjectionKey<Clock> = Symbol('clock')
/** 컴포넌트 setup 에서 부른다 */
export function useClock(): Clock {
return inject(CLOCK, systemClock)
}
@@ -0,0 +1,119 @@
import { onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { saveDraftDetached } from './useDraftSaving'
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
// - 주소 맞추기(sync): 저장할 때마다 /mail/compose?draft=<새 id> 로 바꿔 끼운다 — 새로고침 · 다시 열기가 마지막 저장본을 이어 쓴다.
// 새 메일 · 답장 · 전달 · 옛 draft 매개변수를 모두 이것으로 바꾼다. 스스로 바꾼 주소는 ownsKey 로 알아본다 —
// 쓰기 화면은 그 주소로 양식을 다시 채우지도, 떠나는 이동으로 보지도 않는다.
// 다른 이동이 진행 중이면 바꾸지 않는다 — 그 이동을 취소해 버린다: 쓰기 화면이 막는 때(canReplace — 나가기 확인 · 떠나기 전 저장 ·
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
export interface ComposeDraftLinkOptions {
/** 지금 주소를 바꿔도 되는가 — 쓰기 화면의 다른 흐름(나가기 확인 · 보내기 · 버리기 · 보낸 뒤 · 버린 뒤)이 아니다 */
canReplace: () => boolean
}
/** 이 화면이 주소에 쓰는 저장본 표시 — 쓰기 화면의 draftKeyOf 와 같은 모양 */
function draftKeyFor(id: string): string {
return `draft:${id}`
}
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
const router = useRouter()
const mailStore = useMailStore()
/**
* 이 양식의 주소 — 연 때의 쓰기 종류 · 원본, 저장한 뒤에는 바꿔 끼운 `draft:<id>`.
* 주소가 이것으로 바뀐 것은 이 화면이 스스로 바꾼 것이다
*/
let ownKey = ''
/** 이 화면이 바꿔 끼우는 중인 주소 — 다른 이동을 세는 데서 뺀다 */
let ownSyncPath: string | null = null
/**
* 라우터의 가드를 지난 다른 이동 수 — 화면이 바뀌기 전(지연 로딩한 화면을 받는 중 등)에 주소를 바꿔 끼우면 그 이동이 취소된다.
* 이 화면의 이동 가드(떠나기 전 저장 · 대화상자)를 기다리는 동안은 쓰기 화면의 canReplace 가 맡는다 (그 가드가 라우터 전체 가드보다 먼저 돈다)
*/
let navigationsPending = 0
const stopCountingStart = router.beforeEach((to) => {
if (to.fullPath !== ownSyncPath) navigationsPending += 1
})
const stopCountingEnd = router.afterEach((to, _from, failure) => {
const own = ownSyncPath !== null && to.fullPath === ownSyncPath
if (own) ownSyncPath = null
// 끝까지 간 이동이 있으면 그 앞의 이동은 남아 있지 않다(라우터가 앞 이동을 취소한다). 실패로 끝난 이동은 하나만 뺀다
if (!failure) navigationsPending = 0
else if (!own) navigationsPending = Math.max(0, navigationsPending - 1)
})
// 이동이 오류로 끝나면 afterEach 가 오지 않는다 — 세던 것을 비운다
const stopCountingError = router.onError(() => {
navigationsPending = 0
})
/** 이 화면이 스스로 바꾼(또는 연) 주소인가 */
function ownsKey(key: string): boolean {
return key === ownKey
}
/** 이 주소의 양식을 연다 — 이제 이 주소가 이 화면의 것이다 */
function adoptKey(key: string) {
ownKey = key
}
/** 주소를 /mail/compose?draft=<id> 로 바꿔 끼운다 — 이 화면이 스스로 바꾼 것으로 표시한다. 이미 그 주소면 그대로 */
function replace(id: string) {
ownKey = draftKeyFor(id)
const current = router.currentRoute.value
if (current.query.draft === id && Object.keys(current.query).length === 1) return
const target = router.resolve({ name: 'mail-compose', query: { draft: id } })
ownSyncPath = target.fullPath
void router.replace(target)
}
/** 저장한 저장본을 주소에 옮긴다 — 다른 이동이 진행 중이거나 쓰기 화면을 떠났으면 바꾸지 않는다 */
function sync(id: string | null) {
if (!id || !options.canReplace() || navigationsPending > 0) return
if (router.currentRoute.value.name !== 'mail-compose') return
replace(id)
}
/** 이어 쓸 저장본 — 이 탭에서 다시 저장해 바뀐 옛 id 면 마지막 저장본을 고르고 주소도 그것으로 바꾼다 */
function openDraft(requested: string): string {
const latest = mailStore.latestDraftId(requested)
if (latest !== requested) replace(latest)
return latest
}
/** 저장이 끝났다 — 옛 id → 새 id 를 적는다 (양식이 바뀐 뒤 끝난 저장도 — 탭 메모리 정보라 해가 없다) */
function recordSaved(previousId: string | null, saved: DraftSaved) {
if (previousId !== null) mailStore.recordDraftSuccessor(previousId, saved.id)
}
/**
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
*/
async function keepUnsent(payload: SendMessagePayload) {
const { draftId = null, ...draft } = payload
const saved = await saveDraftDetached(draft, draftId)
if (!saved) return
recordSaved(draftId, saved)
mailStore.syncCounts()
}
onBeforeUnmount(() => {
stopCountingStart()
stopCountingEnd()
stopCountingError()
})
return { ownsKey, adoptKey, sync, openDraft, recordSaved, keepUnsent }
}
@@ -0,0 +1,447 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { useDraftSaving, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
const PAYLOAD: DraftPayload = { to: [], cc: [], bcc: [], subject: '초안', body: '', attachments: [] }
function savedAs(id: string, at = '2026-10-27T06:12:00Z'): DraftSaved {
return { id, attachments: [], updatedAt: at }
}
/** 붙잡아 둔 응답 */
interface Held {
release: (result: MockResult) => void
}
describe('useDraftSaving (임시 저장 — 계약 2절 "임시 저장")', () => {
let scope: EffectScope
let clock: ManualClock
/** 저장할 바뀐 내용이 있다 — 저장이 끝나면(commit) 지운다 */
let changed: boolean
let commits: DraftSaved[]
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
scope = effectScope()
clock = createManualClock(1_000_000)
changed = false
commits = []
})
afterEach(() => {
scope.stop()
useToastStore().clear()
restoreApiAdapter()
})
function setup(handler: MockHandler, canSave = () => true) {
requests = installMockApi(handler)
const drafts = scope.run(() =>
useDraftSaving({
canSave,
hasChanges: () => changed,
prepare: (): DraftSaveRequest => ({
payload: PAYLOAD,
commit: (saved) => {
changed = false
commits.push(saved)
},
}),
clock,
}),
)
if (!drafts) throw new Error('scope')
return drafts
}
/** 바꿨다 — 화면이 하는 것처럼 touch */
function edit(drafts: ReturnType<typeof setup>) {
changed = true
drafts.touch()
}
const saves = () => requests.filter((request) => request.url.startsWith('/drafts'))
const calls = () => saves().map((request) => `${request.method} ${request.url}`)
/** 처음은 d1, 그다음 d2 … 를 돌려주는 서버 */
function counting(): MockHandler {
let next = 1
return (request) => {
if (!request.url.startsWith('/drafts')) return ok(null)
const id = `d${next}`
next += 1
return ok(savedAs(id), request.method === 'POST' ? 201 : 200)
}
}
it('입력이 3초 멈추면 저장한다 — 계속 쓰는 동안은 미룬다', async () => {
const drafts = setup(counting())
edit(drafts)
clock.advance(2_000)
edit(drafts)
clock.advance(2_999)
await flushPromises()
expect(calls()).toEqual([])
clock.advance(1)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
expect(drafts.status.value).toBe('saved')
expect(drafts.draftId.value).toBe('d1')
expect(drafts.savedAt.value).toBe('2026-10-27T06:12:00Z')
expect(commits).toHaveLength(1)
})
it('쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다 — 되돌려 바꾼 것이 없어지면 거둔다', async () => {
const drafts = setup(counting())
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
await flushPromises()
if (elapsed + 1_000 < 30_000) expect(calls()).toEqual([])
}
expect(calls()).toEqual(['POST /drafts'])
// 바꿨다가 되돌리면(바꾼 것이 없어짐) 30초 타이머도 거둔다
edit(drafts)
changed = false
drafts.touch()
clock.advance(60_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
})
it('저장하는 동안 바꾼 것은 그때(저장하는 동안 처음 바꾼 때)부터 30초를 센다', async () => {
let held: Held | null = null
let posts = 0
const drafts = setup((request) => {
if (request.method === 'POST' && posts === 0) {
posts += 1
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d2'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
// 저장이 끝나지 않은 채 25초 동안 쉬지 않고 쓴다 — 30초 타이머는 저장하는 동안 처음 바꾼 때(3초)부터
for (let elapsed = 0; elapsed < 25_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
}
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
changed = true
for (let elapsed = 0; elapsed < 5_000; elapsed += 1_000) {
edit(drafts)
clock.advance(1_000)
await flushPromises()
}
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it('바꾼 내용이 없으면 저장하지 않는다', async () => {
const drafts = setup(counting())
drafts.touch()
clock.advance(60_000)
await flushPromises()
expect(calls()).toEqual([])
expect(drafts.status.value).toBe('idle')
})
it('10초에 한 번까지 — 그다음 저장은 직전 저장을 시작한 지 10초 뒤, 직전 id 로 PUT', async () => {
const drafts = setup(counting())
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
// 첫 저장을 시작한 때(3초)부터 10초 — 13초에
clock.advance(6_999)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
clock.advance(1)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
expect(drafts.draftId.value).toBe('d2')
})
it('한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시 저장한다', async () => {
let held: Held | null = null
const drafts = setup((request) => {
if (request.method === 'POST') {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d2'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(drafts.status.value).toBe('saving')
// 저장하는 동안 또 바꾸고 3초 · 10초가 지나도 두 번째 요청은 나가지 않는다
edit(drafts)
clock.advance(15_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts'])
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
// 첫 저장의 commit 이 "바꿈"을 지웠지만, 그 뒤 또 바꿨다 — 화면은 다시 touch 한다
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it.each(['MAIL_008', 'RES_001'] as const)(
'PUT 이 %s 면 막지 않고 지금 내용을 POST 로 새로 남긴다 — 상태 replaced, 다음은 새 id 로',
async (code) => {
let posts = 0
const drafts = setup((request) => {
if (request.method === 'POST') {
posts += 1
return ok(savedAs(posts === 1 ? 'd1' : 'd9'), 201)
}
if (request.url === '/drafts/d1') return fail(code === 'MAIL_008' ? 409 : 404, code)
return ok(savedAs('d10'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1', 'POST /drafts'])
expect(drafts.status.value).toBe('replaced')
expect(drafts.draftId.value).toBe('d9')
expect(useToastStore().toasts).toEqual([])
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls().at(-1)).toBe('PUT /drafts/d9')
expect(drafts.status.value).toBe('saved')
},
)
it('자동 저장이 실패하면 알림 없이 failed — 다시 해 보면 나아질 실패는 15초 뒤 저절로 다시 한다', async () => {
let fails = 1
const drafts = setup(() => {
if (fails > 0) {
fails -= 1
return fail(503, 'MAIL_001')
}
return ok(savedAs('d1'), 201)
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(drafts.status.value).toBe('failed')
expect(drafts.lastError.value?.code).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
clock.advance(14_999)
await flushPromises()
expect(calls()).toHaveLength(1)
clock.advance(1)
await flushPromises()
expect(calls()).toHaveLength(2)
expect(drafts.status.value).toBe('saved')
})
it('내용을 고쳐야 하는 실패(MAIL_007 등)는 저절로 다시 하지 않는다 — 다시 바꾸면 저장한다', async () => {
const drafts = setup(() => fail(400, 'MAIL_007'))
edit(drafts)
clock.advance(3_000)
await flushPromises()
clock.advance(120_000)
await flushPromises()
expect(calls()).toHaveLength(1)
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls()).toHaveLength(2)
})
it('「임시 저장」은 바로 저장하고, 실패하면 사전 문구 알림을 띄운다', async () => {
const drafts = setup(() => fail(507, 'MAIL_003'))
edit(drafts)
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
expect(drafts.status.value).toBe('failed')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
})
it('「임시 저장」 — 아직 저장한 적 없으면 바꾼 것이 없어도 저장하고, 저장한 그대로면 요청하지 않는다', async () => {
const drafts = setup(counting())
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
await drafts.saveNow()
expect(calls()).toEqual(['POST /drafts'])
})
it('보내기 · 버리기 전 — suspend 로 멈추고 settle 로 진행 중인 저장이 끝나기를 기다린다', async () => {
let held: Held | null = null
const drafts = setup(() => {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
drafts.suspend()
let settled = false
void drafts.settle().then(() => {
settled = true
})
edit(drafts)
clock.advance(30_000)
await flushPromises()
expect(settled).toBe(false)
expect(calls()).toHaveLength(1)
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
expect(settled).toBe(true)
expect(drafts.draftId.value).toBe('d1')
// 멈춘 동안은 자동 저장하지 않는다
clock.advance(30_000)
await flushPromises()
expect(calls()).toHaveLength(1)
})
it('떠나기 전 — 남은 변경을 저장하고 성공 여부를 알려 준다 (저장한 그대로면 요청 없이 true)', async () => {
const drafts = setup(counting())
expect(await drafts.saveBeforeLeave()).toBe(true)
expect(calls()).toEqual([])
edit(drafts)
expect(await drafts.saveBeforeLeave()).toBe(true)
expect(calls()).toEqual(['POST /drafts'])
})
it('저장할 수 없는 때(canSave false)는 요청하지 않는다', async () => {
const drafts = setup(counting(), () => false)
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(await drafts.saveNow()).toBe(false)
expect(calls()).toEqual([])
})
it('reset — 이어 쓰는 저장본이면 그 id · 시각으로 시작하고, 늦게 끝난 앞 양식의 저장은 무시한다', async () => {
let held: Held | null = null
const drafts = setup((request) => {
if (request.method === 'POST') {
return new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
}
return ok(savedAs('d8'))
})
edit(drafts)
clock.advance(3_000)
await flushPromises()
drafts.reset({ id: 'd7', savedAt: '2026-10-26T01:00:00Z' })
expect(drafts.status.value).toBe('saved')
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
await flushPromises()
expect(drafts.draftId.value).toBe('d7')
expect(commits).toEqual([])
edit(drafts)
clock.advance(3_000)
await flushPromises()
expect(calls().at(-1)).toBe('PUT /drafts/d7')
})
it('reset(keepOriginal) — 원본을 지우지 않는다: 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT. 그동안 draftId 는 없다', async () => {
const drafts = setup(counting())
drafts.reset({ id: 'outlook-1', savedAt: '2026-10-26T01:00:00Z', keepOriginal: true })
expect(drafts.status.value).toBe('saved')
expect(drafts.draftId.value).toBeNull()
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
})
it('onSaved — 저장이 끝날 때마다 바꾼 id 와 새 저장본을 알린다 (양식이 바뀐 뒤 끝난 저장도)', async () => {
let held: Held | null = null
const seen: [string | null, string][] = []
requests = installMockApi((request) =>
request.method === 'PUT'
? new Promise<MockResult>((resolve) => {
held = { release: resolve }
})
: ok(savedAs('d1'), 201),
)
const drafts = scope.run(() =>
useDraftSaving({
canSave: () => true,
hasChanges: () => changed,
prepare: (): DraftSaveRequest => ({ payload: PAYLOAD, commit: () => (changed = false) }),
onSaved: (previousId, saved) => seen.push([previousId, saved.id]),
clock,
}),
)
if (!drafts) throw new Error('scope')
edit(drafts)
clock.advance(3_000)
await flushPromises()
edit(drafts)
clock.advance(10_000)
await flushPromises()
// PUT 이 나가 있는 동안 양식을 새로 채운다 — 늦게 끝난 저장도 알린다
drafts.reset()
;(held as Held | null)?.release(ok(savedAs('d2')))
await flushPromises()
expect(seen).toEqual([
[null, 'd1'],
['d1', 'd2'],
])
expect(drafts.draftId.value).toBeNull()
})
})
+352
View File
@@ -0,0 +1,352 @@
import { onScopeDispose, readonly, ref } from 'vue'
import { toApiError, type ApiError } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail, type MailRequestOptions } from './useMail'
import {
DRAFT_IDLE_SAVE_MS,
DRAFT_MAX_WAIT_MS,
DRAFT_MIN_SAVE_INTERVAL_MS,
DRAFT_RETRY_CODES,
DRAFT_RETRY_DELAYS_MS,
} from '@/shared/constants/mail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
// 쓰기 화면의 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장")
// - 자동 저장: 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 저장은 한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시.
// 쉬지 않고 쓰는 동안에도 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 30초가 지나면 저장한다.
// - 처음은 POST /drafts, 그다음은 PUT /drafts/{직전 id} — 응답의 id · 첨부로 바꿔 쓴다(prepare 가 준 commit).
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌으면(409 MAIL_008 · 임시보관함 밖으로 옮겨져 404 RES_001) 막지 않고 POST 로 새로 남긴다
// — 상태 'replaced' (임시보관함에 두 통이 남는다).
// - 자동 저장 실패는 알림 없이 상태(failed)로만 보이고, 다시 해 보면 나아질 실패(DRAFT_RETRY_CODES)는 조금 뒤 저절로 다시 한다.
// 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다 (인터셉터 — MAIL_008 · RES_001 은 위처럼 처리하므로 끈다).
// - 보내기 · 버리기 전에는 suspend 로 자동 저장을 멈추고 settle 로 진행 중인 저장을 기다린다 (draftId 가 최신이어야 한다).
export type DraftSaveStatus = 'idle' | 'saving' | 'saved' | 'failed' | 'replaced'
/** 저장을 시작한 까닭 — 알림을 띄울지(manual) 정한다 */
export type DraftSaveTrigger = 'auto' | 'manual' | 'leave'
/**
* 저장할 수 없는 내용이다 — 보내기 전 검사에 걸렸다 (예: 본문이 한도를 넘음 MAIL_007).
* 요청 없이 실패로 두고 저절로 다시 하지 않는다 — 내용이 바뀌면(touch) 다시 잰다
*/
export interface DraftSaveRejected {
rejected: ApiError
}
/** 저장 한 번 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수 */
export interface DraftSaveRequest {
payload: DraftPayload
/** 저장이 끝났다 — 응답의 첨부 id 로 바꿔 쓰고, 이 요청을 만든 때의 양식을 "바꾸지 않은" 기준으로 삼는다 */
commit: (saved: DraftSaved) => void
}
export interface DraftSavingOptions {
/** 지금 저장해도 되는가 — 편집기 준비 · 로그인 · 보내는 중 아님 등 */
canSave: () => boolean
/** 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다 */
hasChanges: () => boolean
/** 지금 내용으로 저장 요청을 만든다 — 만들 수 없으면 null, 저장할 수 없는 내용이면(사전 검사) rejected */
prepare: () => DraftSaveRequest | DraftSaveRejected | null
/** 저장이 실패했다 — 예: 찾지 못한 첨부(MAIL_004)를 표시한다 */
onFailed?: (error: ApiError, trigger: DraftSaveTrigger) => void
/**
* 저장이 끝났다 — previousId 는 이 저장이 바꾼(PUT 한) 저장본 id, 새로 만들었으면 null.
* 양식이 바뀐 뒤 끝난 저장도 부른다 (commit 은 부르지 않는다) — 예: 옛 id → 새 id 를 적어 둔다
*/
onSaved?: (previousId: string | null, saved: DraftSaved) => void
clock?: Clock
}
/** 이어 쓰는 저장본 — 쓰기 화면이 임시 저장본을 불러와 채웠을 때 */
export interface ExistingDraft {
id: string
/** 마지막으로 저장한 시각 (ISO 8601) */
savedAt: string
/**
* 원본을 지우지 않는다 — 첫 저장을 PUT 대신 새 저장본(POST)으로 하고, 그 뒤는 새 id 로 PUT 한다.
* 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(사전 검사 실패)가 있을 때 — PUT 하면 서버가 그 첨부째 원본을 지운다.
* 그동안 draftId 는 null 이다 (보내기도 원본을 가리키지 않는다)
*/
keepOriginal?: boolean
}
/** 「임시 저장」을 눌렀을 때 끄는 알림 — 이 둘은 새로 저장해 처리한다 */
const REPLACED_CODES: readonly ErrorCode[] = ['MAIL_008', 'RES_001']
function isRetryable(error: ApiError): boolean {
return !error.canceled && DRAFT_RETRY_CODES.includes(error.code)
}
type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
/**
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
*/
async function putOrCreate(
mail: DraftApi,
payload: DraftPayload,
id: string | null,
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
): Promise<{ saved: DraftSaved; replaced: boolean }> {
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
try {
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
} catch (error) {
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
}
}
/**
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
*/
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
try {
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
} catch {
return null
}
}
export function useDraftSaving(options: DraftSavingOptions) {
const mail = useMail()
const clock = options.clock ?? systemClock
/** 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로 */
const draftId = ref<string | null>(null)
const status = ref<DraftSaveStatus>('idle')
/** 마지막으로 저장한 시각 — 상태 글에 보인다 */
const savedAt = ref<string | null>(null)
/** 마지막 저장의 실패 (성공하면 비운다) */
const lastError = ref<ApiError | null>(null)
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게 */
let generation = 0
let inFlight: Promise<boolean> | null = null
let idleTimer: ClockTimer | null = null
/** 쉬지 않고 쓰는 동안의 저장 — 마지막 저장을 시작한 뒤 처음 바꾼 때 건다 (그 뒤로는 다시 걸지 않는다) */
let maxWaitTimer: ClockTimer | null = null
let retryTimer: ClockTimer | null = null
/** 마지막 저장을 시작한 시각 — 자동 저장은 10초에 한 번까지 */
let lastStartedAt = Number.NEGATIVE_INFINITY
let retryCount = 0
/** 저장하는 동안 자동 저장할 차례가 왔다 — 끝나면 다시 저장한다 */
let queued = false
let suspended = false
let stopped = false
function clearIdle() {
if (idleTimer !== null) clock.clearTimeout(idleTimer)
idleTimer = null
}
function clearMaxWait() {
if (maxWaitTimer !== null) clock.clearTimeout(maxWaitTimer)
maxWaitTimer = null
}
function clearRetry() {
if (retryTimer !== null) clock.clearTimeout(retryTimer)
retryTimer = null
}
/** 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 그때까지 미룬다 (입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다) */
function autosave() {
clearIdle()
if (suspended || stopped) return
if (inFlight) {
queued = true
return
}
if (!options.hasChanges()) return
const wait = lastStartedAt + DRAFT_MIN_SAVE_INTERVAL_MS - clock.now()
if (wait > 0) {
idleTimer = clock.setTimeout(autosave, wait)
return
}
void run('auto')
}
function onMaxWait() {
maxWaitTimer = null
autosave()
}
/**
* 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다 (계속 쓰는 동안은 미룬다).
* 다만 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다 — 그 타이머는 처음 바꾼 때 한 번만 건다.
* 바꾼 것이 없어졌으면(되돌림) 둘 다 거둔다.
*/
function touch() {
if (suspended || stopped) return
clearIdle()
if (!options.hasChanges()) {
clearMaxWait()
return
}
idleTimer = clock.setTimeout(autosave, DRAFT_IDLE_SAVE_MS)
if (maxWaitTimer === null) maxWaitTimer = clock.setTimeout(onMaxWait, DRAFT_MAX_WAIT_MS)
}
function scheduleRetry() {
clearRetry()
const delay = DRAFT_RETRY_DELAYS_MS[Math.min(retryCount, DRAFT_RETRY_DELAYS_MS.length - 1)] ?? 0
retryCount += 1
retryTimer = clock.setTimeout(() => {
retryTimer = null
autosave()
}, delay)
}
/** 저장 요청 — PUT 이 MAIL_008 · RES_001 이면 새 저장본으로. 「임시 저장」만 사전 문구 알림을 띄운다 */
function send(payload: DraftPayload, trigger: DraftSaveTrigger): Promise<{ saved: DraftSaved; replaced: boolean }> {
const manual = trigger === 'manual'
return putOrCreate(mail, payload, draftId.value, {
first: manual ? REPLACED_CODES : true,
fallback: manual ? undefined : true,
})
}
/** 저장 한 번 — 끝나면 성공 여부 */
async function run(trigger: DraftSaveTrigger): Promise<boolean> {
if (stopped || !options.canSave()) return false
const request = options.prepare()
if (!request) return false
clearIdle()
clearMaxWait()
clearRetry()
queued = false
if ('rejected' in request) {
// 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다. 저절로 다시 하지 않는다 (내용이 바뀌면 touch 가 다시 잡는다)
status.value = 'failed'
lastError.value = request.rejected
options.onFailed?.(request.rejected, trigger)
return false
}
const started = generation
lastStartedAt = clock.now()
status.value = 'saving'
// 이 저장이 바꿀 저장본 — send 가 이 id 로 PUT 한다(없으면 POST)
const previousId = draftId.value
const attempt = (async (): Promise<boolean> => {
try {
const { saved, replaced } = await send(request.payload, trigger)
options.onSaved?.(previousId, saved)
if (started !== generation) return false
draftId.value = saved.id
savedAt.value = saved.updatedAt
status.value = replaced ? 'replaced' : 'saved'
lastError.value = null
retryCount = 0
request.commit(saved)
return true
} catch (error) {
if (started !== generation) return false
const apiError = toApiError(error)
status.value = 'failed'
lastError.value = apiError
options.onFailed?.(apiError, trigger)
if (isRetryable(apiError) && !suspended && !stopped) scheduleRetry()
return false
}
})()
inFlight = attempt
const ok = await attempt
if (inFlight === attempt) inFlight = null
// 저장하는 동안 자동 저장 차례가 왔다 — 남은 변경을 다시 (10초 간격은 autosave 가 지킨다)
if (started === generation && queued) {
queued = false
autosave()
}
return ok
}
/** 진행 중인 저장이 끝나기를 기다린다 (보내기 · 버리기 전) */
async function settle(): Promise<void> {
while (inFlight) await inFlight
}
/**
* 「임시 저장」 — 진행 중인 저장을 기다린 뒤, 바꾼 것이 있거나 아직 한 번도 저장하지 않았으면 저장한다.
* 이미 저장한 그대로면 요청하지 않는다. 실패하면 사전 문구 알림(인터셉터)이 함께 뜬다.
*/
async function saveNow(): Promise<boolean> {
clearIdle()
await settle()
if (draftId.value !== null && !options.hasChanges()) return true
return run('manual')
}
/** 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) true */
async function saveBeforeLeave(): Promise<boolean> {
clearIdle()
await settle()
if (!options.hasChanges()) return true
return run('leave')
}
/** 자동 저장을 멈춘다 — 보내기 · 버리기 동안 */
function suspend() {
suspended = true
clearIdle()
clearMaxWait()
clearRetry()
}
/** 다시 자동 저장한다 — 보내기가 실패했을 때 등 */
function resume() {
if (stopped) return
suspended = false
touch()
}
/** 더 저장하지 않는다 — 보냈거나 버렸다 */
function stop() {
stopped = true
suspend()
}
/** 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 와 저장 시각으로 시작한다 */
function reset(existing: ExistingDraft | null = null) {
generation += 1
clearIdle()
clearMaxWait()
clearRetry()
inFlight = null
queued = false
suspended = false
stopped = false
lastStartedAt = Number.NEGATIVE_INFINITY
retryCount = 0
draftId.value = existing && !existing.keepOriginal ? existing.id : null
savedAt.value = existing?.savedAt ?? null
status.value = existing ? 'saved' : 'idle'
lastError.value = null
}
// 화면이 사라지면 더 저장하지 않는다 — 진행 중인 저장은 끝까지 받아 draftId 를 맞춘다
// (보내기를 누르고 바로 떠난 경우 — 보내기가 그 저장을 기다렸다가 새 draftId 로 보낸다)
onScopeDispose(stop)
return {
draftId: readonly(draftId),
status: readonly(status),
savedAt: readonly(savedAt),
lastError: readonly(lastError),
touch,
saveNow,
saveBeforeLeave,
settle,
suspend,
resume,
stop,
reset,
}
}
+65
View File
@@ -0,0 +1,65 @@
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
import { useMail } from './useMail'
import { useToast } from './useToast'
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장" · 계약 2절 "임시 저장")
// - 저장본을 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 } 이다. DELETE 는 이미 휴지통에 있는 메일을 영구 삭제한다 —
// 다른 창에서 먼저 휴지통으로 옮긴 저장본이 되돌릴 수 없게 지워지지 않게.
// - 휴지통 id 를 모르면 메일함을 먼저 불러온다. 그래도 모르면 지우지 않고 실패다 — 메일함 목록을 못 받았으면 그 오류,
// 받았는데 휴지통이 없으면 BIZ_001 을 알린다.
// - 옮겼으면 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + [되돌리기](임시보관함으로 — "임시보관함으로 되돌렸습니다.").
// 이미 없는 저장본(RES_001 — 다른 곳에서 보냈거나 지웠다)이면 버린 것과 같다 — 알리지 않는다.
/** moved — 옮겼다 · gone — 이미 없다(RES_001) · failed — 옮기지 못했다(알렸다) */
export type DraftTrashResult = 'moved' | 'gone' | 'failed'
export function useDraftTrash() {
const mailStore = useMailStore()
const { updateMessage } = useMail()
const toast = useToast()
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
let trash = mailStore.mailboxByRole('trash')
if (!trash) {
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
const loadError = await mailStore.loadMailboxes({ background: true })
trash = mailStore.mailboxByRole('trash')
if (!trash) {
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
return 'failed'
}
}
try {
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
return 'moved'
} catch (error) {
// 그 밖의 실패는 인터셉터가 알렸다
return toApiError(error).code === 'RES_001' ? 'gone' : 'failed'
}
}
/** 되돌리기 — 버린 저장본을 임시보관함으로 (PATCH { mailboxId }) */
async function restore(draftId: string, draftsMailboxId: string) {
if (await mailStore.moveMessage(draftId, draftsMailboxId)) toast.success(DRAFT_RESTORED_MESSAGE)
}
/** 저장본을 휴지통으로 옮기고 알린다 — 옮겼으면 [되돌리기] 알림. 옮겼거나 이미 없으면 개수를 서버 기준으로 다시 맞춘다 */
async function discard(draftId: string): Promise<DraftTrashResult> {
const result = await moveToTrash(draftId)
if (result === 'failed') return result
// 임시보관함 · 휴지통 개수를 서버 기준으로
mailStore.syncCounts()
if (result === 'moved') {
const draftsMailbox = mailStore.mailboxByRole('drafts')
toast.success(
DRAFT_DISCARDED_MESSAGE,
draftsMailbox ? { label: DRAFT_UNDO_LABEL, run: () => void restore(draftId, draftsMailbox.id) } : undefined,
)
}
return result
}
return { discard }
}
+45 -5
View File
@@ -1,11 +1,13 @@
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
import { computed, onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
// 묻기 전에 prepareLeave(임시 저장 등)를 기다린다 — 그 뒤에도 잃을 것이 남았을 때만 묻는다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
// guarding: 이동 가드가 준비 · 답을 기다리는 동안 true — 그동안 화면이 스스로 주소를 바꾸면(router.replace) 그 이동을 취소해 버린다.
export interface LeaveConfirmOptions {
/**
@@ -24,6 +26,16 @@ export interface LeaveConfirmOptions {
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
/**
* 묻기 전에 할 일 — 예: 남은 변경을 임시 저장한다. 끝난 뒤 active 를 다시 읽어 잃을 것이 남았을 때만 묻는다.
* 이동은 이것이 끝날 때까지 기다린다 (그사이 다른 이동이 오면 라우터가 앞 이동을 취소한다)
*/
prepareLeave?: () => Promise<unknown>
/**
* 묻는 중에 잃을 것이 없어졌을 때(active 가 false) 그대로 떠날지 — 예: 늦게 끝난 떠나기 전 저장(이제 잃을 것이 없다).
* 주지 않거나 false 면 그 이동을 취소한다 — 세션이 끝난 때처럼 다른 흐름(로그인 화면으로)이 이어받을 때
*/
leaveWhenSettled?: () => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
@@ -52,8 +64,34 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
resolve?.(leave)
}
function confirmLeave(): true | Promise<boolean> {
return options.active() ? ask() : true
/** 떠나기 전 준비 — 이동이 겹쳐도 한 번만 한다 */
let preparing: Promise<unknown> | null = null
function prepare(): Promise<unknown> {
if (!options.prepareLeave) return Promise.resolve()
if (!preparing) {
// 준비가 실패해도 이동 가드를 깨뜨리지 않는다 — 그다음 active 로 물을지 정한다
preparing = options
.prepareLeave()
.catch(() => undefined)
.finally(() => {
preparing = null
})
}
return preparing
}
/** 답을 기다리는 이동 가드 수 — 이동이 겹치면 여럿이다 */
const guards = ref<number>(0)
async function confirmLeave(): Promise<boolean> {
if (!options.active()) return true
guards.value += 1
try {
await prepare()
return options.active() ? await ask() : true
} finally {
guards.value -= 1
}
}
onBeforeRouteLeave(() => confirmLeave())
@@ -78,7 +116,7 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
if (!active && pending) answer(false)
if (!active && pending) answer(options.leaveWhenSettled?.() ?? false)
})
onBeforeUnmount(() => {
@@ -89,6 +127,8 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
return {
confirming: readonly(confirming),
/** 이동 가드가 준비(prepareLeave) · 답을 기다리는 중 */
guarding: computed(() => guards.value > 0),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
+77 -4
View File
@@ -8,13 +8,21 @@ import {
} from '@/shared/constants/mail'
import type {
Attachment,
Draft,
DraftPayload,
DraftSaved,
MailQuota,
MailSignature,
Mailbox,
MessageDetail,
MessageListQuery,
MessageSummary,
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
SendMessagePayload,
SendMessageResult,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -86,13 +94,71 @@ export function useMail() {
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
}
function updateMessage(id: string, changes: UpdateMessagePayload): Promise<MessageSummary> {
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes)
function updateMessage(
id: string,
changes: UpdateMessagePayload,
options?: MailRequestOptions,
): Promise<MessageSummary> {
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
}
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
async function deleteMessage(id: string): Promise<void> {
await api.delete(`/messages/${segment(id)}`)
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
await api.delete(`/messages/${segment(id)}`, options)
}
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
/** 이어 쓸 임시 저장본 — 없거나 초안이 아니면 404 RES_001 */
function getDraft(id: string, options?: MailRequestOptions): Promise<Draft> {
return api.get<Draft, Draft>(`/drafts/${segment(id)}`, options)
}
/** 처음 저장 — 201 DraftSaved. 본문 · 첨부는 발송과 같은 한도라 시간 한도도 발송과 같다 */
function createDraft(payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
return api.post<DraftSaved, DraftSaved>('/drafts', payload, { timeout: MAIL_SEND_TIMEOUT_MS, ...options })
}
/**
* 다시 저장 — 서버가 새 초안을 만들고 옛것을 지운다(응답의 id · attachments 로 바꿔 쓴다).
* 옛 초안이 다른 곳에서 먼저 바뀌었거나 지워졌으면 409 MAIL_008 — 부르는 쪽이 createDraft 로 새로 남긴다.
*/
function updateDraft(id: string, payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
return api.put<DraftSaved, DraftSaved>(`/drafts/${segment(id)}`, payload, {
timeout: MAIL_SEND_TIMEOUT_MS,
...options,
})
}
// ── 서명 (계약 2절 "서명") ─────────────────────────────
/** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
function getSignature(options?: MailRequestOptions): Promise<MailSignature> {
return api.get<MailSignature, MailSignature>('/mail-signature', options)
}
function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
return api.put<MailSignature, MailSignature>('/mail-signature', payload, options)
}
/** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise<SignatureImage> {
const form = new FormData()
form.append('file', file)
return api.post<SignatureImage, SignatureImage>('/mail-signature/images', form, options)
}
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
function listRecipientSuggestions(
query: RecipientSuggestionQuery,
options?: MailRequestOptions,
): Promise<PaginatedResult<RecipientSuggestion>> {
return api.get<PaginatedResult<RecipientSuggestion>, PaginatedResult<RecipientSuggestion>>(
'/recipient-suggestions',
{ ...options, params: query },
)
}
/**
@@ -160,5 +226,12 @@ export function useMail() {
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
getDraft,
createDraft,
updateDraft,
getSignature,
saveSignature,
uploadSignatureImage,
listRecipientSuggestions,
}
}
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import {
RECIPIENT_SEARCH_DELAY_MS,
useRecipientSuggestions,
} from '@/composables/useRecipientSuggestions'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
} from '@/test-utils/mock-api'
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
const VENDOR: RecipientSuggestion = { name: '협력사 담당', email: 'ops@vendor.example', source: 'recent' }
function page(items: RecipientSuggestion[], hasNext: boolean) {
return ok({ items, pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: null } })
}
describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () => {
let scope: EffectScope
let clock: ManualClock
let requests: MockRequest[]
let excluded: Set<string>
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
scope = effectScope()
clock = createManualClock()
excluded = new Set()
})
afterEach(() => {
scope.stop()
restoreApiAdapter()
})
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
requests = installMockApi((request) =>
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
)
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
const handle = setup(true)
handle.preload()
handle.preload()
await flushPromises()
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
excluded = new Set([DOYUN.email])
expect(handle.matchesFor('ㄷㅇ')).toEqual([])
})
it('받아 둔 목록이 전부면 서버에 다시 묻지 않는다', async () => {
const handle = setup(true)
handle.preload()
await flushPromises()
handle.search('vendor')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
})
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
const handle = setup(false, [VENDOR])
handle.preload()
await flushPromises()
handle.search('ven')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
await flushPromises()
expect(searches()).toEqual([])
clock.advance(1)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
handle.search('ㄷㅇ')
handle.search('vendor')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual(['vendor'])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
let failPreload = true
requests = installMockApi((request) => {
if (request.params.q !== undefined) return page([VENDOR], false)
if (failPreload) return fail(503, 'MAIL_001')
return page([DOYUN, JUNHO], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
handle.preload()
await flushPromises()
handle.search('ㄷㅇ')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
failPreload = false
handle.preload()
await flushPromises()
handle.preload()
await flushPromises()
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
})
it('추천을 못 받아도 조용히 넘어간다 (주소를 그대로 칠 수 있다)', async () => {
installMockApi(() => fail(503, 'MAIL_001'))
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
handle?.preload()
await flushPromises()
expect(handle?.matchesFor('도윤')).toEqual([])
})
})
@@ -0,0 +1,127 @@
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useMail } from './useMail'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail } from '@/shared/utils/mail-address'
import {
RECIPIENT_SUGGESTION_LIMIT,
filterRecipientSuggestions,
hasHangulJamo,
type RecipientMatch,
} from '@/shared/utils/recipient-match'
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
/** 처음 받아 두는 개수 — 계약의 최대 limit */
export const RECIPIENT_SUGGESTION_PRELOAD = 100
/** 서버에 다시 묻기 전에 기다리는 시간 */
export const RECIPIENT_SEARCH_DELAY_MS = 250
/** 서버가 받는 검색 글자 길이 (계약: q 1~100자) */
const SEARCH_QUERY_MAX_LENGTH = 100
export interface RecipientSuggestionsOptions {
/** 이미 넣은 주소 — 받는 사람 · 참조 · 숨은참조 모두 (정규화한 주소) */
excluded: () => ReadonlySet<string>
clock?: Clock
}
export interface RecipientSuggestionsHandle {
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
preload: () => void
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
matchesFor: (query: string) => RecipientMatch[]
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
search: (query: string) => void
}
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
const { listRecipientSuggestions } = useMail()
const clock = options.clock ?? systemClock
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
const pool = shallowRef<readonly RecipientSuggestion[]>([])
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
const complete = ref<boolean>(false)
/** 미리 받기 — 실패했으면 다음 preload 가 다시 받는다 */
let preloadState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
let searchTimer: ClockTimer | null = null
let controller: AbortController | null = null
/** 이미 물어본 글자 — 같은 글자를 두 번 묻지 않는다 */
const asked = new Set<string>()
function merge(items: readonly RecipientSuggestion[]) {
const known = new Set(pool.value.map((item) => normalizeEmail(item.email)))
const added = items.filter((item) => {
const key = normalizeEmail(item.email)
if (known.has(key)) return false
known.add(key)
return true
})
if (added.length > 0) pool.value = [...pool.value, ...added]
}
function preload() {
if (preloadState === 'loading' || preloadState === 'done') return
preloadState = 'loading'
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
.then((page) => {
preloadState = 'done'
merge(page.items)
complete.value = !page.pageInfo.hasNext
})
.catch(() => {
// 치는 대로 서버에 묻는다(search) — 다음에 칸에 초점이 오면 다시 받아 본다
preloadState = 'failed'
})
}
function matchesFor(query: string): RecipientMatch[] {
return filterRecipientSuggestions(query, pool.value, options.excluded())
}
function cancelSearch() {
if (searchTimer !== null) clock.clearTimeout(searchTimer)
searchTimer = null
controller?.abort()
controller = null
}
async function ask(query: string) {
searchTimer = null
asked.add(query)
controller = new AbortController()
const { signal } = controller
try {
const page = await listRecipientSuggestions(
{ q: query, limit: RECIPIENT_SUGGESTION_LIMIT * 2 },
{ silent: true, signal },
)
if (!signal.aborted) merge(page.items)
} catch {
// 보조 정보 — 못 받으면 받아 둔 목록만 보인다
asked.delete(query)
}
}
function search(query: string) {
cancelSearch()
const text = query.trim()
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
if (hasHangulJamo(text) || asked.has(text)) return
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
}
onScopeDispose(cancelSearch)
return { preload, matchesFor, search }
}