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:
2026-10-28 17:11:26 +09:00
parent 19c20eb617
commit 3634cee955
25 changed files with 707 additions and 69 deletions
+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 {