generated from ttipo/project-base
fix(frontend): 받는 사람별 발송 결과 알림 · 보내는 중 세션 바뀜 막기 · 부재중 한도
- 일부 거절: 보낸 것으로 떠나며 "보냈지만 N명은 받지 못했습니다 — 주소 (까닭)" 오류 모양 알림 + [보낸편지함 보기] - MAIL_010: 받는 사람 안내 띠 · 초점(알림 없음, 칸은 aria-invalid · aria-describedby), MAIL_011: 인터셉터 알림 — 둘 다 같은 키로 다시 보낸다 - 보내기를 시작한 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 보내지 않고, 결과 알림 · 보내지 못한 메일의 저장본 남기기도 하지 않는다 — 다른 계정의 쿠키로 앞 사용자의 메일이 나가거나 저장되던 틈 - 응답의 rejectedRecipients 는 sendMessage 한 곳에서 배열로 맞춘다(필드가 없는 옛 서버) - 부재중 자동 응답 제목 150 · 내용 600 (켤 때 검사) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 : [] }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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분이면 충분하다)
|
||||
|
||||
@@ -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 = '메일 쓰기'
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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통")은 끝날 때까지 남는다: 저절로 닫히지 않고,
|
||||
|
||||
Reference in New Issue
Block a user