generated from ttipo/project-base
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:
@@ -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>
|
||||
@@ -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('로그인')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user