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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user