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
+63
View File
@@ -0,0 +1,63 @@
// 단위 테스트 전용 — 손으로 돌리는 시계. advance(ms) 를 부를 때만 시간이 흐르고 그 사이에 닿는 타이머를 차례로 부른다.
// 화면에는 CLOCK 으로 provide 한다: mount(Page, { global: { provide: { [CLOCK as symbol]: clock } } })
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
import type { Clock, ClockTimer } from '@/composables/useClock'
interface PendingTimer {
id: symbol
due: number
/** 같은 시각이면 먼저 건 것부터 */
order: number
callback: () => void
}
export interface ManualClock extends Clock {
/** 시간을 ms 만큼 넘긴다 — 그 사이에 닿는 타이머(넘기는 동안 새로 건 것 포함)를 시각 순서대로 부른다 */
advance: (ms: number) => void
/** 아직 부르지 않은 타이머 수 */
pendingCount: () => number
}
export function createManualClock(start = 0): ManualClock {
let current = start
let nextOrder = 0
const timers: PendingTimer[] = []
function nextDue(limit: number): PendingTimer | null {
let found: PendingTimer | null = null
for (const timer of timers) {
if (timer.due > limit) continue
if (!found || timer.due < found.due || (timer.due === found.due && timer.order < found.order)) found = timer
}
return found
}
return {
now: () => current,
setTimeout: (callback, ms) => {
const timer: PendingTimer = {
id: Symbol('timer'),
due: current + Math.max(0, ms),
order: nextOrder,
callback,
}
nextOrder += 1
timers.push(timer)
return timer.id
},
clearTimeout: (handle: ClockTimer) => {
const index = timers.findIndex((timer) => timer.id === handle)
if (index >= 0) timers.splice(index, 1)
},
advance: (ms) => {
const target = current + ms
for (let timer = nextDue(target); timer; timer = nextDue(target)) {
timers.splice(timers.indexOf(timer), 1)
current = Math.max(current, timer.due)
timer.callback()
}
current = target
},
pendingCount: () => timers.length,
}
}
@@ -122,3 +122,56 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
['text-decoration', 'underline', false],
]
/** 표본 그림 id — 서버가 만드는 UUID 모양 */
export const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
/**
* 그림(img) 속성 표본 — [속성, 값, 서버가 남기는가]. 계약 2절 "서식 본문"의 그림 예외 (서명 그림만).
* src 가 남지 않으면 그림째 지운다. width · alt 가 남지 않으면 그 속성만 지운다.
* ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.spec.ts · sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
* 서버는 여기에 더해 id 가 보내는 사람의 그림인지 본다 (표의 id 는 보내는 사람의 그림이라고 본다).
*/
export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'alt', value: string, kept: boolean])[] = [
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`, true],
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
['src', '', false],
['src', '/api/mail-signature/images/', false],
['src', '/api/mail-signature/images/not-a-uuid', false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
['src', `https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
['src', 'https://evil.example/t.png', false],
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
['width', '1', true],
['width', '96', true],
['width', '144', true],
['width', '600', true],
['width', ' 144 ', true],
['width', '0', false],
['width', '601', false],
['width', '1000', false],
['width', '096', false],
['width', '-1', false],
['width', '+5', false],
['width', '14.5', false],
['width', '144px', false],
['width', '50%', false],
['width', '1e2', false],
['width', '', false],
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
['alt', '', true],
['alt', '회사 로고', true],
['alt', 'ㄱ'.repeat(200), true],
['alt', 'ㄱ'.repeat(201), false],
]
+7
View File
@@ -28,6 +28,13 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
{
path: '/settings/mail/signature',
name: 'settings-mail-signature',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}