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
@@ -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)