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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:00:09 +09:00
parent fbfad8d0ca
commit bbd88e707a
39 changed files with 1912 additions and 196 deletions
+1
View File
@@ -59,6 +59,7 @@ describe('에러 코드 사전', () => {
'MAIL_004', // 첨부 만료 — 쓰기 화면이 첨부를 "다시 올리기 필요"로 표시
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
'FILE_001', // 첨부 형식 사전 검사
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
]
+9
View File
@@ -115,6 +115,15 @@ const showErrorUI = (code: ErrorCode, message: string, traceId?: string) => {
useToastStore().show({ kind: 'error', message, traceId })
}
/**
* 알림을 끄고(silent) 보낸 요청의 실패를 나중에 알린다 — 그 실패를 칸 안에서 안내하려던 화면이 이미 사라졌을 때
* (예: 보내는 중에 쓰기 화면을 떠났다). 다른 오류 알림과 같은 길(showErrorUI — 같은 코드 중복 억제 · 세션 만료는 대화상자)이다.
*/
export function notifyApiError(error: ApiError): void {
if (error.canceled) return
showErrorUI(error.code, error.message, error.traceId)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -171,6 +171,18 @@ describe('useAttachmentUploads', () => {
stop()
})
it('올린 항목을 돌려준다 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
uploads.addFiles([file('회의록.pdf')])
const added = uploads.addExisting([CHECKLIST, { ...CHECKLIST, id: 'att3', name: '견적.pdf' }])
expect(added.map((item) => item.name)).toEqual(['체크리스트.xlsx', '견적.pdf'])
expect(uploads.items.value.slice(1).map((item) => item.key)).toEqual(added.map((item) => item.key))
stop()
})
it('허용 목록 밖의 원본 첨부는 실패(FILE_001)로 보이고 발송에서 빠진다', () => {
installMockApi(() => ok({}))
const { uploads, stop } = withUploads()
@@ -162,8 +162,9 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
* 전달 — 원본 메일의 첨부를 목록에 미리 올려 둔다 (다시 올리지 않고 id 를 그대로 보낸다, 계약 2절 "전달").
* 서버는 업로드와 같은 규칙으로 개수·확장자를, 발송 때 합계를 검사한다 — 걸리면 실패로 표시하고 발송에서 뺀다.
* (파일당 한도는 업로드에만 있어 보지 않는다)
* @returns 목록에 올린 항목 — 쓰기 화면이 "처음 채운 첨부"로 기억한다 (나가기 전 확인)
*/
function addExisting(attachments: readonly Attachment[]) {
function addExisting(attachments: readonly Attachment[]): UploadItem[] {
const running: AttachedSummary = { ...attached.value }
const added = attachments.map((attachment): UploadItem => {
const rejected = precheckAttachment(attachment, limitsFor('forward'), running)
@@ -184,6 +185,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
}
})
items.value = [...items.value, ...added]
return added
}
/**
@@ -0,0 +1,97 @@
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
export interface LeaveConfirmOptions {
/**
* 지금 앱 안에서 떠나면 잃는 것이 있다 — 이때만 묻는다. 반응형으로 읽는다.
* 묻는 중에 false 가 되면(세션이 끝남 등) 대화상자를 닫고 그 이동은 취소한다 — 그 뒤의 이동은 다른 흐름(로그인 화면으로)이 한다.
*/
active: () => boolean
/**
* 창 닫기 · 새로고침 확인(beforeunload)을 걸 때 — 주지 않으면 active 와 같다. 반응형으로 읽는다.
* 앱 안 이동은 묻지 않아도 창은 닫으면 안 되는 때가 있다 — 예: 보내는 중 (앱 안에서는 요청이 이어지고 결과를 토스트로 알리지만,
* 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳도 없다)
*/
unloadActive?: () => boolean
/**
* 같은 경로 안의 이동(쿼리만 바뀜)도 이 화면을 떠나는 것인가 — 예: 답장 쓰기 → 새 메일 쓰기는 양식을 새로 채운다.
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
*/
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
}
export function useLeaveConfirm(options: LeaveConfirmOptions) {
/** 대화상자를 띄우고 답을 기다리는 중 */
const confirming = ref<boolean>(false)
/** 기다리는 답 — 뒤로 가기를 거듭 눌러 이동이 겹쳐도 대화상자는 하나, 답도 하나다 */
let pending: Promise<boolean> | null = null
let resolvePending: ((leave: boolean) => void) | null = null
function ask(): Promise<boolean> {
if (!pending) {
pending = new Promise<boolean>((resolve) => {
resolvePending = resolve
})
confirming.value = true
}
return pending
}
/** 답한다 — true: 떠난다 · false: 남는다 (이동을 취소한다) */
function answer(leave: boolean) {
const resolve = resolvePending
pending = null
resolvePending = null
confirming.value = false
resolve?.(leave)
}
function confirmLeave(): true | Promise<boolean> {
return options.active() ? ask() : true
}
onBeforeRouteLeave(() => confirmLeave())
onBeforeRouteUpdate((to, from) => (options.leavesOnUpdate?.(to, from) ? confirmLeave() : true))
/** 창 닫기 · 새로고침 — 브라우저 기본 확인창 (문구는 브라우저가 정한다) */
function onBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault()
// 옛 브라우저(Chrome · Edge 119 미만)는 returnValue 를 채워야 묻는다
event.returnValue = true
}
let listening = false
function listenBeforeUnload(on: boolean) {
if (on === listening) return
listening = on
if (on) window.addEventListener('beforeunload', onBeforeUnload)
else window.removeEventListener('beforeunload', onBeforeUnload)
}
// 지킬 것이 있는 동안만 건다 — 늘 걸어 두면 일부 브라우저가 페이지를 뒤로 가기 캐시(bfcache)에 두지 않는다 (MDN 권고)
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
watch(options.active, (active) => {
if (!active && pending) answer(false)
})
onBeforeUnmount(() => {
listenBeforeUnload(false)
// 다른 이동이 이겨 화면이 사라진다 — 기다리던 이동은 취소로 끝낸다
if (pending) answer(false)
})
return {
confirming: readonly(confirming),
/** 계속 쓰기 — 이동을 취소한다 */
stay: () => answer(false),
/** 나가기 — 이동을 그대로 보낸다 */
leave: () => answer(true),
}
}