chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
25 changed files with 707 additions and 69 deletions
Showing only changes of commit 3634cee955 - Show all commits
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기]) + 닫기
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + (이동 링크 — 예: 바탕화면 알림을 막아 둠 [설정 열기] · 받는 사람 일부가 받지 못함 [보낸편지함 보기]) + 닫기
// 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
@@ -186,6 +186,9 @@ function runAction(toast: Toast) {
.toast__message {
font-weight: 500;
/* 한국어 낱말 가운데서는 줄을 바꾸지 않고, 받지 못한 주소처럼 끊을 곳 없는 긴 글자는 알림 안에서 줄을 바꾼다 */
word-break: keep-all;
overflow-wrap: anywhere;
}
.toast__trace {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002 · 엔진이 모두 거절한 MAIL_010) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 문구는 쓰기 화면이 에러 코드 사전에서 가져와 준다. 그 칸의 입력이 id 를 aria-describedby 로 가리킨다
import AppIcon from '@/components/AppIcon.vue'
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 서버가 받는 사람을 거절하면(MAIL_002 · MAIL_010 — rejected) 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 두고 같은 안내 띠를 가리킨다
// (어느 주소인지는 모른다 — 밑줄은 긋지 않는다).
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
@@ -41,8 +43,10 @@ interface Props {
inputId: string
placeholder?: string
disabled?: boolean
/** 오류 안내 문단 id — 형식이 틀린 주소가 있으면 입력칸과 연결한다 */
/** 오류 안내 문단 id — 형식이 틀린 주소가 있거나 서버가 거절했으면(rejected) 입력칸과 연결한다 */
errorId?: string
/** 쓰기 화면이 서버의 받는 사람 거절(MAIL_002 · MAIL_010)을 안내하는 중 — 빈 칸은 거절된 주소가 없으므로 그대로 둔다 */
rejected?: boolean
}
interface Emits {
@@ -53,6 +57,7 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: '',
disabled: false,
errorId: '',
rejected: false,
})
const emit = defineEmits<Emits>()
@@ -76,6 +81,8 @@ const dismissed = ref<boolean>(false)
const activeIndex = ref<number>(-1)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 잘못된 입력 — 형식이 틀린 주소가 있거나, 서버가 거절한 받는 사람이 이 칸에 들었을 수 있다 (rejected · 주소가 있는 칸) */
const invalid = computed(() => hasInvalid.value || (props.rejected && props.modelValue.length > 0))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim() !== '')
@@ -94,7 +101,7 @@ const activeOptionId = computed(() =>
)
const hint = computed(() => (typedFullAddress.value && activeIndex.value < 0 ? HINT_TYPED_ADDRESS : HINT_DEFAULT))
const describedBy = computed(() => {
const ids = [hasInvalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
const ids = [invalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
return ids.filter((id) => id !== '').join(' ') || undefined
})
@@ -281,7 +288,7 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="hasInvalid"
:aria-invalid="invalid"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 150자) · 내용(여러 줄 · 600자 · 글자만) (디자인 원본 SettingsVacation — 한도는 계약 2절)
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
import { ref } from 'vue'
+2
View File
@@ -69,6 +69,8 @@ describe('에러 코드 사전', () => {
// 웹메일 4단계
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
// 6단계 앞 수정
'MAIL_010', // 엔진이 받는 사람을 모두 거절(없는 주소 등) — 쓰기 화면이 받는 사람 칸 아래 띠에 보인다
]
for (const code of usedByClient) {
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import { saveDraftDetached } from './useDraftSaving'
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
@@ -13,7 +14,7 @@ import { useMailStore } from '@/stores/mail.store'
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent) — 보내기를 시작한 세션 그대로일 때만.
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
export interface ComposeDraftLinkOptions {
@@ -28,6 +29,7 @@ function draftKeyFor(id: string): string {
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
/**
@@ -99,12 +101,18 @@ export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
/**
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다.
* session 은 보내기를 시작한 세션 세대(authStore.generation)다 — 그 뒤 세션이 끝났거나 바뀌었으면(로그아웃 · 만료 · 다른 계정 로그인)
* 남기지 않는다. 요청은 그때의 쿠키로 나가, 다른 계정의 임시보관함에 앞 사용자의 메일이 들어가기 때문이다
* (PUT 을 기다리는 사이 바뀔 수도 있어 PUT 뒤에 새로 남기기 전에도 본다)
*/
async function keepUnsent(payload: SendMessagePayload) {
async function keepUnsent(payload: SendMessagePayload, session: number) {
const sameSession = () => authStore.generation === session
if (!sameSession()) return
const { draftId = null, ...draft } = payload
const saved = await saveDraftDetached(draft, draftId)
if (!saved) return
const saved = await saveDraftDetached(draft, draftId, sameSession)
// 저장하는 사이 세션이 바뀌었으면 이 탭의 기록 · 개수도 건드리지 않는다 (새 세션의 것이다)
if (!saved || !sameSession()) return
recordSaved(draftId, saved)
mailStore.syncCounts()
}
+12 -5
View File
@@ -84,19 +84,21 @@ type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
/**
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다.
* canFallback 이 false 면 새로 남기지 않고 PUT 의 실패를 그대로 던진다 — PUT 을 기다리는 사이 세션이 바뀌었을 때 (saveDraftDetached)
*/
async function putOrCreate(
mail: DraftApi,
payload: DraftPayload,
id: string | null,
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
canFallback: () => boolean = () => true,
): Promise<{ saved: DraftSaved; replaced: boolean }> {
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
try {
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
} catch (error) {
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
if (!REPLACED_CODES.includes(toApiError(error).code) || !canFallback()) throw error
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
}
@@ -104,11 +106,16 @@ async function putOrCreate(
/**
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null.
* stillValid 가 false 가 되면(세션이 바뀜) 옛 저장본이 없어도 새로 남기지 않는다 — 두 번째 요청은 다른 계정의 쿠키로 나간다
*/
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
export async function saveDraftDetached(
payload: DraftPayload,
draftId: string | null,
stillValid: () => boolean = () => true,
): Promise<DraftSaved | null> {
try {
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true }, stillValid)).saved
} catch {
return null
}
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { useMail } from './useMail'
const PAYLOAD: SendMessagePayload = {
to: [{ name: null, email: 'doyun.lee@arasystem.it.kr' }],
cc: [],
bcc: [],
subject: '일정 공유',
body: '확인 부탁드립니다.',
attachments: [],
}
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약 2절 "받는 사람별 결과")', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it.each([
['받지 못한 사람이 있으면 그대로', { id: 'sent-1', rejectedRecipients: [NOBODY] }, [NOBODY]],
['빈 배열은 그대로', { id: 'sent-1', rejectedRecipients: [] }, []],
['필드가 없는 옛 서버 응답은 빈 배열', { id: 'sent-1' }, []],
['배열이 아니면(응답을 바꾼 게이트웨이 등) 빈 배열', { id: 'sent-1', rejectedRecipients: { email: NOBODY.email } }, []],
['null 도 빈 배열', { id: 'sent-1', rejectedRecipients: null }, []],
])('%s', async (_label, reply, rejectedRecipients) => {
const requests = installMockApi(() => ok(reply, 201))
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
id: 'sent-1',
rejectedRecipients,
})
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
})
})
+11 -4
View File
@@ -32,7 +32,7 @@ import type {
RecipientSuggestionQuery,
SaveSignaturePayload,
SendMessagePayload,
SendMessageResult,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
@@ -49,6 +49,9 @@ export interface SendMessageOptions extends MailRequestOptions {
idempotencyKey: string
}
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
export interface UploadOptions {
/** 0~100 */
onProgress?: (percent: number) => void
@@ -256,17 +259,21 @@ export function useMail() {
/**
* 메일 발송. 같은 idempotencyKey 로 다시 보내면 서버는 두 번 내보내지 않고 처음 결과를 돌려준다.
* (401 뒤 갱신 재시도도 같은 헤더로 다시 간다 — 인터셉터가 원래 설정을 그대로 쓴다)
* 201 의 rejectedRecipients 는 엔진이 받지 않은 사람이다 — 있어도 보낸 것이다. 모두 받지 않았으면 보내지 않은 것으로
* 400 MAIL_010(없는 주소 · 거절) · 503 MAIL_011(지금은 받을 수 없음)이다 (계약 2절 "받는 사람별 결과").
* rejectedRecipients 는 여기서 늘 배열로 맞춘다 — 이 필드가 없는 옛 서버 · 응답을 바꾼 게이트웨이면 빈 배열(모두 받은 것)
*/
function sendMessage(
async function sendMessage(
payload: SendMessagePayload,
options: SendMessageOptions,
): Promise<SendMessageResult> {
): Promise<SentMessage> {
const { idempotencyKey, ...requestOptions } = options
return api.post<SendMessageResult, SendMessageResult>('/messages', payload, {
const sent = await api.post<SentMessageReply, SentMessageReply>('/messages', payload, {
timeout: MAIL_SEND_TIMEOUT_MS,
...requestOptions,
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
})
return { ...sent, rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [] }
}
/**
+2 -1
View File
@@ -40,7 +40,8 @@ export function useToast() {
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아니라 브라우저 상태를 알릴 때만 쓴다 (예: 바탕화면 알림을 막아 둠 + [설정 열기]).
* 화면이 직접 띄우는 오류 알림 — API 오류가 아닌 실패를 알릴 때만 쓴다: 브라우저 상태(예: 바탕화면 알림을 막아 둠 + [설정 열기]),
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
function error(message: string, action?: ToastAction): number {
@@ -859,6 +859,126 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
})
})
describe('보내는 중에 세션이 바뀌면 — 보내지도 · 알리지도 · 저장본으로 남기지도 않는다 (요청은 지금 쿠키로 나간다)', () => {
const RouterHost: FunctionalComponent = () => h('div')
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
/** 붙잡아 둔 응답 — 테스트가 풀어 준다 */
function holder() {
const held: { release: ((result: MockResult) => void) | null } = { release: null }
const hold = () =>
new Promise<MockResult>((resolve) => {
held.release = resolve
})
return { held, hold }
}
/** 이 탭의 로그아웃 → 로그인 화면 → 다른 계정으로 로그인 (쓰기 화면은 내려간다) */
async function switchAccount(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
useAuthStore().clearAuth()
await router.push('/login')
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
useAuthStore().startSession(OTHER)
}
async function writeAndAutosave(wrapper: VueWrapper) {
const input = wrapper.get('#compose-to')
await input.setValue('junho.park@arasystem.it.kr')
await input.trigger('blur')
await wrapper.get('#compose-subject').setValue('일정 공유')
await wait(3_000)
}
const sendCalls = (requests: MockRequest[]) =>
requests.filter((request) => request.method === 'POST' && request.url === '/messages')
it('임시 저장을 기다리는 사이 바뀌면 보내지 않는다 — 앞 사용자의 메일이 다른 계정에서 나가지 않게', async () => {
const save = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => (request.method === 'POST' && request.url === '/drafts' ? save.hold() : undefined),
}),
)
await writeAndAutosave(wrapper)
// 저장이 나가 있는 동안 보내기를 누른다 — 보내기는 그 저장을 기다린다
await wrapper.get('form').trigger('submit')
await flushPromises()
await switchAccount(wrapper, router)
save.held.release?.(ok({ id: 'd1', attachments: [], updatedAt: SAVED_AT }, 201))
await flushPromises()
await flushPromises()
expect(sendCalls(requests)).toEqual([])
expect(useToastStore().toasts).toEqual([])
})
it('보내기가 실패로 끝나도(MAIL_002) 알리지 않고 저장본으로 남기지 않는다 — 다른 계정의 임시보관함에 들어가지 않게', async () => {
const send = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => (request.method === 'POST' && request.url === '/messages' ? send.hold() : undefined),
}),
)
await writeAndAutosave(wrapper)
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
await wrapper.get('form').trigger('submit')
await flushPromises()
await switchAccount(wrapper, router)
send.held.release?.(fail(400, 'MAIL_002'))
await flushPromises()
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual(['POST /drafts'])
expect(useToastStore().toasts).toEqual([])
})
it('저장본으로 남기는 PUT 을 기다리는 사이 바뀌면 — 옛 저장본이 없어도(RES_001) 새로 남기지 않는다', async () => {
const send = holder()
const keep = holder()
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
server({
extra: (request) => {
if (request.method === 'POST' && request.url === '/messages') return send.hold()
if (request.method === 'PUT' && request.url === '/drafts/d1') return keep.hold()
return undefined
},
}),
)
await writeAndAutosave(wrapper)
await wrapper.get('#compose-subject').setValue('일정 공유 (최종)')
await wrapper.get('form').trigger('submit')
await flushPromises()
// 같은 세션에서 다른 화면으로 — 보내기가 실패하면 보내려던 내용을 남긴다 (PUT /drafts/d1)
await router.push('/mail/sent')
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
wrapper.unmount()
send.held.release?.(fail(503, 'MAIL_001'))
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
'POST /drafts',
'PUT /drafts/d1',
])
// 그 PUT 이 나가 있는 동안 다른 계정으로 로그인했다 — 새로 남기는 POST 는 그 계정의 쿠키로 나간다
useAuthStore().clearAuth()
useAuthStore().startSession(OTHER)
keep.held.release?.(fail(404, 'RES_001'))
await flushPromises()
await flushPromises()
expect(draftCalls(requests).map((request) => `${request.method} ${request.url}`)).toEqual([
'POST /drafts',
'PUT /drafts/d1',
])
})
})
describe('본문이 한도를 넘으면', () => {
it('자동 저장을 보내지 않는다 — 상태 글은 실패, 본문 아래 MAIL_007 안내. 내용이 바뀔 때까지 다시 하지 않는다', async () => {
const { wrapper, requests } = await mountCompose('/mail/compose', server())
+213 -1
View File
@@ -17,7 +17,7 @@ import {
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { MessageDetail } from '@/shared/types/mail'
import type { Mailbox, MessageDetail, RejectedRecipient } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -265,6 +265,135 @@ describe('ComposePage', () => {
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
})
describe('받는 사람별 결과 — 일부만 받지 못하면 보낸 것, 모두 받지 못하면 보내지 않은 것 (계약 2절 "받는 사람별 결과")', () => {
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 1, totalCount: 1, unreadCount: 0 },
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
]
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
/** 보내기 응답을 차례로 돌려주는 서버 — 메일함 목록에는 보낸편지함이 있다 */
function sendSequence(outcomes: MockResult[]): MockHandler {
return (request) => {
if (request.method === 'GET' && request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.method === 'POST' && request.url === '/messages') {
return outcomes.shift() ?? ok({ id: 'sent-2', rejectedRecipients: [] }, 201)
}
return replyApi(request)
}
}
function sentKeys(requests: MockRequest[]): unknown[] {
return requests
.filter((request) => request.method === 'POST' && request.url === '/messages')
.map((request) => request.config.headers.get('Idempotency-Key'))
}
async function addRecipient(wrapper: VueWrapper, email: string) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
async function submit(wrapper: VueWrapper) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
it('일부만 받지 못했으면 보낸 것이다 — 보던 곳으로 돌아가고, "보냈습니다" 대신 오류 알림(받지 못한 사람 · [보낸편지함])', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
sendSequence([ok({ id: 'sent-1', rejectedRecipients: [NOBODY] }, 201)]),
)
await useMailStore().loadMailboxes()
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await addRecipient(wrapper, NOBODY.email)
await submit(wrapper)
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts).toEqual([
expect.objectContaining({
kind: 'error',
message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
// 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store)
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
}),
])
// 보낸 것이니 키를 바꾼다 — 같은 화면에서 또 보내면 다른 메일이다
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toEqual([expect.any(String), expect.any(String)])
expect(keys[1]).not.toBe(keys[0])
})
it.each(['MAIL_002', 'MAIL_010'] as const)(
'서버가 받는 사람을 거절하면(%s) 주소가 든 칸은 잘못된 입력 — 안내 띠를 가리킨다. 빈 칸은 그대로, 받는 사람을 고치면 푼다',
async (code) => {
const { wrapper } = await mountCompose('/mail/compose', sendSequence([fail(400, code)]))
await addRecipient(wrapper, NOBODY.email)
const cc = wrapper.get('#compose-cc')
await cc.setValue('infra@arasystem.it.kr')
await cc.trigger('blur')
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
await submit(wrapper)
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES[code])
for (const id of ['compose-to', 'compose-cc']) {
expect(wrapper.get(`#${id}`).attributes('aria-invalid')).toBe('true')
expect(wrapper.get(`#${id}`).attributes('aria-describedby')).toBe('compose-recipient-error')
}
// 빈 숨은참조에는 거절된 주소가 없다
expect(wrapper.get('#compose-bcc').attributes('aria-invalid')).toBe('false')
expect(wrapper.get('#compose-bcc').attributes('aria-describedby')).toBeUndefined()
// 받는 사람을 고치면 띠와 함께 푼다
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
expect(wrapper.get('#compose-to').attributes('aria-invalid')).toBe('false')
expect(wrapper.get('#compose-to').attributes('aria-describedby')).toBeUndefined()
},
)
it('모두 받지 못했으면(MAIL_010 — 없는 주소 등) 보내지 않은 것이다 — 알림 없이 받는 사람 띠에 그 문구 · 받는 사람 칸으로 초점, 쓴 내용 · 키는 그대로', async () => {
const { wrapper, router, requests } = await mountCompose(
'/mail/compose',
sendSequence([fail(400, 'MAIL_010')]),
true,
)
await addRecipient(wrapper, NOBODY.email)
await wrapper.get('#compose-subject').setValue('안녕하세요')
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
await submit(wrapper)
expect(wrapper.get('#compose-recipient-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_010)
expect(document.activeElement?.id).toBe('compose-to')
expect(useToastStore().toasts).toEqual([])
expect(chips(wrapper, 'compose-to')).toEqual([NOBODY.email])
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('안녕하세요')
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
// 받는 사람을 고치면 띠를 내린다 — 없는 주소를 빼고 다시 보내면 같은 키로 간다 (서버가 보낸 사본을 지워 새로 보낸다)
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
await wrapper.get(`button[aria-label="${NOBODY.email} 삭제"]`).trigger('click')
expect(chips(wrapper, 'compose-to')).toEqual(['doyun.lee@arasystem.it.kr'])
await submit(wrapper)
const keys = sentKeys(requests)
expect(keys).toHaveLength(2)
expect(keys[1]).toBe(keys[0])
expect(router.currentRoute.value.fullPath).toBe('/mail')
wrapper.unmount()
})
})
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 }
@@ -800,6 +929,8 @@ describe('ComposePage', () => {
['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'],
// 보내지 않은 것이다(서버가 보낸 사본을 지웠다) — 잠시 뒤 다시 누르면 같은 키로 새로 간다 (계약 2절 "받는 사람별 결과")
['503 MAIL_011 (받는 사람 모두 지금은 받을 수 없음)', fail(503, 'MAIL_011'), 'MAIL_011'],
] as const)(
'%s — 사전 문구로 알리고 쓴 내용·키를 그대로 둔다. 자동으로 다시 보내지 않고, 다시 누르면 같은 키다',
async (_label, failure, code) => {
@@ -1230,6 +1361,32 @@ describe('ComposePage', () => {
expect(blocksUnload()).toBe(false)
})
it('받는 사람 일부가 받지 못했어도 보낸 것이다 — 묻지 않고 돌아가며 창 닫기도 막지 않는다 (계약 2절 "받는 사람별 결과")', async () => {
useMailStore().rememberBrowseLocation('/mail/inbox')
const { wrapper, router } = await mountCompose('/mail/compose', (request) =>
request.method === 'POST' && request.url === '/messages'
? ok({ id: 'sent-1', rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'refused' }] }, 201)
: replyApi(request),
)
for (const email of ['doyun.lee@arasystem.it.kr', 'nobody@arasystem.it.kr']) {
const input = wrapper.get('#compose-to')
await input.setValue(email)
await input.trigger('blur')
}
await writeSubject(wrapper, '일정 공유')
expect(blocksUnload()).toBe(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'],
])
})
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
const RouterHost: FunctionalComponent = () => h('div')
@@ -1297,11 +1454,66 @@ describe('ComposePage', () => {
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
it('떠난 뒤 보내기가 받는 사람 일부를 받지 못한 채 끝나도 — "보냈습니다" 대신 받지 못한 사람 알림 하나, 간 곳에서 끌고 오지 않는다', async () => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
await startSending(wrapper)
await router.push('/mail/sent')
leaveScreen(wrapper, router)
held.release?.(
ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'temporary' }] }, 201),
)
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'],
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
type AuthStore = ReturnType<typeof useAuthStore>
const OTHER = { email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }
const PARTIAL = ok({ id: 'sent-1', rejectedRecipients: [{ email: 'infra@arasystem.it.kr', reason: 'not-found' }] }, 201)
it.each([
['로그아웃', (store: AuthStore) => store.clearAuth(), PARTIAL, true],
[
'다른 계정 로그인',
(store: AuthStore) => {
store.clearAuth()
store.startSession(OTHER)
},
PARTIAL,
true,
],
['로그아웃 — 모두 받았다', (store: AuthStore) => store.clearAuth(), ok({ id: 'sent-1', rejectedRecipients: [] }, 201), true],
['세션 만료 — 쓰기 화면은 대화상자 뒤에 남는다', (store: AuthStore) => store.expireSession(), PARTIAL, false],
] as const)(
'보내는 중 %s — 결과가 와도 알리지 않는다 (일부 거절 알림에는 앞 사용자의 받는 사람 주소가 있다)',
async (_label, endSession, result, leaves) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1&all=1', handler)
await startSending(wrapper)
endSession(useAuthStore())
if (leaves) {
await router.push('/login')
leaveScreen(wrapper, router)
}
held.release?.(result)
await flushPromises()
expect(useToastStore().toasts).toEqual([])
},
)
it.each([
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
['MAIL_010 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_010'), 'MAIL_010'],
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
['MAIL_011 (평소에도 토스트)', fail(503, 'MAIL_011'), 'MAIL_011'],
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
const { handler, held } = heldSendApi()
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
+79 -33
View File
@@ -21,10 +21,14 @@
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
// 엔진이 받는 사람을 모두 거절하면(MAIL_010 — 없는 주소 등, 보내지 않은 것) 같은 띠 · 같은 초점에 그 코드의 문구.
// 일부만 받지 못했으면 보낸 것이다 — 떠나면서 "보냈습니다" 대신 받지 못한 사람을 오류 알림 모양으로 알린다 (계약 2절 "받는 사람별 결과").
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
// 보내는 중 · 보낸 뒤 · 버린 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
// 휴지통 #compose-discard · 닫기 .compose__close
import {
@@ -71,6 +75,7 @@ import {
MAIL_ATTACHMENT_ACCEPT,
MAIL_MAX_ATTACHMENTS,
MAIL_MAX_RECIPIENTS,
MAIL_PARTIAL_SEND_TEXT,
MAIL_SENT_ACTION_LABEL,
MAIL_SENT_MESSAGE,
MAIL_SUBJECT_MAX_LENGTH,
@@ -78,7 +83,7 @@ import {
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload } from '@/shared/types/mail'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
import {
isSameComposeForm,
snapshotComposeForm,
@@ -101,6 +106,7 @@ import { attachmentHint } from '@/shared/utils/mail-attachment'
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
import { partialSendMessage } from '@/shared/utils/mail-send-result'
import { newMailHtmlWithSignature, signatureHtmlFor } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -124,6 +130,9 @@ const BODY_ERROR_ID = 'compose-body-error'
/** 쓰기 화면을 여는 까닭 — 새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기(임시 저장본) */
type ComposeSource = ComposeMode | 'draft'
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
type RecipientRejectionCode = Extract<ErrorCode, 'MAIL_002' | 'MAIL_010'>
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
@@ -179,8 +188,11 @@ const discarding = ref<boolean>(false)
const discarded = ref<boolean>(false)
/** 보내기를 한 번 눌렀는지 — 받는 사람이 비었다는 안내는 그 뒤에만 보인다 */
const attempted = ref<boolean>(false)
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
const rejectedByServer = ref<boolean>(false)
/**
* 서버가 받는 사람을 거부했다 — MAIL_002(형식 · 합계 100명 초과) · MAIL_010(엔진이 모두 거절 — 없는 주소 등).
* 받는 사람을 고치거나 다시 보내면 내린다
*/
const rejectedByServer = ref<RecipientRejectionCode | null>(null)
/**
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
@@ -210,7 +222,7 @@ let formGeneration = 0
*/
let resetCount = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -268,14 +280,18 @@ 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)),
/** 보내기 전 검사에 걸린 받는 사람 — 형식이 틀린 주소, 보내기를 누른 뒤의 빈 받는 사람 · 100명 초과 */
const recipientPrecheckFailed = computed(
() => hasInvalidRecipient.value || (attempted.value && (to.value.length === 0 || tooManyRecipients.value)),
)
const showRecipientError = computed(() => recipientPrecheckFailed.value || rejectedByServer.value !== null)
/**
* 문구는 에러 코드 사전에서만 온다 — 보내기 전 검사와 서버의 MAIL_002 는 같은 문구,
* 엔진이 모두 거절한 것(MAIL_010)은 그 코드의 문구 (보내기 전 검사에 걸렸으면 그것부터)
*/
const recipientErrorMessage = computed(() =>
getErrorMessage(recipientPrecheckFailed.value ? 'MAIL_002' : (rejectedByServer.value ?? 'MAIL_002')),
)
/** 문구는 에러 코드 사전에서만 온다 */
const recipientErrorMessage = getErrorMessage('MAIL_002')
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
const subjectErrorMessage = getErrorMessage('VAL_001')
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
@@ -528,7 +544,7 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
bodyPending.value = false
renewEditor()
attempted.value = false
rejectedByServer.value = false
rejectedByServer.value = null
lengthError.value = null
resetUploads()
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
@@ -691,14 +707,20 @@ function settleSendKey(outcome: SendOutcome) {
if (shouldRenewSendKey(outcome)) sendKey = createIdempotencyKey()
}
function notifySent() {
/**
* 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
* 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
*/
function notifySent(result: SentMessage) {
const sentMailbox = mailStore.mailboxByRole('sent')
toast.success(
MAIL_SENT_MESSAGE,
sentMailbox
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
: undefined,
)
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
const partial = partialSendMessage(result.rejectedRecipients)
if (partial) {
toast.error(partial, sentLocation ? { label: MAIL_PARTIAL_SEND_TEXT.action, to: sentLocation } : undefined)
return
}
toast.success(MAIL_SENT_MESSAGE, sentLocation ? { label: MAIL_SENT_ACTION_LABEL, to: sentLocation } : undefined)
}
/** 보내지 못했다 — 쓰던 대로 두고 자동 저장을 다시 켠다. 기다린 저장이 저장본을 바꿨으면 주소도 맞춘다 */
@@ -713,7 +735,7 @@ async function onSubmit() {
// 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
commitRecipientInputs()
attempted.value = true
rejectedByServer.value = false
rejectedByServer.value = null
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
toField.value?.focus()
return
@@ -745,12 +767,24 @@ async function onSubmit() {
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
const generation = formGeneration
const mark = resetCount
/**
* 이 보내기를 시작한 세션 — 그 뒤 끝났거나 바뀌었으면(로그아웃 · 만료 · 다른 계정 로그인) 보내지도 · 알리지도 · 저장본으로 남기지도 않는다.
* 결과 알림에는 받는 사람 주소가 들 수 있고(일부 거절), 그 뒤의 요청은 지금 쿠키(다른 계정일 수 있다)로 나간다
*/
const session = authStore.generation
const sameSession = () => authStore.generation === session
sending.value = true
// 진행 중인 임시 저장을 기다린다 — 보내는 본문의 draftId · 첨부 id 가 최신이어야 한다 (계약 2절 "임시 저장").
// 그사이 화면을 떠나도 보낸다(보내기를 이미 눌렀다). 다른 메일로 바뀌었으면 이 메일은 보내지 않는다
drafts.suspend()
await drafts.settle()
if (mark !== resetCount) return
// 기다리는 사이 세션이 바뀌었다 — 보내지 않는다 (다른 계정의 쿠키로 나가지 않게). 자동 저장도 다시 켜지 않는다 —
// 양식이 남아 있다면 세션 만료 대화상자 뒤다
if (!sameSession()) {
if (generation === formGeneration) sending.value = false
return
}
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다) 그사이 새 첨부를 올리기 시작했다.
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다
if (hasMissing.value || isUploading.value) {
@@ -780,15 +814,19 @@ async function onSubmit() {
}
try {
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 받는 사람 거부(MAIL_002) · 엔진이 받는 사람을 모두 거절(MAIL_010 — 없는 주소 등) · 첨부 만료(MAIL_004) ·
// 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
notifySent()
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
if (sameSession()) {
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
notifySent(result)
// 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
mailStore.syncCounts()
}
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
settleSendKey('sent')
@@ -800,18 +838,23 @@ async function onSubmit() {
} catch (error) {
const apiError = toApiError(error)
if (generation !== formGeneration) {
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
// 양식이 이미 없다 — 세션이 바뀌었으면 알리지도 저장본으로 남기지도 않는다 (로그인 화면 · 다른 계정에 앞 사용자의 실패가 보이거나
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
if (!sameSession()) return
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
void draftLink.keepUnsent(payload)
void draftLink.keepUnsent(payload, session)
return
}
const { code } = apiError
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절).
// MAIL_010 · MAIL_011 도 그대로 — 아무에게도 가지 않아 서버가 보낸 사본을 지웠다. 같은 키로 다시 보내면 새로 보낸다
settleSendKey(code)
endSending(generation)
if (code === 'MAIL_002') {
rejectedByServer.value = true
if (code === 'MAIL_002' || code === 'MAIL_010') {
// 받는 사람 칸 아래 띠 — MAIL_010 은 보내지 않은 것이라 쓰던 메일은 그대로 두고 주소를 고치게 한다
rejectedByServer.value = code
toField.value?.focus()
} else if (code === 'MAIL_007') {
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
@@ -949,7 +992,7 @@ watch(hasMissing, (missing) => {
// 받는 사람을 고치면 서버 거부 안내를 내린다
watch(allRecipients, () => {
rejectedByServer.value = false
rejectedByServer.value = null
})
</script>
@@ -975,6 +1018,7 @@ watch(allRecipients, () => {
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
>
<template #trailing>
@@ -1001,6 +1045,7 @@ watch(allRecipients, () => {
input-id="compose-cc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
/>
<RecipientField
@@ -1010,6 +1055,7 @@ watch(allRecipients, () => {
input-id="compose-bcc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
/>
</div>
@@ -99,8 +99,9 @@ describe('MailVacationPage', () => {
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-body').attributes('disabled')).toBeDefined()
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('200')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('4000')
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
})
@@ -191,6 +192,34 @@ describe('MailVacationPage', () => {
expect(wrapper.find('#vacation-body-error').exists()).toBe(true)
})
it('켠 채 저장할 때 제목 150자 · 내용 600자를 넘으면(4단계 한도로 저장해 둔 긴 글 등) 칸 아래에 사전 문구 — 줄이면 저장한다', async () => {
const { wrapper } = await mountPage({ vacation: { ...SAVED, subject: 'S'.repeat(151), textBody: 'B'.repeat(601) } })
await save(wrapper)
expect(puts()).toEqual([])
expect(wrapper.get('#vacation-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(wrapper.get('#vacation-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(document.activeElement?.id).toBe('vacation-subject')
await wrapper.get('#vacation-subject').setValue('S'.repeat(150))
await wrapper.get('#vacation-body').setValue('B'.repeat(600))
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, subject: 'S'.repeat(150), textBody: 'B'.repeat(600) }])
})
it('켜진 옛 긴 글(4단계에 저장한 내용 1,000자)도 끄는 것은 늘 저장된다 — 꺼 둘 때는 길이를 보지 않는다 (서버는 꺼 둔 채로 4단계 한도까지 받는다)', async () => {
const longBody = 'B'.repeat(1_000)
const { wrapper } = await mountPage({ vacation: { ...SAVED, textBody: longBody } })
await wrapper.get('#vacation-enabled').setValue(false)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
expect(useMailVacationStore().vacation?.isEnabled).toBe(false)
expect(value(wrapper, '#vacation-body')).toBe(longBody)
})
it('끄면 빈 제목 · 내용도 저장된다 — 쓴 글은 남기고 "자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다."', async () => {
const { wrapper } = await mountPage({ vacation: SAVED })
await wrapper.get('#vacation-enabled').setValue(false)
@@ -1,10 +1,14 @@
// 설정 › 메일 › 부재중 자동 응답 상수 — 계약 2절 "부재중 자동 응답" · 디자인 README "4단계 › 부재중 자동 응답" (디자인 원본 SettingsVacation)
// 한도는 서버와 같은 값이다 — 켜 둘 때만 제목 · 내용이 필요하다 (서버가 최종 검증한다, 넘으면 400 VAL_001).
// 길이는 JS 문자열 길이(UTF-16 단위)로 센다. 엔진이 UTF-8 바이트로 막는다(제목 511 · 내용 2,047바이트) — 한 단위가 3바이트까지라
// 한글 · 이모지 등 어떤 글로 채워도 넘지 않는 값이다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거" — 4단계의 200자 · 4,000자는
// 한글로는 저장되지 않았다). 켤 때만 본다 — 꺼 둔 채로는 서버가 4단계 한도까지 받아, 4단계에 저장한 긴 글이 켜져 있어도 끌 수 있다
// (꺼 두면 칸이 흐려 고칠 수 없다).
/** 제목 — 켤 때 1~200자 */
export const VACATION_SUBJECT_MAX_LENGTH = 200
/** 내용 — 켤 때 1~4,000자 (글자만) */
export const VACATION_BODY_MAX_LENGTH = 4_000
/** 제목 — 켤 때 1~150자 */
export const VACATION_SUBJECT_MAX_LENGTH = 150
/** 내용 — 켤 때 1~600자 (글자만) */
export const VACATION_BODY_MAX_LENGTH = 600
/**
* 메일함 패널 안내를 다시 계산하는 간격 — 끝나는 시각이 지나면 사라지고, 시작 전 문구("10월 30일 ~ …")가 시작한 뒤의 문구로 바뀐다
* (안내의 날짜는 하루 단위라 1분이면 충분하다)
+25 -1
View File
@@ -2,7 +2,7 @@
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { IconName } from '@/shared/constants/icons'
import type { MailboxRole } from '@/shared/types/mail'
import type { MailboxRole, RejectedRecipientReason } from '@/shared/types/mail'
/**
* 메일함 정렬 순서 — 계약 2절 "메일함 정렬(확정 디자인 기준)".
@@ -138,6 +138,30 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
/**
* 받는 사람 일부가 받지 못한 채 보냈을 때 — "보냈습니다" 대신 오류 알림 모양(흰 카드) + [보낸편지함 보기]
* (계약 2절 "받는 사람별 결과" · 디자인 README "발송 · 부재중 결함 수정" — 캔버스에는 없다).
* 문장은 shared/utils/mail-send-result.ts 가 짓는다 — "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
*/
export const MAIL_PARTIAL_SEND_TEXT = {
/** count 는 formatCount 로 만든 글자, recipients 는 아래 recipient 를 separator 로 이은 것 (+ others) */
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다 — ${recipients}`,
/** 주소와 까닭 사이를 띄운다 — 긴 주소 뒤에서 줄을 바꿔 까닭 낱말이 쪼개지지 않게 */
recipient: (email: string, reason: string) => `${email} (${reason})`,
separator: ', ',
others: (count: string) => ` 외 ${count}명`,
/** 링크 글은 보냈다는 알림과 같다 */
action: MAIL_SENT_ACTION_LABEL,
} as const
/** 알림에 주소 · 까닭을 적는 사람 수 — 더 있으면 " 외 N명" */
export const MAIL_PARTIAL_SEND_LISTED = 2
/** 받지 못한 까닭 — 계약의 reason 값마다 */
export const MAIL_REJECTED_REASON_TEXT: Readonly<Record<RejectedRecipientReason, string>> = {
'not-found': '없는 주소',
refused: '받는 쪽이 거절',
temporary: '지금은 받을 수 없음',
}
/** 임시 저장본을 불러오는 동안의 쓰기 화면 제목 — 불러오면 답장 · 전달 · 새 메일로 바뀐다 */
export const COMPOSE_LOADING_TITLE = '메일 쓰기'
+18 -3
View File
@@ -171,9 +171,24 @@ export interface SendMessagePayload {
draftId?: string
}
/** POST /messages 응답 — 보낸편지함에 저장된 메일 id */
export interface SendMessageResult {
/** 엔진이 받는 사람을 받지 않은 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등) */
export type RejectedRecipientReason = 'not-found' | 'refused' | 'temporary'
/** 발송 응답의 받지 못한 사람 (계약 2절 "받는 사람별 결과") */
export interface RejectedRecipient {
/** 요청에 쓴 주소 그대로 — 받는 사람 · 참조 · 숨은참조 모두 */
email: string
reason: RejectedRecipientReason
}
/**
* POST /messages 201 응답 (계약 2절 "받는 사람별 결과") — 보낸편지함에 저장된 메일 id 와 받지 못한 사람.
* 받지 못한 사람이 있어도 보낸 것이다. 모두 받지 못했으면 201 이 아니라 400 MAIL_010 · 503 MAIL_011 (보내지 않았다)
*/
export interface SentMessage {
id: string
/** 엔진이 받지 않은 사람 — 없으면 빈 배열 */
rejectedRecipients: RejectedRecipient[]
}
// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
@@ -247,7 +262,7 @@ export interface MailboxBulkResult {
/**
* GET · PUT /mail-vacation — 저장은 전체 교체. 꺼도 쓴 제목 · 내용은 남긴다.
* 제목(1~200자) · 내용(1~4,000자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
* 제목(1~150자) · 내용(1~600자)은 켤 때만 필요하다 — 꺼져 있으면 빈 값도 된다. 설정한 적 없으면 꺼짐 · 빈 값이 온다
*/
export interface MailVacation {
isEnabled: boolean
@@ -60,6 +60,9 @@ describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계
'MAIL_002',
'MAIL_004',
'MAIL_006',
// 받는 사람 모두 거절 — 서버가 보낸 사본을 지웠으니 같은 키로 다시 보내면 새로 나간다 (계약 2절 "받는 사람별 결과")
'MAIL_010',
'MAIL_011',
])(
'%s 뒤에는 같은 키로 다시 보낸다 (나갔거나 처리 중일 수 있다)',
(code) => {
+2 -1
View File
@@ -45,7 +45,8 @@ export function createIdempotencyKey(): string {
* 다시 누르면 새 메일로 나가야 한다.
* 그 밖의 실패는 키를 그대로 둔다. MAIL_001·시간 초과·네트워크 오류는 실제로는 나갔을 수 있고,
* REQ_001 은 같은 키가 아직 처리 중일 수 있다. 같은 키로 다시 보내야 서버가 두 번 내보내지 않는다.
* (서버는 실제로 보낸 메일에만 키를 남기므로, 보내지 못한 실패 뒤 내용을 고쳐 같은 키로 보내도 MAIL_005 가 되지 않는다)
* (서버는 실제로 보낸 메일에만 키를 남기므로, 보내지 못한 실패 뒤 내용을 고쳐 같은 키로 보내도 MAIL_005 가 되지 않는다 —
* 받는 사람을 모두 거절한 MAIL_010 · MAIL_011 도 서버가 보낸 사본을 지워, 주소를 고쳐 같은 키로 보내면 새로 보낸다)
*/
export function shouldRenewSendKey(outcome: SendOutcome): boolean {
return outcome === 'sent' || outcome === 'MAIL_005'
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { RejectedRecipient, RejectedRecipientReason } from '@/shared/types/mail'
import { partialSendMessage } from './mail-send-result'
const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
const REFUSED: RejectedRecipient = { email: 'ceo@partner.example', reason: 'refused' }
const BUSY: RejectedRecipient = { email: 'busy@arasystem.it.kr', reason: 'temporary' }
describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송 알림 (계약 2절 "받는 사람별 결과")', () => {
it('받지 못한 사람이 없으면 null — 보냈다는 알림을 띄운다', () => {
expect(partialSendMessage([])).toBeNull()
})
it('1명 — "보냈지만 1명은 받지 못했습니다 — 주소 (까닭)"', () => {
expect(partialSendMessage([NOBODY])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)')
})
it.each<[RejectedRecipientReason, string]>([
['not-found', '없는 주소'],
['refused', '받는 쪽이 거절'],
['temporary', '지금은 받을 수 없음'],
])('까닭 %s 는 "%s"', (reason, text) => {
expect(partialSendMessage([{ email: 'a@arasystem.it.kr', reason }])).toBe(
`보냈지만 1명은 받지 못했습니다 — a@arasystem.it.kr (${text})`,
)
})
it('2명 — 두 사람 모두 "주소 (까닭)"를 ", " 로 잇는다', () => {
expect(partialSendMessage([NOBODY, REFUSED])).toBe(
'보냈지만 2명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절)',
)
})
it('3명 이상 — 앞의 두 명만 적고 나머지는 " 외 N명"', () => {
expect(partialSendMessage([NOBODY, REFUSED, BUSY])).toBe(
'보냈지만 3명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절) 외 1명',
)
const many = Array.from({ length: 5 }, (_, index) => ({ ...BUSY, email: `user${index + 1}@arasystem.it.kr` }))
expect(partialSendMessage(many)).toBe(
'보냈지만 5명은 받지 못했습니다 — user1@arasystem.it.kr (지금은 받을 수 없음), user2@arasystem.it.kr (지금은 받을 수 없음) 외 3명',
)
})
it.each(['bounced', 'constructor'])('계약에 없는 까닭(%s)이면 까닭 없이 주소만 적는다', (reason) => {
const recipient: RejectedRecipient = { email: NOBODY.email, reason: reason as string as RejectedRecipientReason }
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr')
})
})
@@ -0,0 +1,33 @@
// 발송 응답의 받는 사람별 결과 — 받지 못한 사람을 알리는 문장 (계약 2절 "받는 사람별 결과" — 순수 함수만 둔다)
// 일부만 받지 못했으면 보낸 것이다 — 쓰기 화면은 "보냈습니다" 대신 이 문장을 오류 알림 모양으로 띄우고 떠난다.
// 같은 주소는 서버가 한 번만 싣는다 (받는 사람 · 참조 · 숨은참조에 함께 넣었어도).
import {
MAIL_PARTIAL_SEND_LISTED,
MAIL_PARTIAL_SEND_TEXT,
MAIL_REJECTED_REASON_TEXT,
} from '@/shared/constants/mail'
import type { RejectedRecipient } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
/** 한 사람 — "주소 (까닭)". 계약에 없는 까닭(나중에 생긴 값 등)이면 주소만 적는다 */
function recipientText(recipient: RejectedRecipient): string {
const reason: unknown = MAIL_REJECTED_REASON_TEXT[recipient.reason]
return typeof reason === 'string' ? MAIL_PARTIAL_SEND_TEXT.recipient(recipient.email, reason) : recipient.email
}
/**
* 받는 사람 일부가 받지 못한 채 보냈을 때의 알림 문장 — 받지 못한 사람이 없으면 null (보냈다는 알림을 띄운다).
* 두 명까지 "주소 (까닭)"를 ", " 로 잇고, 더 있으면 " 외 N명":
* "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
*/
export function partialSendMessage(rejected: readonly RejectedRecipient[]): string | null {
if (rejected.length === 0) return null
const listed = rejected
.slice(0, MAIL_PARTIAL_SEND_LISTED)
.map(recipientText)
.join(MAIL_PARTIAL_SEND_TEXT.separator)
const othersCount = rejected.length - MAIL_PARTIAL_SEND_LISTED
const others = othersCount > 0 ? MAIL_PARTIAL_SEND_TEXT.others(formatCount(othersCount)) : ''
return MAIL_PARTIAL_SEND_TEXT.message(formatCount(rejected.length), `${listed}${others}`)
}
@@ -112,6 +112,17 @@ describe('저장하기 전 검사 — 켜 둘 때만', () => {
expect(vacationProblems(form())).toEqual([])
})
it('제목은 150자 · 내용은 600자까지 — 한글로 채워도 엔진의 바이트 한도 안이다 (계약 2절 "길이 한도의 근거")', () => {
expect(vacationProblems(form({ subject: '가'.repeat(150), textBody: '가'.repeat(600) }))).toEqual([])
expect(vacationProblems(form({ subject: '가'.repeat(151), textBody: '가'.repeat(601) }))).toEqual([
'subject',
'textBody',
])
// 길이는 JS 문자열 길이 — 이모지 하나는 2자다
expect(vacationProblems(form({ subject: '🌴'.repeat(75) }))).toEqual([])
expect(vacationProblems(form({ subject: `${'🌴'.repeat(75)}가` }))).toEqual(['subject'])
})
it('기간 정하기 — 날짜가 하나도 없거나 시작이 끝보다 늦으면 막는다 (같은 날은 하루)', () => {
expect(vacationProblems(form({ period: 'range' }))).toEqual(['period'])
expect(vacationProblems(form({ period: 'range', fromDay: '2026-11-04', toDay: '2026-11-03' }))).toEqual(['period'])
+2 -2
View File
@@ -90,8 +90,8 @@ function rangeOf(form: Pick<VacationForm, 'fromDay' | 'toDay'>): { fromDate: str
}
/**
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도 저장된다 — 계약 2절).
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~200자 · 내용 1~4,000자 (공백뿐이면 빈 것)
* 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도, 4단계에 저장한 긴 글도 저장된다 — 계약 2절).
* 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~150자 · 내용 1~600자 (공백뿐이면 빈 것)
*/
export function vacationProblems(form: VacationForm): VacationProblem[] {
if (!form.isEnabled) return []
+13
View File
@@ -41,6 +41,19 @@ describe('toast.store', () => {
expect(messages()).toEqual([])
})
it('오류 알림도 링크가 있으면 10초 — 받는 사람 일부가 받지 못한 발송의 [보낸편지함] 등', () => {
const store = useToastStore()
store.show({ kind: 'error', message: '실패했습니다' })
useToast().error('보냈지만 1명은 받지 못했습니다', { label: '보낸편지함', to: '/mail/sent' })
vi.advanceTimersByTime(TOAST_DURATION_MS.error)
expect(messages()).toEqual(['보냈지만 1명은 받지 못했습니다'])
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - TOAST_DURATION_MS.error - 1)
expect(messages()).toEqual(['보냈지만 1명은 받지 못했습니다'])
vi.advanceTimersByTime(1)
expect(messages()).toEqual([])
})
it('붙잡은 동안은 닫지 않고, 까닭이 모두 사라지면 시간을 처음부터 다시 센다', () => {
const store = useToastStore()
const id = store.show({ kind: 'success', message: '옮겼습니다', action: undo })
+2 -1
View File
@@ -5,7 +5,8 @@ import type { RouteLocationRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
// API 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
// API 오류가 아닌 실패(바탕화면 알림을 막아 둠 · 받는 사람 일부가 받지 못한 발송)는 화면이 useToast().error() 로 띄운다.
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
// 진행 알림(progress — 오래 걸리는 메일함 처리 "…하는 중 · 600 / 1,234통")은 끝날 때까지 남는다: 저절로 닫히지 않고,