diff --git a/frontend/src/components/feedback/ToastHost.vue b/frontend/src/components/feedback/ToastHost.vue
index 1e24d85..47500ce 100644
--- a/frontend/src/components/feedback/ToastHost.vue
+++ b/frontend/src/components/feedback/ToastHost.vue
@@ -1,6 +1,6 @@
@@ -975,6 +1018,7 @@ watch(allRecipients, () => {
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
:error-id="RECIPIENT_ERROR_ID"
+ :rejected="rejectedByServer !== null"
:disabled="preparing"
>
@@ -1001,6 +1045,7 @@ watch(allRecipients, () => {
input-id="compose-cc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
+ :rejected="rejectedByServer !== null"
:disabled="preparing"
/>
{
input-id="compose-bcc"
placeholder="메일 주소"
:error-id="RECIPIENT_ERROR_ID"
+ :rejected="rejectedByServer !== null"
:disabled="preparing"
/>
diff --git a/frontend/src/pages/settings/MailVacationPage.spec.ts b/frontend/src/pages/settings/MailVacationPage.spec.ts
index 186007c..034082d 100644
--- a/frontend/src/pages/settings/MailVacationPage.spec.ts
+++ b/frontend/src/pages/settings/MailVacationPage.spec.ts
@@ -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)
diff --git a/frontend/src/shared/constants/mail-vacation.ts b/frontend/src/shared/constants/mail-vacation.ts
index d30b0c0..bfe02bb 100644
--- a/frontend/src/shared/constants/mail-vacation.ts
+++ b/frontend/src/shared/constants/mail-vacation.ts
@@ -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분이면 충분하다)
diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts
index 4b9c465..a600e6c 100644
--- a/frontend/src/shared/constants/mail.ts
+++ b/frontend/src/shared/constants/mail.ts
@@ -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> = {
+ 'not-found': '없는 주소',
+ refused: '받는 쪽이 거절',
+ temporary: '지금은 받을 수 없음',
+}
+
/** 임시 저장본을 불러오는 동안의 쓰기 화면 제목 — 불러오면 답장 · 전달 · 새 메일로 바뀐다 */
export const COMPOSE_LOADING_TITLE = '메일 쓰기'
diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts
index 50de694..4ee15d9 100644
--- a/frontend/src/shared/types/mail.ts
+++ b/frontend/src/shared/types/mail.ts
@@ -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
diff --git a/frontend/src/shared/utils/idempotency-key.spec.ts b/frontend/src/shared/utils/idempotency-key.spec.ts
index 267b49b..50f0b34 100644
--- a/frontend/src/shared/utils/idempotency-key.spec.ts
+++ b/frontend/src/shared/utils/idempotency-key.spec.ts
@@ -60,6 +60,9 @@ describe('shouldRenewSendKey — 키는 성공과 MAIL_005 때만 바꾼다 (계
'MAIL_002',
'MAIL_004',
'MAIL_006',
+ // 받는 사람 모두 거절 — 서버가 보낸 사본을 지웠으니 같은 키로 다시 보내면 새로 나간다 (계약 2절 "받는 사람별 결과")
+ 'MAIL_010',
+ 'MAIL_011',
])(
'%s 뒤에는 같은 키로 다시 보낸다 (나갔거나 처리 중일 수 있다)',
(code) => {
diff --git a/frontend/src/shared/utils/idempotency-key.ts b/frontend/src/shared/utils/idempotency-key.ts
index 7ae1a05..c878007 100644
--- a/frontend/src/shared/utils/idempotency-key.ts
+++ b/frontend/src/shared/utils/idempotency-key.ts
@@ -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'
diff --git a/frontend/src/shared/utils/mail-send-result.spec.ts b/frontend/src/shared/utils/mail-send-result.spec.ts
new file mode 100644
index 0000000..51daf14
--- /dev/null
+++ b/frontend/src/shared/utils/mail-send-result.spec.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/shared/utils/mail-send-result.ts b/frontend/src/shared/utils/mail-send-result.ts
new file mode 100644
index 0000000..fb62bb1
--- /dev/null
+++ b/frontend/src/shared/utils/mail-send-result.ts
@@ -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}`)
+}
diff --git a/frontend/src/shared/utils/mail-vacation.spec.ts b/frontend/src/shared/utils/mail-vacation.spec.ts
index 208af0e..e562145 100644
--- a/frontend/src/shared/utils/mail-vacation.spec.ts
+++ b/frontend/src/shared/utils/mail-vacation.spec.ts
@@ -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'])
diff --git a/frontend/src/shared/utils/mail-vacation.ts b/frontend/src/shared/utils/mail-vacation.ts
index 4616dfb..6ba7bc4 100644
--- a/frontend/src/shared/utils/mail-vacation.ts
+++ b/frontend/src/shared/utils/mail-vacation.ts
@@ -90,8 +90,8 @@ function rangeOf(form: Pick): { fromDate: str
}
/**
- * 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도 저장된다 — 계약 2절).
- * 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~200자 · 내용 1~4,000자 (공백뿐이면 빈 것)
+ * 저장하기 전에 막는 문제 — 켜 둘 때만 본다 (꺼 두면 빈 제목 · 내용도, 4단계에 저장한 긴 글도 저장된다 — 계약 2절).
+ * 기간 정하기: 날짜가 하나도 없거나, 틀렸거나, 시작이 끝보다 늦다 · 제목 1~150자 · 내용 1~600자 (공백뿐이면 빈 것)
*/
export function vacationProblems(form: VacationForm): VacationProblem[] {
if (!form.isEnabled) return []
diff --git a/frontend/src/stores/toast.store.spec.ts b/frontend/src/stores/toast.store.spec.ts
index acd6ec3..4a2f7ce 100644
--- a/frontend/src/stores/toast.store.spec.ts
+++ b/frontend/src/stores/toast.store.spec.ts
@@ -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 })
diff --git a/frontend/src/stores/toast.store.ts b/frontend/src/stores/toast.store.ts
index 386be09..0c2afe1 100644
--- a/frontend/src/stores/toast.store.ts
+++ b/frontend/src/stores/toast.store.ts
@@ -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통")은 끝날 때까지 남는다: 저절로 닫히지 않고,