feat(frontend): 전역 셸·로그인·세션 처리와 웹메일 화면

확정된 디자인(docs/design/webmail)을 기준으로 만든 웹메일 화면이다.

- 앞으로 붙일 사내 모듈(캘린더·파일·ERP 등)을 위한 전역 앱 레일과 셸,
  Pretendard 자체 호스팅, 바다 청록 브랜드 토큰과 콘텐츠 열 토큰
- 로그인과 세션 처리: 401 이면 한 번 갱신한 뒤 다시 보내고, 실패하면 세션 만료 대화상자를 띄운다.
  로그아웃은 다른 탭에도 알리고, 로그아웃 뒤에 늦게 온 갱신 결과는 버린다
- 메일함(분할·목록 보기), 읽기(외부 HTML 은 sandbox iframe 으로만 그린다),
  쓰기(발송 키 규칙, 첨부 형식·크기·합계·개수 사전 검사, 답장·전달 초안 길이 맞춤)
- 오류는 코드로만 분기하고, 문구는 생성된 에러 사전에서 가져온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 01:31:59 +09:00
parent 71f2e01d83
commit 468a4377ee
104 changed files with 14136 additions and 747 deletions
+37
View File
@@ -0,0 +1,37 @@
// 단위 테스트 전용 — BroadcastChannel 대역. 같은 이름의 채널끼리 메시지를 주고받는다.
// 실제 브라우저처럼 보낸 객체 자신에게는 보내지 않고, 받는 쪽에는 다음 작업(task)으로 전달한다.
// 사용: vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) — "다른 탭"은 new FakeBroadcastChannel(이름) 으로 만든다.
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
export class FakeBroadcastChannel extends EventTarget {
private static readonly open = new Set<FakeBroadcastChannel>()
readonly name: string
private closed = false
constructor(name: string) {
super()
this.name = name
FakeBroadcastChannel.open.add(this)
}
postMessage(data: unknown): void {
if (this.closed) throw new DOMException('채널이 닫혔다', 'InvalidStateError')
for (const other of FakeBroadcastChannel.open) {
if (other === this || other.name !== this.name) continue
setTimeout(() => {
if (!other.closed) other.dispatchEvent(new MessageEvent('message', { data }))
}, 0)
}
}
close(): void {
this.closed = true
FakeBroadcastChannel.open.delete(this)
}
/** 테스트가 끝날 때 남은 채널을 모두 닫는다 */
static closeAll(): void {
FakeBroadcastChannel.open.forEach((channel) => channel.close())
}
}
+113
View File
@@ -0,0 +1,113 @@
// 단위 테스트 전용 — 중앙 axios 인스턴스의 어댑터(실제 전송부)만 바꿔 끼운다.
// 인터셉터(언래핑·에러 코드·401 재시도)는 그대로 거치므로 실제 흐름을 검증할 수 있다.
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
import {
AxiosError,
CanceledError,
type AxiosAdapter,
type AxiosProgressEvent,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
import api from '@/composables/useApi'
export interface MockReply {
status: number
data?: unknown
}
/** 'network' — 응답 없음(네트워크 단절) / 'cancel' — 호출부가 취소한 요청 */
export type MockResult = MockReply | 'network' | 'cancel'
export type MockHandler = (request: MockRequest) => MockResult | Promise<MockResult>
export interface MockRequest {
method: string
/** baseURL 을 뺀 경로 (예: /messages/m1) */
url: string
params: Record<string, unknown>
data: unknown
config: InternalAxiosRequestConfig
}
/** 표준 성공 응답 { success: true, data } */
export function ok(data: unknown, status = 200): MockReply {
return { status, data: { success: true, data } }
}
/** 표준 실패 응답 { success: false, error: { code, message, traceId } } */
export function fail(status: number, code: string, traceId = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'): MockReply {
return { status, data: { success: false, error: { code, message: '서버 메시지', traceId } } }
}
function parseBody(data: unknown): unknown {
if (typeof data !== 'string') return data
try {
return JSON.parse(data)
} catch {
return data
}
}
/** 업로드 진행 이벤트를 흉내 낸다 (onUploadProgress) */
export function emitUploadProgress(request: MockRequest, loaded: number, total: number) {
const event: AxiosProgressEvent = { loaded, total, bytes: loaded, lengthComputable: true }
request.config.onUploadProgress?.(event)
}
/**
* 가짜 어댑터를 끼우고 보낸 요청 목록을 돌려준다.
* 테스트가 끝나면 restoreApiAdapter() 로 원래 어댑터를 되돌린다.
*/
const originalAdapter = api.defaults.adapter
export function installMockApi(handler: MockHandler): MockRequest[] {
const requests: MockRequest[] = []
const adapter: AxiosAdapter = async (config) => {
const request: MockRequest = {
method: (config.method ?? 'get').toUpperCase(),
url: config.url ?? '',
params: (config.params as Record<string, unknown> | undefined) ?? {},
data: parseBody(config.data),
config,
}
requests.push(request)
const result = await handler(request)
if (result === 'cancel') throw new CanceledError(undefined, undefined, config)
if (result === 'network') {
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK, config)
}
const response: AxiosResponse = {
data: result.data ?? '',
status: result.status,
statusText: '',
headers: {},
config,
}
if (result.status >= 200 && result.status < 300) return response
throw new AxiosError(
`Request failed with status code ${result.status}`,
AxiosError.ERR_BAD_RESPONSE,
config,
null,
response,
)
}
api.defaults.adapter = adapter
return requests
}
export function restoreApiAdapter() {
api.defaults.adapter = originalAdapter
}
/** 대기 중인 Promise 들이 끝나도록 한 번 양보한다 */
export function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
+24
View File
@@ -0,0 +1,24 @@
// 단위 테스트 전용 — 앱과 같은 이름의 라우트를 가진 메모리 라우터 (화면은 빈 컴포넌트)
import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
const Empty: FunctionalComponent = () => h('div')
export function createTestRouter(): Router {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: Empty },
{ path: '/login', name: 'login', component: Empty },
{ path: '/mail', name: 'mail', component: Empty, meta: { requiresAuth: true } },
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { requiresAuth: true } },
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { requiresAuth: true } },
{
path: '/mail/:mailboxId/:messageId',
name: 'mail-message',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}