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
-27
View File
@@ -1,27 +0,0 @@
<script setup lang="ts">
// 홈 페이지 — 템플릿 기본 진입점
import { ref } from 'vue'
const message = ref<string>('You did it!')
</script>
<template>
<main class="home">
<h1>{{ message }}</h1>
<p>
Visit
<a
href="https://vuejs.org/"
target="_blank"
rel="noopener"
>vuejs.org</a>
to read the documentation
</p>
</main>
</template>
<style scoped>
.home {
padding: 2rem;
}
</style>
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import LoginPage from './LoginPage.vue'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
let clock = Date.UTC(2026, 9, 26, 5, 0, 0)
describe('LoginPage', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
async function mountLogin(path = '/login') {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(LoginPage, { global: { plugins: [pinia, router] } })
return { wrapper, router }
}
async function submit(wrapper: Awaited<ReturnType<typeof mountLogin>>['wrapper'], password: string) {
await wrapper.get('#login-email').setValue(USER.email)
await wrapper.get('#login-password').setValue(password)
await wrapper.get('form').trigger('submit')
await flushPromises()
}
it('로그인 버튼은 진짜 submit 버튼이다 (Enter 로 제출된다)', async () => {
const { wrapper } = await mountLogin()
const button = wrapper.get('form button')
expect(button.attributes('type')).toBe('submit')
expect(button.text()).toBe('로그인')
})
it('자격 증명이 틀리면(AUTH_004) 사전 문구를 폼 안에 보이고 전역 알림은 띄우지 않는다', async () => {
installMockApi(() => fail(401, 'AUTH_004'))
const { wrapper } = await mountLogin()
await submit(wrapper, 'wrong')
expect(wrapper.get('[role="alert"]').text()).toBe(ERROR_CODE_MESSAGES.AUTH_004)
expect(wrapper.get('#login-password').attributes('aria-invalid')).toBe('true')
expect((wrapper.get('#login-password').element as HTMLInputElement).value).toBe('')
expect(useToastStore().toasts).toEqual([])
})
it('성공하면 사용자를 저장하고 redirect 로 돌아간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=/mail/inbox/m1')
await submit(wrapper, 'secret')
expect(useAuthStore().user).toEqual(USER)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('redirect 가 앱 밖 주소면 무시하고 메일로 간다', async () => {
installMockApi(() => ok(USER))
const { wrapper, router } = await mountLogin('/login?redirect=//evil.example')
await submit(wrapper, 'secret')
expect(router.currentRoute.value.name).toBe('mail')
})
it('응답이 올 때까지 다시 보낼 수 없다 — 버튼을 막고, Enter 로 한 번 더 제출해도 요청은 하나다 (계약 1절)', async () => {
let releaseLogin: (result: MockResult) => void = () => undefined
const loginGate = new Promise<MockResult>((resolve) => {
releaseLogin = resolve
})
const requests = installMockApi(() => loginGate)
const { wrapper } = await mountLogin()
await wrapper.get('#login-email').setValue(USER.email)
await wrapper.get('#login-password').setValue('wrong')
const form = wrapper.get('form')
const button = wrapper.get('form button')
await form.trigger('submit')
await flushPromises()
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('aria-busy')).toBe('true')
expect(button.text()).toBe('로그인하는 중…')
// 비밀번호 칸에서 Enter — 브라우저는 막힌 기본 버튼이면 제출하지 않지만, 제출 이벤트가 와도 무시한다
await wrapper.get('#login-password').trigger('keydown', { key: 'Enter' })
await form.trigger('submit')
await flushPromises()
expect(requests.filter((request) => request.url === '/auth/login')).toHaveLength(1)
releaseLogin(fail(401, 'AUTH_004'))
await flushPromises()
expect(button.attributes('disabled')).toBeUndefined()
expect(button.attributes('aria-busy')).toBe('false')
expect(button.text()).toBe('로그인')
})
})
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
// 로그인 (디자인 원본 Login) — 직원 계정 = 메일 계정
// 실패 문구는 에러 코드 사전(AUTH_004)에서만 온다. 계정 없음·비밀번호 틀림을 구분하지 않는다.
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import { isApiError } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import { APP_NAME, COMPANY_MAIL_DOMAIN } from '@/shared/constants/brand'
import { toSafeRedirect } from '@/shared/utils/redirect'
const route = useRoute()
const router = useRouter()
const { login } = useAuth()
const email = ref<string>('')
const password = ref<string>('')
const submitting = ref<boolean>(false)
/** 자격 증명 불일치(AUTH_004) 안내 — 사전 문구 그대로 */
const credentialError = ref<string | null>(null)
const passwordRef = ref<HTMLInputElement | null>(null)
async function onSubmit() {
if (submitting.value) return
submitting.value = true
credentialError.value = null
try {
await login({ email: email.value.trim(), password: password.value })
await router.replace(toSafeRedirect(route.query.redirect) ?? { name: 'mail' })
} catch (error) {
if (isApiError(error) && error.code === 'AUTH_004') {
credentialError.value = error.message
password.value = ''
passwordRef.value?.focus()
}
// 그 밖의 실패(시도 과다 REQ_001 · 메일 엔진 장애 MAIL_001 등)는 인터셉터가 알렸다
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="login">
<section
class="login__brand"
:aria-label="`${APP_NAME} 소개`"
>
<!-- 흰 로고 — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. 원본 960×459 -->
<img
:src="logoFull"
:alt="APP_NAME"
width="960"
height="459"
class="login__logo"
>
<div class="login__pitch">
<p class="login__headline">
사내 업무를<br>한 계정으로
</p>
<p class="login__lead">
메일부터 시작해 캘린더, 파일 저장소, ERP까지 같은 계정으로 이어집니다.
</p>
</div>
</section>
<main class="login__main">
<div class="login__panel">
<div class="login__heading">
<h1 class="login__title">
로그인
</h1>
<p class="login__subtitle">
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
</p>
</div>
<p
v-if="credentialError"
id="login-error"
class="login__error"
role="alert"
>
<AppIcon
name="alert-circle"
class="login__error-icon"
/>
<span>{{ credentialError }}</span>
</p>
<form
class="login__form"
@submit.prevent="onSubmit"
>
<div class="login__field">
<label
for="login-email"
class="login__label"
>메일 주소</label>
<input
id="login-email"
v-model="email"
type="email"
name="email"
autocomplete="username"
:placeholder="`name@${COMPANY_MAIL_DOMAIN}`"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError ? 'login-error' : undefined"
>
</div>
<div class="login__field">
<label
for="login-password"
class="login__label"
>비밀번호</label>
<input
id="login-password"
ref="passwordRef"
v-model="password"
type="password"
name="password"
autocomplete="current-password"
placeholder="비밀번호 입력"
required
:class="['login__input', { 'is-invalid': credentialError }]"
:aria-invalid="credentialError ? 'true' : undefined"
:aria-describedby="credentialError ? 'login-error' : undefined"
>
</div>
<button
type="submit"
class="login__submit"
:disabled="submitting"
:aria-busy="submitting"
>
{{ submitting ? '로그인하는 중…' : '로그인' }}
</button>
</form>
<p class="login__help">
비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.
</p>
<p class="login__note">
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
</p>
</div>
</main>
</div>
</template>
<style scoped>
.login {
min-height: 100vh;
display: flex;
flex-wrap: wrap;
background: var(--color-surface);
color: var(--color-text);
}
.login__brand {
flex: 1 1 25rem;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-56);
padding: var(--space-44) var(--space-48) var(--space-48);
background: var(--color-brand-surface);
color: var(--color-on-brand-text);
}
.login__logo {
display: block;
width: 15rem;
height: auto;
}
.login__pitch {
display: flex;
flex-direction: column;
gap: var(--space-14);
max-width: 26.25rem;
}
.login__headline {
margin: 0;
font-size: var(--font-size-display);
line-height: 1.32;
font-weight: 700;
letter-spacing: var(--letter-spacing-display);
color: var(--color-on-brand);
}
.login__lead {
margin: 0;
font-size: var(--font-size-body);
line-height: 1.65;
color: var(--color-on-brand-muted);
}
.login__main {
flex: 999 1 30rem;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-48) var(--space-24);
}
.login__panel {
width: 100%;
max-width: 22.5rem;
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.login__heading {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.login__title {
margin: 0;
font-size: var(--font-size-heading-lg);
line-height: 1.35;
font-weight: 700;
letter-spacing: var(--letter-spacing-heading);
}
.login__subtitle {
margin: 0;
color: var(--color-text-secondary);
}
.login__error {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 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);
}
.login__error-icon {
margin-top: var(--space-1);
}
.login__form {
display: flex;
flex-direction: column;
gap: var(--space-14);
}
.login__field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.login__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-strong);
}
.login__input {
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-body);
}
.login__input.is-invalid {
border-color: var(--color-danger);
}
.login__submit {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--size-touch);
margin-top: var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-body);
font-weight: 600;
}
.login__submit:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.login__submit:disabled {
opacity: 0.7;
cursor: progress;
}
.login__help {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.login__note {
margin: 0;
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-small);
line-height: 1.6;
color: var(--color-text-muted);
}
</style>
+555
View File
@@ -0,0 +1,555 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
const ORIGINAL: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: '메일 서버 이전 일정 공유',
preview: '',
receivedAt: '2026-10-26T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
bcc: [],
replyTo: [],
sentAt: null,
messageId: '<m1@arasystem.it.kr>',
references: [],
htmlBody: null,
textBody: '김아라님, 안녕하세요.',
hasBlockedImages: false,
attachments: [],
}
describe('ComposePage', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
vi.useFakeTimers({ toFake: ['Date'] })
clock += 60_000
vi.setSystemTime(clock)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
})
/** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
async function mountCompose(path: string, handler: MockHandler, attach = false) {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: attach ? document.body : undefined,
global: { plugins: [pinia, router] },
})
await flushPromises()
return { wrapper, router, requests }
}
function chips(wrapper: VueWrapper, inputId: string): string[] {
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
(chip) => chip.textContent ?? '',
)
}
function sendRequest(requests: MockRequest[]) {
return requests.find((request) => request.method === 'POST' && request.url === '/messages')
}
const replyApi: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
it('답장(?replyTo=)이면 받는 사람 · "Re:" 제목 · 인용 본문을 채운다', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
expect(wrapper.get('h1').text()).toBe('답장')
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Re: 메일 서버 이전 일정 공유',
)
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
expect(body).toContain('──────────')
expect(body).toContain('이도윤 <doyun.lee@arasystem.it.kr> 작성:')
expect(body).toContain('> 김아라님, 안녕하세요.')
})
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1&all=1', replyApi)
expect(wrapper.get('h1').text()).toBe('전체 답장')
expect(chips(wrapper, 'compose-cc')).toEqual(['인프라팀 <infra@arasystem.it.kr>'])
})
it('보내면 계약대로 발송하고(inReplyTo 포함) 성공 토스트 후 보던 메일함으로 돌아간다', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(sendRequest(requests)?.data).toEqual({
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
cc: [],
bcc: [],
subject: 'Re: 메일 서버 이전 일정 공유',
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
attachments: [],
inReplyTo: 'm1',
})
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
it('형식이 틀린 주소가 있으면 보내지 않고 MAIL_002 문구를 보인다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
const input = wrapper.get('#compose-to')
await input.setValue('kim@arasystem')
await input.trigger('keydown', { key: 'Enter' })
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
expect(sendRequest(requests)).toBeUndefined()
})
it('받는 사람 없이 보내면 보내지 않고 같은 안내를 보인다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
expect(sendRequest(requests)).toBeUndefined()
})
it('서버가 받는 사람을 거부하면(MAIL_002) 토스트 없이 같은 문구로 알리고 쓴 내용은 둔다', async () => {
const { wrapper } = await mountCompose('/mail/compose', ({ url }) =>
url === '/messages' ? fail(400, 'MAIL_002') : ok({}),
)
const input = wrapper.get('#compose-to')
await input.setValue('nobody@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('#compose-subject').setValue('안녕하세요')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
expect(useToastStore().toasts).toEqual([])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
})
describe('전달 (?forward=)', () => {
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const ESTIMATE = { id: 'att2', name: '견적.pdf', type: 'application/pdf', size: 1000 }
const forwardApi: MockHandler = ({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') {
return ok({ ...ORIGINAL, attachments: [CHECKLIST, ESTIMATE] })
}
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
function attachmentNames(wrapper: VueWrapper): string[] {
return wrapper.findAll('.upload-item__name').map((item) => item.text())
}
it('"Fwd:" 제목 · 원문 · 원본 첨부를 채우고, 받는 사람은 비워 둔다', async () => {
const { wrapper } = await mountCompose('/mail/compose?forward=m1', forwardApi)
expect(wrapper.get('h1').text()).toBe('전달')
expect(chips(wrapper, 'compose-to')).toEqual([])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Fwd: 메일 서버 이전 일정 공유',
)
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
expect(body).toContain('전달된 메일')
expect(body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
expect(body).toContain('김아라님, 안녕하세요.')
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
})
it('원본 첨부 id 를 그대로 보내고 inReplyTo 는 넣지 않는다 — 뺀 첨부는 보내지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', forwardApi)
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
expect(payload?.attachments).toEqual([{ id: 'att2', name: '견적.pdf' }])
expect(payload?.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
expect(payload).not.toHaveProperty('inReplyTo')
// 다시 올리지 않는다
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(false)
})
})
it('발송이 MAIL_004 면 첨부 전체를 "찾지 못함"으로 표시하고, 빼기 전까지 보내지 않는다 — 전달 원본뿐이면 다시 올릴 수 없어 빼기 버튼으로 초점을 옮긴다', async () => {
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const { wrapper, requests } = await mountCompose(
'/mail/compose?forward=m1',
({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [CHECKLIST] })
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_004')
return ok({})
},
true,
)
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('form').trigger('submit')
await flushPromises()
const item = wrapper.get('.upload-item')
expect(item.classes()).toContain('upload-item--failed')
expect(item.get('.upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_004)
expect(useToastStore().toasts).toEqual([])
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
const removeButton = wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]')
expect(document.activeElement).toBe(removeButton.element)
// 찾지 못한 첨부를 빼면 다시 보낼 수 있다
await removeButton.trigger('click')
expect(wrapper.get('.compose__send').attributes('disabled')).toBeUndefined()
expect(requests.filter((request) => request.url === '/messages' && request.method === 'POST')).toHaveLength(1)
wrapper.unmount()
})
it('발송이 MAIL_004 인데 올린 파일이 있으면 바로 다시 고를 수 있게 "파일 첨부" 버튼으로 초점을 옮긴다', async () => {
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
const { wrapper } = await mountCompose(
'/mail/compose?forward=m1',
({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [CHECKLIST] })
if (method === 'POST' && url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 10 }, 201)
}
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_004')
return ok({})
},
true,
)
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', {
value: [new File(['x'], '회의록.pdf')],
configurable: true,
})
await picker.trigger('change')
await flushPromises()
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.findAll('.upload-item--failed')).toHaveLength(2)
expect(document.activeElement).toBe(wrapper.get('.compose__attach').element)
wrapper.unmount()
})
it('받는 사람이 합계 100명을 넘으면 보내지 않고 MAIL_002 문구를 보인다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
const input = wrapper.get('#compose-to')
const many = Array.from({ length: 101 }, (_, index) => `user${index}@arasystem.it.kr`).join(', ')
await input.setValue(many)
await input.trigger('blur')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(chips(wrapper, 'compose-to')).toHaveLength(101)
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_002)
expect(sendRequest(requests)).toBeUndefined()
})
it('첨부 안내 문구는 서버가 알려 준 파일당 · 합계 한도로 만든다', async () => {
useMailStore().quota = {
usedBytes: 0,
limitBytes: null,
attachmentMaxBytes: 10 * 1024 * 1024,
attachmentTotalMaxBytes: 30 * 1024 * 1024,
}
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 문서, 이미지, 압축 파일',
)
})
it('한도를 아직 못 받았으면 안내 문구는 대체값(파일당 20MB · 합계 25MB)으로 만든다', async () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(useMailStore().quota).toBeNull()
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일',
)
})
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 붙일 때 막는다 — 전달 원본도 세고, 넘는 파일은 올리지 않고 빼고 보낸다', async () => {
const MB = 1024 * 1024
useMailStore().quota = {
usedBytes: 0,
limitBytes: null,
attachmentMaxBytes: 20 * MB,
attachmentTotalMaxBytes: 15 * MB,
}
const PHOTOS = { id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 12 * MB }
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', ({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [PHOTOS] })
if (method === 'POST' && url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
}
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
return ok({})
})
const sized = (name: string, size: number) => {
const chosen = new File(['x'], name)
Object.defineProperty(chosen, 'size', { value: size })
return chosen
}
// 원본 12MB + 회의록 2MB = 14MB 는 통과, 도면 5MB 를 더하면 19MB 라 막힌다
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', {
value: [sized('회의록.pdf', 2 * MB), sized('도면.pdf', 5 * MB)],
configurable: true,
})
await picker.trigger('change')
await flushPromises()
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
expect.arrayContaining(['upload-item--done']),
expect.arrayContaining(['upload-item--done']),
expect.arrayContaining(['upload-item--failed']),
])
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(
ERROR_CODE_MESSAGES.FILE_002,
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
expect(payload?.attachments).toEqual([
{ id: 'att1', name: '현장사진.zip' },
{ id: 'up1', name: '회의록.pdf' },
])
})
it('보내기 버튼은 submit 이고, 제목에서 Enter 를 눌러도 보내지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.get('.compose__send').attributes('type')).toBe('submit')
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
wrapper.get('#compose-subject').element.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(sendRequest(requests)).toBeUndefined()
})
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
function sendRequests(requests: MockRequest[]): MockRequest[] {
return requests.filter((request) => request.method === 'POST' && request.url === '/messages')
}
function sentKeys(requests: MockRequest[]): unknown[] {
return sendRequests(requests).map((request) => request.config.headers.get('Idempotency-Key'))
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
async function addRecipient(wrapper: VueWrapper, email: string) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
/** 보내기 응답을 차례로 돌려준다 — 그 밖의 요청은 답장 원문 등 */
function sendSequence(outcomes: MockResult[]): MockHandler {
return (request) =>
request.method === 'POST' && request.url === '/messages'
? (outcomes.shift() ?? ok({ id: 'sent-1' }, 201))
: replyApi(request)
}
// 키는 성공과 MAIL_005 때만 바꾼다 — 아래 실패는 실제로 나갔거나 아직 처리 중일 수 있어 같은 키로 다시 보낸다
it.each([
['응답 없음(시간 초과·네트워크 오류)', 'network', 'SYS_001'],
['503 MAIL_001 (엔진 장애)', fail(503, 'MAIL_001'), 'MAIL_001'],
['429 REQ_001 (발송 한도·같은 키 처리 중)', fail(429, 'REQ_001'), 'REQ_001'],
['413 FILE_002 (첨부 합계 초과)', fail(413, 'FILE_002'), 'FILE_002'],
['403 AUTH_003 (보낼 권한 없음·출처 불일치)', fail(403, 'AUTH_003'), 'AUTH_003'],
['400 MAIL_006 (첨부 20개 초과 — 붙일 때 미리 막지만 서버가 마지막에 본다)', fail(400, 'MAIL_006'), 'MAIL_006'],
] as const)(
'%s — 사전 문구로 알리고 쓴 내용·키를 그대로 둔다. 자동으로 다시 보내지 않고, 다시 누르면 같은 키다',
async (_label, failure, code) => {
const { wrapper, router, requests } = await mountCompose(
'/mail/compose?replyTo=m1',
sendSequence([failure, ok({ id: 'sent-1' }, 201)]),
)
await submit(wrapper)
expect(sendRequests(requests)).toHaveLength(1)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
ERROR_CODE_MESSAGES[code],
])
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Re: 메일 서버 이전 일정 공유',
)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
expect(useAuthStore().isLoggedIn).toBe(true)
// 사용자가 잠시 뒤 다시 누른다 — 이미 나간 메일이면 서버가 처음 결과를 돌려준다
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toHaveLength(2)
expect(keys[0]).toMatch(UUID_V4)
expect(keys[1]).toBe(keys[0])
},
)
it('409 MAIL_005(같은 키·다른 내용)면 사전 문구로 알리고 쓴 내용은 둔 채 키를 바꾼다 — 다시 누르면 새 메일로 나간다', async () => {
const { wrapper, router, requests } = await mountCompose(
'/mail/compose?replyTo=m1',
sendSequence([fail(409, 'MAIL_005'), ok({ id: 'sent-2' }, 201)]),
)
await submit(wrapper)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
ERROR_CODE_MESSAGES.MAIL_005,
])
expect(chips(wrapper, 'compose-to')).toEqual(['이도윤 <doyun.lee@arasystem.it.kr>'])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
'Re: 메일 서버 이전 일정 공유',
)
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
await submit(wrapper)
const [first, second] = sentKeys(requests)
expect(first).toMatch(UUID_V4)
expect(second).toMatch(UUID_V4)
expect(second).not.toBe(first)
expect(router.currentRoute.value.fullPath).toBe('/mail')
})
it('로그인 갱신(401 → /auth/refresh) 뒤 다시 보낸 요청도 같은 키다', async () => {
let refreshed = false
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) => {
if (request.url === '/auth/refresh') {
refreshed = true
return ok(USER)
}
if (request.method === 'POST' && request.url === '/messages') {
return refreshed ? ok({ id: 'sent-1' }, 201) : fail(401, 'AUTH_001')
}
return replyApi(request)
})
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toHaveLength(2)
expect(keys[1]).toBe(keys[0])
})
it('발송에 성공하면 키를 바꾼다 — 같은 화면에서 또 보내면 다른 메일이다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await submit(wrapper)
await submit(wrapper)
const [first, second] = sentKeys(requests)
expect(first).toMatch(UUID_V4)
expect(second).toMatch(UUID_V4)
expect(second).not.toBe(first)
})
it('쓰기 화면을 새로 열거나 같은 화면에서 쓰기 종류가 바뀌면 새 키를 쓴다', async () => {
const failing: MockHandler = (request) =>
request.method === 'POST' && request.url === '/messages'
? fail(503, 'MAIL_001')
: replyApi(request)
const first = await mountCompose('/mail/compose?replyTo=m1', failing)
await submit(first.wrapper)
// 같은 화면을 재사용한 채 답장 → 새 메일로 바뀐다 (양식을 새로 채운다)
await first.router.push('/mail/compose')
await flushPromises()
await addRecipient(first.wrapper, 'doyun.lee@arasystem.it.kr')
await submit(first.wrapper)
const [replyKey, newMailKey] = sentKeys(first.requests)
first.wrapper.unmount()
const reopened = await mountCompose('/mail/compose?replyTo=m1', failing)
await submit(reopened.wrapper)
const [reopenedKey] = sentKeys(reopened.requests)
expect(replyKey).toMatch(UUID_V4)
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
})
})
})
+715
View File
@@ -0,0 +1,715 @@
<script setup lang="ts">
// 메일 쓰기 (디자인 원본 Compose) — /mail/compose
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 인용 본문을 채운다.
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 본문 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
import { useMail } from '@/composables/useMail'
import { useToast } from '@/composables/useToast'
import {
COMPOSE_TITLES,
MAIL_ATTACHMENT_ACCEPT,
MAIL_BODY_MAX_LENGTH,
MAIL_MAX_ATTACHMENTS,
MAIL_MAX_RECIPIENTS,
MAIL_SENT_ACTION_LABEL,
MAIL_SENT_MESSAGE,
MAIL_SUBJECT_MAX_LENGTH,
type ComposeMode,
} from '@/shared/constants/mail'
import type { SendMessagePayload } from '@/shared/types/mail'
import {
createIdempotencyKey,
shouldRenewSendKey,
type SendOutcome,
} from '@/shared/utils/idempotency-key'
import { formatAddress, tokenFromAddress, type RecipientToken } from '@/shared/utils/mail-address'
import { attachmentHint } from '@/shared/utils/mail-attachment'
import { buildForwardDraft } from '@/shared/utils/mail-forward'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/** 받는 사람 오류 안내 문단 — 세 입력칸이 함께 가리킨다 */
const RECIPIENT_ERROR_ID = 'compose-recipient-error'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const { getMessage, sendMessage } = useMail()
const toast = useToast()
const {
items: uploadItems,
isUploading,
hasMissing,
uploaded,
addFiles,
addExisting,
markMissing,
remove: removeUpload,
reset: resetUploads,
} = useAttachmentUploads({
// 한도는 서버 값(GET /mail-quota 의 파일당 attachmentMaxBytes · 합계 attachmentTotalMaxBytes)
// — 못 받았으면 스토어가 대체값을 준다
maxBytes: () => mailStore.attachmentMaxBytes,
totalMaxBytes: () => mailStore.attachmentTotalMaxBytes,
})
const to = ref<RecipientToken[]>([])
const cc = ref<RecipientToken[]>([])
const bcc = ref<RecipientToken[]>([])
const showCopies = ref<boolean>(false)
const subject = ref<string>('')
const body = ref<string>('')
/** 답장할 원문을 불러오는 중 — 그동안 입력을 막아 미리 채운 값이 사용자 입력을 덮지 않게 한다 */
const preparing = ref<boolean>(false)
const sending = ref<boolean>(false)
/** 보내기를 한 번 눌렀는지 — 받는 사람이 비었다는 안내는 그 뒤에만 보인다 */
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
* 성공이나 MAIL_005 일 때만 바꾼다 (shouldRenewSendKey). 그 밖의 실패 뒤 다시 보내기는 같은 키다.
* (화면에 그리지 않으므로 반응형이 아니어도 된다)
*/
let sendKey = createIdempotencyKey()
const toField = ref<InstanceType<typeof RecipientField> | null>(null)
const ccField = ref<InstanceType<typeof RecipientField> | null>(null)
const bccField = ref<InstanceType<typeof RecipientField> | null>(null)
const bodyRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
function queryId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
return value === 'reply' || value === 'replyAll'
}
/** 답장·전달할 원본 메일 id */
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
const mode = computed<ComposeMode>(() => {
if (queryId(route.query.forward)) return 'forward'
if (!queryId(route.query.replyTo)) return 'new'
return route.query.all === '1' ? 'replyAll' : 'reply'
})
const title = computed(() => COMPOSE_TITLES[mode.value])
const attachmentHintText = computed(() =>
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
)
/** 쓰기를 마치거나 취소하면 보던 화면으로 — 처음부터 쓰기로 들어왔으면 받은편지함 */
const backTo = computed(() => mailStore.lastBrowseLocation ?? { name: 'mail' })
const sender = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
const allRecipients = computed(() => [...to.value, ...cc.value, ...bcc.value])
const hasInvalidRecipient = computed(() => allRecipients.value.some((token) => !token.valid))
/** 받는 사람 합계 100명 초과 — 서버도 MAIL_002 로 거절하므로 보내기 전에 같은 문구로 막는다 (계약 2절) */
const tooManyRecipients = computed(() => allRecipients.value.length > MAIL_MAX_RECIPIENTS)
const showRecipientError = computed(
() =>
hasInvalidRecipient.value ||
rejectedByServer.value ||
(attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
const canSend = computed(
() => !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
)
function resetForm() {
to.value = []
cc.value = []
bcc.value = []
showCopies.value = false
subject.value = ''
body.value = ''
attempted.value = false
rejectedByServer.value = false
resetUploads()
// 양식을 새로 채우면 다른 메일이다
sendKey = createIdempotencyKey()
}
let prepareSeq = 0
/** 답장·전달 — 원본 메일을 불러와 양식을 채운다 */
async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, 'new'>) {
prepareSeq += 1
const seq = prepareSeq
preparing.value = true
try {
const original = await getMessage(messageId)
if (seq !== prepareSeq) return
if (sourceMode === 'forward') {
const draft = buildForwardDraft(original)
subject.value = draft.subject
body.value = draft.body
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
addExisting(draft.attachments)
} else {
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
to.value = draft.to.map(tokenFromAddress)
cc.value = draft.cc.map(tokenFromAddress)
showCopies.value = cc.value.length > 0
subject.value = draft.subject
body.value = draft.body
}
} catch {
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
} finally {
if (seq === prepareSeq) preparing.value = false
}
if (seq !== prepareSeq) return
await nextTick()
if (sourceMode === 'forward') {
// 전달은 받는 사람부터 정한다
toField.value?.focus()
return
}
// 답장은 커서를 본문 맨 앞(인용 위)에 둔다
bodyRef.value?.focus()
bodyRef.value?.setSelectionRange(0, 0)
}
function commitRecipientInputs() {
toField.value?.commit()
ccField.value?.commit()
bccField.value?.commit()
}
/** 보내기 결과를 보고 키를 정한다 — 바꾸는 것은 성공과 MAIL_005 뿐 */
function settleSendKey(outcome: SendOutcome) {
if (shouldRenewSendKey(outcome)) sendKey = createIdempotencyKey()
}
function notifySent() {
const sent = mailStore.mailboxByRole('sent')
toast.success(
MAIL_SENT_MESSAGE,
sent
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
: undefined,
)
}
async function onSubmit() {
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
commitRecipientInputs()
attempted.value = true
rejectedByServer.value = false
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
toField.value?.focus()
return
}
if (!canSend.value) return
const payload: SendMessagePayload = {
to: to.value.map((token) => token.address),
cc: cc.value.map((token) => token.address),
bcc: bcc.value.map((token) => token.address),
subject: subject.value.trim(),
body: body.value,
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id 를 그대로 보낸다 (계약 2절 "전달")
attachments: uploaded.value.map(({ id, name }) => ({ id, name })),
// 스레드 연결은 답장만 — 전달에는 inReplyTo 를 넣지 않는다
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
}
sending.value = true
try {
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
settleSendKey('sent')
notifySent()
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
await router.push(backTo.value)
} catch (error) {
const { code } = toApiError(error)
settleSendKey(code)
if (code === 'MAIL_002') {
rejectedByServer.value = true
toField.value?.focus()
} else if (code === 'MAIL_004') {
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
const marked = markMissing()
await nextTick()
if (marked.some((item) => item.origin === 'upload')) {
// 올린 파일이 있으면 바로 다시 고를 수 있게 "파일 첨부" 버튼으로
attachButtonRef.value?.focus()
} else if (marked[0]) {
// 전달 원본뿐이면 다시 올릴 수 없으므로 첫 첨부의 빼기 버튼으로
attachmentListRef.value?.focusRemove(marked[0].key)
}
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
} finally {
sending.value = false
}
}
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 */
function onSubjectEnter(event: KeyboardEvent) {
if (event.isComposing) return
event.preventDefault()
bodyRef.value?.focus()
}
function pickFiles() {
fileInputRef.value?.click()
}
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) addFiles(Array.from(input.files))
// 같은 파일을 다시 고를 수 있게 비운다 (실패한 파일 다시 올리기)
input.value = ''
}
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
watch(
draftKey,
() => {
resetForm()
const id = sourceId.value
const current = mode.value
if (id && current !== 'new') void prepareFrom(id, current)
else void nextTick(() => toField.value?.focus())
},
{ immediate: true },
)
// 받는 사람을 고치면 서버 거부 안내를 내린다
watch(allRecipients, () => {
rejectedByServer.value = false
})
</script>
<template>
<main class="compose">
<form
class="compose__form"
aria-labelledby="compose-title"
novalidate
@submit.prevent="onSubmit"
>
<div class="compose__header">
<h1
id="compose-title"
class="compose__title"
>
{{ title }}
</h1>
<RouterLink
:to="backTo"
class="compose__close"
aria-label="쓰기 닫기"
>
<AppIcon name="x" />
</RouterLink>
</div>
<RecipientField
ref="toField"
v-model="to"
label="받는 사람"
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
:error-id="RECIPIENT_ERROR_ID"
:disabled="preparing"
>
<template #trailing>
<button
type="button"
class="compose__copies-toggle"
aria-controls="compose-copies"
:aria-expanded="showCopies"
@click="showCopies = !showCopies"
>
참조 · 숨은참조
</button>
</template>
</RecipientField>
<div
v-show="showCopies"
id="compose-copies"
>
<RecipientField
ref="ccField"
v-model="cc"
label="참조"
input-id="compose-cc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:disabled="preparing"
/>
<RecipientField
ref="bccField"
v-model="bcc"
label="숨은참조"
input-id="compose-bcc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:disabled="preparing"
/>
</div>
<p
v-if="showRecipientError"
:id="RECIPIENT_ERROR_ID"
class="compose__alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ recipientErrorMessage }}</span>
</p>
<div class="compose__row">
<label
for="compose-subject"
class="compose__label"
>제목</label>
<input
id="compose-subject"
v-model="subject"
type="text"
class="compose__subject"
placeholder="제목을 입력하세요"
autocomplete="off"
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
:disabled="preparing"
@keydown.enter="onSubjectEnter"
>
</div>
<textarea
ref="bodyRef"
v-model="body"
class="compose__body"
aria-label="본문"
rows="13"
placeholder="내용을 입력하세요"
:maxlength="MAIL_BODY_MAX_LENGTH"
:disabled="preparing"
/>
<div class="compose__attachments">
<div class="compose__attach-bar">
<button
ref="attachButtonRef"
type="button"
class="compose__attach"
@click="pickFiles"
>
<AppIcon
name="paperclip"
:size="15"
/>
<span>파일 첨부</span>
</button>
<span class="compose__attach-hint">{{ attachmentHintText }}</span>
<input
ref="fileInputRef"
type="file"
multiple
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="MAIL_ATTACHMENT_ACCEPT"
@change="onFilesChosen"
>
</div>
<ComposeAttachmentList
v-if="uploadItems.length > 0"
ref="attachmentListRef"
:items="uploadItems"
@remove="removeUpload"
/>
</div>
<div class="compose__footer">
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-busy="sending"
>
<AppIcon
name="send"
:size="15"
:stroke-width="2"
/>
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
</button>
<RouterLink
:to="backTo"
class="compose__cancel"
>
취소
</RouterLink>
<span
v-if="sender"
class="compose__sender"
>
<span class="compose__sender-label">보내는 사람</span>
{{ sender }}
</span>
</div>
</form>
</main>
</template>
<style scoped>
.compose {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-direction: column;
}
.compose__form {
flex: 1 1 auto;
display: flex;
flex-direction: column;
/* 콘텐츠 열 — 읽기 화면과 같은 폭·같은 32px 선 (tokens.css --content-*) */
max-width: var(--content-max-width);
}
.compose__header {
display: flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-18) var(--space-4) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-border);
}
.compose__title {
flex: 1 1 auto;
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
.compose__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
}
.compose__close:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__copies-toggle {
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.compose__copies-toggle:hover {
background: var(--color-brand-subtle);
}
.compose__alert {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-danger-border);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
.compose__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
min-height: var(--size-touch);
padding: var(--space-4) var(--content-toolbar-start) var(--space-4) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.compose__label {
flex: 0 0 4rem;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.compose__subject {
flex: 1 1 20rem;
min-width: 0;
min-height: 2.125rem;
border: 0;
background: transparent;
color: var(--color-text);
font-size: var(--font-size-body);
font-weight: 600;
}
.compose__body {
flex: 1 1 auto;
width: 100%;
min-height: 18.75rem;
max-width: var(--compose-body-max-width);
padding: var(--space-16) var(--content-inline);
border: 0;
resize: vertical;
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
/* 테두리 없는 넓은 입력칸이라 초점 표시는 1px 로 가볍게, 안쪽에 둔다 */
.compose__subject:focus-visible,
.compose__body:focus-visible {
outline-width: var(--border-width);
outline-offset: calc(var(--border-width) * -1);
}
.compose__attachments {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline) var(--space-14);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.compose__attach-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
.compose__attach {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
/* 바깥 여백 -6px — 아이콘이 콘텐츠 열 32px 선에 온다 (디자인 원본 Compose) */
margin-left: calc(var(--space-6) * -1);
padding: 0 var(--space-10) 0 var(--space-6);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.compose__attach:hover {
background: var(--color-surface-muted);
}
.compose__attach-hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.compose__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
}
.compose__send {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-20);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.compose__send:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.compose__send:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.compose__cancel {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-14);
border-radius: var(--radius-md);
color: var(--color-text-strong);
font-weight: 500;
text-decoration: none;
}
.compose__cancel:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__sender {
margin-left: auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
.compose__sender-label {
margin-right: var(--space-6);
}
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
@media (min-width: 70rem) {
.compose {
overflow-y: auto;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
import AppShell from '@/components/shell/AppShell.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
void mailStore.ensureMailboxes()
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
void mailStore.ensureQuota()
</script>
<template>
<AppShell module-id="mail">
<MailboxPanel />
<RouterView />
</AppShell>
</template>
+204
View File
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { mailViewModeStorageKey, useMailViewStore } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailboxPage from './MailboxPage.vue'
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const SUMMARY: MessageSummary = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [],
subject: '메일 서버 이전 일정 공유',
preview: '',
receivedAt: '2026-10-26T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
}
const DETAIL: MessageDetail = {
...SUMMARY,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
describe('MailboxPage — 보기 방식에 따라 같은 주소를 다르게 그린다', () => {
let pinia: Pinia
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
installMockApi(({ url }) => {
if (url === '/messages') {
return ok({ items: [SUMMARY], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
return ok(DETAIL)
})
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
async function mountAt(path: string) {
useAuthStore().markAuthenticated(USER)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
await flushPromises()
return wrapper
}
it('분할 보기(기본) — 2줄 목록과 본문 칸이 나란히', async () => {
const wrapper = await mountAt('/mail/inbox')
expect(wrapper.find('.message-list').exists()).toBe(true)
expect(wrapper.find('.reader').exists()).toBe(true)
expect(wrapper.find('table').exists()).toBe(false)
})
it('목록 보기 — 메일을 열지 않았으면 표만 넓게', async () => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const wrapper = await mountAt('/mail/inbox')
expect(useMailViewStore().mode).toBe('list')
expect(wrapper.find('table.mail-table').exists()).toBe(true)
expect(wrapper.find('.message-list').exists()).toBe(false)
expect(wrapper.find('.reader').exists()).toBe(false)
})
it('목록 보기에서 메일을 열면 표 자리에 본문을 넓게 — "목록으로 · 이전/다음" 이 붙는다', async () => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const wrapper = await mountAt('/mail/inbox/m1')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.get('.reader').text()).toContain('목록으로')
expect(wrapper.find('button[aria-label="다음 메일"]').exists()).toBe(true)
expect(wrapper.get('#reader-subject').text()).toBe('메일 서버 이전 일정 공유')
})
it('전환 버튼을 누르면 바로 바뀌고 그 사용자 키로 기억한다', async () => {
const wrapper = await mountAt('/mail/inbox')
await wrapper.findAll('[aria-label="보기 방식"] button')[1]?.trigger('click')
await flushPromises()
// 목록 머리는 보기마다 다시 그려진다 — 새로 그린 전환 버튼에서 확인한다
const pressed = wrapper.findAll('[aria-label="보기 방식"] button').map((button) => button.attributes('aria-pressed'))
expect(pressed).toEqual(['false', 'true'])
expect(wrapper.find('table.mail-table').exists()).toBe(true)
expect(window.localStorage.getItem(mailViewModeStorageKey(USER.email))).toBe('list')
})
describe('목록을 못 불러왔을 때', () => {
const INBOX: Mailbox = {
id: 'inbox-id',
name: '받은편지함',
role: 'inbox',
parentId: null,
sortOrder: 0,
totalCount: 1,
unreadCount: 0,
}
const emptyPage = { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }
function listAreaOf(wrapper: Awaited<ReturnType<typeof mountAt>>, mode: 'split' | 'list') {
return wrapper.get(mode === 'list' ? 'section.message-table' : 'section.message-list')
}
it.each(['split', 'list'] as const)(
'주소창에 남은 옛 메일함 id 면(404 RES_001) %s 보기 모두 사전 문구와 "받은편지함으로" — 누르면 기록을 쌓지 않고 받은편지함으로 바꿔 끼운다',
async (mode) => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), mode)
useAuthStore().markAuthenticated(USER)
useMailStore().mailboxes = [INBOX]
const requests = installMockApi(({ url }) =>
url === '/messages' ? fail(404, 'RES_001') : ok(emptyPage),
)
const wrapper = await mountAt('/mail/old-mailbox')
expect(requests.find((request) => request.url === '/messages')?.params).toMatchObject({
mailboxId: 'old-mailbox',
})
const listArea = listAreaOf(wrapper, mode)
expect(listArea.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(wrapper.text()).not.toContain(ERROR_CODE_MESSAGES.VAL_001)
expect(useToastStore().toasts).toEqual([])
const action = listArea.get('.mail-load-error__action')
expect(action.text()).toBe('받은편지함으로')
const router = wrapper.vm.$router
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await action.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox-id')
expect(replace).toHaveBeenCalled()
expect(push).not.toHaveBeenCalled()
},
)
it('메일함 목록도 아직 모르면 /mail 로 바꿔 끼운다 (메일함을 받은 뒤 기본 메일함으로 간다)', async () => {
installMockApi(({ url }) => (url === '/messages' ? fail(404, 'RES_001') : ok(emptyPage)))
const wrapper = await mountAt('/mail/old-mailbox')
const router = wrapper.vm.$router
const push = vi.spyOn(router, 'push')
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail')
expect(push).not.toHaveBeenCalled()
})
it.each(['split', 'list'] as const)(
'메일 서버 장애 같은 일시적 오류는 %s 보기에서도 "다시 시도" 그대로 — 누르면 다시 불러온다',
async (mode) => {
window.localStorage.setItem(mailViewModeStorageKey(USER.email), mode)
let healthy = false
const requests = installMockApi(({ url }) =>
url === '/messages' && !healthy ? fail(503, 'MAIL_001') : ok(emptyPage),
)
const wrapper = await mountAt('/mail/inbox')
const action = listAreaOf(wrapper, mode).get('.mail-load-error__action')
expect(listAreaOf(wrapper, mode).text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(action.text()).toBe('다시 시도')
healthy = true
await action.trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/messages')).toHaveLength(2)
expect(wrapper.vm.$router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(wrapper.find('.mail-load-error__action').exists()).toBe(false)
},
)
})
})
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import StateMessage from '@/components/feedback/StateMessage.vue'
import ListReadNav from '@/components/mail/ListReadNav.vue'
import MessageList from '@/components/mail/MessageList.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import MessageReader from '@/components/mail/MessageReader.vue'
import MessageTable from '@/components/mail/MessageTable.vue'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
const route = useRoute()
const router = useRouter()
const mailStore = useMailStore()
const viewStore = useMailViewStore()
function paramOf(value: string | string[] | undefined): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
const mailboxId = computed(() => paramOf(route.params.mailboxId))
const messageId = computed(() => paramOf(route.params.messageId))
const isListView = computed(() => viewStore.mode === 'list')
watch(
mailboxId,
(id) => {
if (id) void mailStore.openMailbox(id)
},
{ immediate: true },
)
watch(
messageId,
(id) => {
if (id) void mailStore.openMessage(id)
else mailStore.closeMessage()
},
{ immediate: true },
)
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
watch(
() => route.fullPath,
(fullPath) => {
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
},
{ immediate: true },
)
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
watch(
[mailboxId, () => mailStore.defaultMailbox],
([id, fallback]) => {
if (!id && fallback) {
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
}
},
{ immediate: true },
)
</script>
<template>
<main :class="['mail-view', { 'mail-view--list': isListView }]">
<template v-if="mailboxId && isListView">
<MessageReader
v-if="messageId"
layout="list"
>
<template #toolbar-start>
<ListReadNav
:mailbox-id="mailboxId"
:message-id="messageId"
/>
</template>
</MessageReader>
<MessageTable
v-else
:mailbox-id="mailboxId"
/>
</template>
<template v-else-if="mailboxId">
<MessageList :mailbox-id="mailboxId" />
<MessageReader />
</template>
<template v-else>
<section
class="mail-view__pending"
aria-label="메일함"
>
<StateMessage
v-if="mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError"
tone="danger"
icon="alert-circle"
:title="mailStore.mailboxesError.message"
:trace-id="mailStore.mailboxesError.traceId"
>
<button
type="button"
class="mail-view__retry"
@click="mailStore.loadMailboxes()"
>
다시 시도
</button>
</StateMessage>
<MessageListSkeleton v-else />
</section>
<MessageReader v-if="!isListView" />
</template>
</main>
</template>
<style scoped>
.mail-view {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.mail-view__pending {
flex: 1 1 24rem;
min-width: 0;
border-right: var(--border-width) solid var(--color-border);
}
/* 목록 보기 — 메일함 패널 오른쪽 전체 폭을 한 칸이 쓴다 */
.mail-view--list .mail-view__pending {
border-right: 0;
}
.mail-view__retry {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.mail-view__retry:hover {
background: var(--color-brand-subtle);
}
/* 넓은 화면에서는 목록·본문이 한 줄에 놓이고 칸마다 스크롤한다 (AppShell 과 같은 이유로 줄바꿈을 끈다) */
@media (min-width: 70rem) {
.mail-view {
flex-wrap: nowrap;
min-height: 0;
}
}
</style>