()
+
+const mailStore = useMailStore()
+const blockStore = useMailBlockStore()
+const scheduledActions = useScheduledMessageActions()
+const senderBlock = useSenderBlock()
+
+/** 예약 줄 · 차단 줄의 단추가 하는 중 */
+const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
+
+const pending = computed(() => messagePendingSend(props.message))
+const scheduledTime = computed(() =>
+ pending.value?.kind === 'scheduled' ? formatScheduleTime(pending.value.sendAt) : null,
+)
+/** 스팸메일함에 든 메일의 보낸 사람이 차단되어 있으면 그 차단 */
+const senderBlockEntry = computed(() => {
+ const junkId = mailStore.mailboxByRole('junk')?.id
+ if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
+ return blockStore.blockOf(props.message.from?.email)
+})
+const bodyNotices = computed(() => mailBodyNotices(props.message))
+const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
+
+async function showImages() {
+ if (imagesLoading.value) return
+ if (await mailStore.showImages()) emit('settled')
+}
+
+/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
+async function runScheduled(kind: 'edit' | 'cancel') {
+ if (working.value) return
+ working.value = kind
+ let done = false
+ try {
+ done =
+ kind === 'edit'
+ ? await scheduledActions.editScheduled(props.message.id)
+ : await scheduledActions.cancelScheduled(props.message, props.layout)
+ } finally {
+ working.value = null
+ }
+ if (done) emit('settled')
+}
+
+async function unblock() {
+ const entry = senderBlockEntry.value
+ if (!entry || working.value) return
+ working.value = 'unblock'
+ try {
+ if (await senderBlock.unblock(entry.address)) emit('settled')
+ } finally {
+ working.value = null
+ }
+}
+
+
+
+
+
+
{{ SEND_LATER_TEXT.notice(scheduledTime) }}
+
+
+
+
+
+
+
{{ MAIL_BLOCK_TEXT.senderNotice }}
+
+
+
+
+
+
+ {{ notice.text }}
+
+
+
+
+
{{ MAIL_IMAGES_TEXT.blocked }}
+
+
+
+
+
diff --git a/frontend/src/components/mail/MessageSpamButton.vue b/frontend/src/components/mail/MessageSpamButton.vue
index 6ad67f5..13d51bb 100644
--- a/frontend/src/components/mail/MessageSpamButton.vue
+++ b/frontend/src/components/mail/MessageSpamButton.vue
@@ -30,11 +30,12 @@ const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox'
async function onClick() {
if (working.value) return
+ const target = props.message
const source = mailStore.listSource
working.value = true
try {
- const outcome = await removal.moveSpam(props.message)
- await removal.leaveRemoved(outcome, source, props.layout)
+ const outcome = await removal.moveSpam(target)
+ await removal.leaveRemoved(outcome, source, props.layout, target.id)
} finally {
working.value = false
}
diff --git a/frontend/src/components/mail/MessageTable.spec.ts b/frontend/src/components/mail/MessageTable.spec.ts
index 0d06a31..83ce752 100644
--- a/frontend/src/components/mail/MessageTable.spec.ts
+++ b/frontend/src/components/mail/MessageTable.spec.ts
@@ -258,6 +258,41 @@ describe('MessageTable (목록 보기)', () => {
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
})
+ it('아직 보내지 않은 메일(보내는 중 · 예약 — 9단계)은 고를 수 없다 — 꺼진 상자(aria-disabled · 까닭 title), 누름 · Ctrl · Shift 도 · 머리 상자도 건너뛴다 (리뷰 #1)', async () => {
+ const held = [
+ summary('m1', { mailboxIds: ['sent'] }),
+ summary('u1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } }),
+ summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' } }),
+ summary('m2', { mailboxIds: ['sent'] }),
+ ]
+ const { wrapper, router, store } = await mountTable({ mailbox: SENT, messages: held })
+ const boxes = () => wrapper.findAll('tbody [role="checkbox"]')
+ const rows = () => wrapper.findAll('tbody tr')
+
+ expect(boxes().map((box) => box.attributes('aria-disabled') ?? null)).toEqual([null, 'true', 'true', null])
+ expect(boxes()[1]?.attributes('title')).toBe(
+ '보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
+ )
+ expect(boxes()[0]?.attributes('title')).toBeUndefined()
+
+ await boxes()[1]?.trigger('click')
+ await rows()[2]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
+ await flushPromises()
+ expect(store.checkedCount).toBe(0)
+ // 수정 글쇠 누름은 메일도 열지 않는다
+ expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
+
+ // 범위 · 모두 고르기는 붙잡은 메일을 건너뛴다
+ await boxes()[0]?.trigger('click')
+ await rows()[3]?.get('.mail-table__who').trigger('click', { shiftKey: true })
+ expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
+ expect(wrapper.get('thead [role="checkbox"]').attributes('aria-checked')).toBe('true')
+ await wrapper.get('thead [role="checkbox"]').trigger('click')
+ expect(store.checkedCount).toBe(0)
+ await wrapper.get('thead [role="checkbox"]').trigger('click')
+ expect([...store.checkedIds].sort()).toEqual(['m1', 'm2'])
+ })
+
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
const { wrapper, store } = await mountTable({ messages: three() })
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
diff --git a/frontend/src/components/mail/MessageTableRow.vue b/frontend/src/components/mail/MessageTableRow.vue
index f78fdeb..b241aec 100644
--- a/frontend/src/components/mail/MessageTableRow.vue
+++ b/frontend/src/components/mail/MessageTableRow.vue
@@ -3,6 +3,8 @@
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
+// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
+// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
@@ -10,9 +12,11 @@ import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
+import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
-import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
+import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
+import { isHeldMessage } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
@@ -43,6 +47,10 @@ const who = computed(() => rowWho(props.message, { role: props.role, mixed: prop
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
+/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
+const pendingLabel = computed(() => pendingSendLabel(props.message))
+/** 아직 보내지 않은 메일 — 고를 수 없다 */
+const held = computed(() => isHeldMessage(props.message))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent) {
@@ -50,7 +58,7 @@ function onRowClick(event: MouseEvent) {
if (target?.closest('button')) return
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
- emit('check', props.message.id, event.shiftKey)
+ if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
@@ -77,6 +85,8 @@ function onMouseDown(event: MouseEvent) {
class="mail-table__check-button"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
+ :unavailable="held"
+ :note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
/>
@@ -127,8 +137,13 @@ function onMouseDown(event: MouseEvent) {
/>
-
-
+ |
+
|
{{ formatFileSize(message.size) }}
@@ -239,4 +254,10 @@ function onMouseDown(event: MouseEvent) {
font-weight: 600;
color: var(--color-text);
}
+
+/* 아직 보내지 않은 내 메일 — 청록 굵게 (디자인 원본 MailSendLater) */
+.mail-table__row .mail-table__date.is-pending {
+ font-weight: 600;
+ color: var(--color-brand-strong);
+}
diff --git a/frontend/src/components/mail/ScheduledMessageItem.vue b/frontend/src/components/mail/ScheduledMessageItem.vue
new file mode 100644
index 0000000..199df15
--- /dev/null
+++ b/frontend/src/components/mail/ScheduledMessageItem.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+ {{ who.text }}
+
+ {{ subject }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/mail/ScheduledMessageList.vue b/frontend/src/components/mail/ScheduledMessageList.vue
new file mode 100644
index 0000000..a490af2
--- /dev/null
+++ b/frontend/src/components/mail/ScheduledMessageList.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/settings/SettingsPageFrame.vue b/frontend/src/components/settings/SettingsPageFrame.vue
index 745a9fb..46f930f 100644
--- a/frontend/src/components/settings/SettingsPageFrame.vue
+++ b/frontend/src/components/settings/SettingsPageFrame.vue
@@ -2,6 +2,7 @@
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
+// 제목 바로 옆(같은 줄 · 글자 아래 선)에 작은 글을 둘 때는 title-aside 슬롯 (디자인 원본 SettingsSendRead 의 "3 / 500")
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: …
interface Props {
@@ -22,12 +23,15 @@ defineProps()
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
+
{{ description }}
@@ -80,6 +84,13 @@ defineProps ()
gap: var(--space-4);
}
+.settings-page__title-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-4) var(--space-10);
+}
+
.settings-page__title {
margin: 0;
font-size: var(--font-size-title-lg);
diff --git a/frontend/src/composables/useMail.spec.ts b/frontend/src/composables/useMail.spec.ts
index f5133f5..374d102 100644
--- a/frontend/src/composables/useMail.spec.ts
+++ b/frontend/src/composables/useMail.spec.ts
@@ -43,11 +43,89 @@ describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
id: 'sent-1',
rejectedRecipients,
+ pendingSend: null,
})
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
})
})
+describe('useMail.sendMessage — 붙잡아 둔 발송 pendingSend 는 늘 값 또는 null (계약 2절 "보내기 취소 · 예약 발송" — 9단계)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ afterEach(() => {
+ restoreApiAdapter()
+ })
+
+ const UNDO = { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' }
+ const SCHEDULED = { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' }
+
+ it.each([
+ ['보내기 취소를 기다리는 중이면 그대로', UNDO, UNDO],
+ ['예약이면 그대로', SCHEDULED, SCHEDULED],
+ ['바로 보냈으면(null) null', null, null],
+ ['필드가 없는 옛 서버 응답은 null', undefined, null],
+ ['모르는 종류는 null — 보내기 취소 단추를 띄우지 않는다', { kind: 'later', sendAt: SCHEDULED.sendAt }, null],
+ ['시각으로 읽을 수 없으면 null', { kind: 'undo', sendAt: '곧' }, null],
+ ])('%s', async (_label, pendingSend, expected) => {
+ installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], ...(pendingSend === undefined ? {} : { pendingSend }) }, 201))
+
+ await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
+ id: 'sent-1',
+ rejectedRecipients: [],
+ pendingSend: expected,
+ })
+ })
+
+ it('예약 발송은 본문의 sendAt(UTC ISO)을 그대로 싣는다', async () => {
+ const requests = installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: SCHEDULED }, 201))
+
+ await useMail().sendMessage({ ...PAYLOAD, sendAt: SCHEDULED.sendAt }, { idempotencyKey: 'key-1' })
+
+ expect(requests[0]?.data).toMatchObject({ sendAt: SCHEDULED.sendAt })
+ })
+})
+
+describe('useMail 예약됨 · 보내기 취소 · 외부 이미지 (9단계)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ })
+
+ afterEach(() => {
+ restoreApiAdapter()
+ })
+
+ it('예약한 메일 목록은 GET /scheduled-messages 에 cursor · limit 을 싣는다', async () => {
+ const requests = installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
+
+ await useMail().listScheduledMessages({ limit: 1 })
+ await useMail().listScheduledMessages({ cursor: 'c2', limit: 20 })
+
+ expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
+ ['GET', '/scheduled-messages', { limit: 1 }],
+ ['GET', '/scheduled-messages', { cursor: 'c2', limit: 20 }],
+ ])
+ })
+
+ it('붙잡은 발송 취소는 DELETE /scheduled-messages/{id} — id 는 인코딩하고 { draftId } 를 돌려준다', async () => {
+ const requests = installMockApi(() => ok({ draftId: 'a/b' }))
+
+ await expect(useMail().cancelScheduledMessage('a/b')).resolves.toEqual({ draftId: 'a/b' })
+ expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['DELETE /scheduled-messages/a%2Fb'])
+ })
+
+ it('외부 이미지 보기는 GET /messages/{id}?images=show — 주지 않으면 쿼리 없이 (서버가 설정을 따른다)', async () => {
+ const requests = installMockApi(() => ok({ id: 'm1' }))
+
+ await useMail().getMessage('m1', { showImages: true, silent: true })
+ await useMail().getMessage('m1')
+
+ expect(requests.map((request) => request.params)).toEqual([{ images: 'show' }, {}])
+ expect(requests[0]?.config.silent).toBe(true)
+ })
+})
+
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
const SET: MailFilterSet = {
rules: [
diff --git a/frontend/src/composables/useMail.ts b/frontend/src/composables/useMail.ts
index 8e7cdf2..bf2692b 100644
--- a/frontend/src/composables/useMail.ts
+++ b/frontend/src/composables/useMail.ts
@@ -4,6 +4,8 @@ import { buildApiUrl, confirmSession, useApi } from './useApi'
import {
MAIL_FILTER_RUN_TIMEOUT_MS,
MAIL_IDEMPOTENCY_HEADER,
+ MAIL_IMAGES_PARAM,
+ MAIL_IMAGES_SHOW,
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
@@ -37,16 +39,23 @@ import type {
RecipientSuggestion,
RecipientSuggestionQuery,
SaveSignaturePayload,
+ ScheduledMessageCancel,
SendMessagePayload,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
-import type { PaginatedResult } from '@/shared/types/pagination'
+import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
+import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type MailRequestOptions = Pick
+/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
+export interface GetMessageOptions extends MailRequestOptions {
+ showImages?: boolean
+}
+
export interface SendMessageOptions extends MailRequestOptions {
/**
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
@@ -55,8 +64,14 @@ export interface SendMessageOptions extends MailRequestOptions {
idempotencyKey: string
}
-/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
-type SentMessageReply = Omit & { rejectedRecipients?: unknown }
+/**
+ * POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients · pendingSend 가 없다
+ * (sendMessage 가 배열 · PendingSend 또는 null 로 맞춘다)
+ */
+type SentMessageReply = Omit & {
+ rejectedRecipients?: unknown
+ pendingSend?: unknown
+}
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
@@ -221,9 +236,17 @@ export function useMail() {
})
}
- /** 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다 */
- function getMessage(id: string, options?: MailRequestOptions): Promise {
- return api.get(`/messages/${segment(id)}`, options)
+ /**
+ * 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
+ * showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
+ * (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
+ */
+ function getMessage(id: string, options: GetMessageOptions = {}): Promise {
+ const { showImages, ...requestOptions } = options
+ return api.get(`/messages/${segment(id)}`, {
+ ...requestOptions,
+ ...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
+ })
}
function updateMessage(
@@ -330,7 +353,33 @@ export function useMail() {
...requestOptions,
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
})
- return { ...sent, rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [] }
+ return {
+ ...sent,
+ rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [],
+ // 엔진이 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계) — 없거나 모양이 틀리면 바로 보낸 것으로 본다
+ pendingSend: pendingSendOf(sent.pendingSend),
+ }
+ }
+
+ // ── 보내기 취소 · 예약 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) ─────────────
+
+ /** 예약한 메일 — 보낼 시각 차례, total 이 있다. 메일함 패널의 「예약됨」은 limit=1 로 total 만 쓴다 */
+ function listScheduledMessages(
+ query: CursorPaginationQuery,
+ options?: MailRequestOptions,
+ ): Promise> {
+ return api.get, PaginatedResult>('/scheduled-messages', {
+ ...options,
+ params: query,
+ })
+ }
+
+ /**
+ * 붙잡은 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id. 엔진에서 취소한 뒤 그 메일을 임시보관함으로 옮긴다
+ * → { draftId }(같은 id — 쓰기 화면이 이어 쓴다). 이미 보냈으면 409 MAIL_014 (아무것도 바꾸지 않는다), 없는 메일은 404 RES_001
+ */
+ function cancelScheduledMessage(id: string, options?: MailRequestOptions): Promise {
+ return api.delete(`/scheduled-messages/${segment(id)}`, options)
}
/**
@@ -408,6 +457,8 @@ export function useMail() {
updateMessages,
deleteMessages,
sendMessage,
+ listScheduledMessages,
+ cancelScheduledMessage,
uploadAttachment,
attachmentDownloadUrl,
downloadAttachment,
diff --git a/frontend/src/composables/useMailPrint.ts b/frontend/src/composables/useMailPrint.ts
new file mode 100644
index 0000000..8943937
--- /dev/null
+++ b/frontend/src/composables/useMailPrint.ts
@@ -0,0 +1,38 @@
+import { useRouter, type RouteLocationRaw } from 'vue-router'
+
+import { MAIL_IMAGES_PARAM, MAIL_IMAGES_SHOW } from '@/shared/constants/mail'
+import { MAIL_PRINT_ROUTE, MAIL_PRINT_SHORTCUT_KEYS } from '@/shared/constants/mail-print'
+import type { MessageDetail } from '@/shared/types/mail'
+import { currentPlatform, formatShortcut, isApplePlatform, isShortcutEvent } from '@/shared/utils/keyboard-shortcut'
+
+/**
+ * 인쇄 — 읽기 화면 ⋯ › 인쇄 · Ctrl+P(맥 ⌘P) (디자인 README "9단계 › 인쇄 화면" · 계약 2절 "인쇄").
+ * 새 탭(noopener — 새 탭이 이 창을 건드리지 못하게)에 인쇄 화면(/mail/print/ — 앱 틀 없음)을 연다. 인쇄 화면이 본문을 인쇄 폭에 맞춰 잰 뒤
+ * 브라우저의 인쇄 창을 띄운다(MailPrintPage). 읽기 화면에서 외부 이미지를 보이고 있었으면 ?images=show — 인쇄에도 그대로 보인다
+ */
+export function useMailPrint() {
+ const router = useRouter()
+ const apple = isApplePlatform(currentPlatform())
+
+ /** 메뉴에 보이는 단축키 — "Ctrl+P" · "⌘P" */
+ const shortcut = formatShortcut(MAIL_PRINT_SHORTCUT_KEYS, apple)
+
+ function printLocation(message: Pick): RouteLocationRaw {
+ return {
+ name: MAIL_PRINT_ROUTE,
+ params: { messageId: message.id },
+ query: message.imagesShown ? { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } : {},
+ }
+ }
+
+ function openPrint(message: Pick): void {
+ window.open(router.resolve(printLocation(message)).href, '_blank', 'noopener')
+ }
+
+ /** 인쇄 단축키인가 — 한글 조합 중 키 · 이미 처리한 키는 아니다 */
+ function isPrintShortcut(event: KeyboardEvent): boolean {
+ return !event.defaultPrevented && !event.isComposing && isShortcutEvent(event, MAIL_PRINT_SHORTCUT_KEYS, apple)
+ }
+
+ return { shortcut, printLocation, openPrint, isPrintShortcut }
+}
diff --git a/frontend/src/composables/useMailSettings.ts b/frontend/src/composables/useMailSettings.ts
new file mode 100644
index 0000000..6d9bf35
--- /dev/null
+++ b/frontend/src/composables/useMailSettings.ts
@@ -0,0 +1,47 @@
+import type { AxiosRequestConfig } from 'axios'
+
+import { useApi } from './useApi'
+import type { BlockedSender, MailBlockList, MailSettings } from '@/shared/types/mail'
+
+/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
+export type MailSettingsRequestOptions = Pick
+
+/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
+const segment = (value: string) => encodeURIComponent(value)
+
+// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
+// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
+// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
+export function useMailSettings() {
+ const api = useApi()
+
+ /** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
+ function getSettings(options?: MailSettingsRequestOptions): Promise {
+ return api.get('/mail-settings', options)
+ }
+
+ /** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
+ function updateSettings(patch: Partial, options?: MailSettingsRequestOptions): Promise {
+ return api.patch('/mail-settings', patch, options)
+ }
+
+ /** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
+ function getBlocks(options?: MailSettingsRequestOptions): Promise {
+ return api.get('/mail-blocks', options)
+ }
+
+ /**
+ * 차단 — 주소 또는 "@도메인". 201(이미 있으면 200) BlockedSender. 형식 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 ·
+ * 500개 초과는 400 VAL_001, 엔진 규칙 한도는 400 MAIL_013
+ */
+ function addBlock(address: string, options?: MailSettingsRequestOptions): Promise {
+ return api.post('/mail-blocks', { address }, options)
+ }
+
+ /** 차단 풀기 — 없으면 404 RES_001 */
+ function removeBlock(address: string, options?: MailSettingsRequestOptions): Promise<{ address: string }> {
+ return api.delete<{ address: string }, { address: string }>(`/mail-blocks/${segment(address)}`, options)
+ }
+
+ return { getSettings, updateSettings, getBlocks, addBlock, removeBlock }
+}
diff --git a/frontend/src/composables/useMessageRemoval.ts b/frontend/src/composables/useMessageRemoval.ts
index 1fb0e11..f4a5b8d 100644
--- a/frontend/src/composables/useMessageRemoval.ts
+++ b/frontend/src/composables/useMessageRemoval.ts
@@ -4,7 +4,7 @@ import { notifyApiError } from './useApi'
import { useMailMoveFeedback } from './useMailMoveFeedback'
import { useMailNavigation } from './useMailNavigation'
import type { MessageSummary } from '@/shared/types/mail'
-import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
+import { listLocationOf, messageLocationOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
@@ -94,13 +94,25 @@ export function useMessageRemoval() {
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
}
- /** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
+ /**
+ * 그 메일을 아직 그 목록에서 보고 있는가 — 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 아니다
+ * (그때 옛 목록의 다음 메일로 바꿔 끼우면 사용자가 연 화면을 빼앗는다 — 2026-10-29 리뷰)
+ */
+ function stillShowing(source: MailListSource | null, messageId: string): boolean {
+ return sameListSource(mailStore.listSource, source) && mailStore.selectedId === messageId
+ }
+
+ /**
+ * 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록, messageId 는 뺀 메일.
+ * 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 움직이지 않는다 (stillShowing)
+ */
async function leaveRemoved(
outcome: MessageRemovalOutcome,
source: MailListSource | null,
layout: 'split' | 'list',
+ messageId: string,
): Promise {
- if (!outcome.removed || !source) return
+ if (!outcome.removed || !source || !stillShowing(source, messageId)) return
if (layout === 'list') {
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
await backToSourceList(source)
@@ -109,5 +121,5 @@ export function useMessageRemoval() {
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
}
- return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
+ return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, stillShowing, leaveRemoved }
}
diff --git a/frontend/src/composables/usePendingSendRefresh.spec.ts b/frontend/src/composables/usePendingSendRefresh.spec.ts
new file mode 100644
index 0000000..25ca67b
--- /dev/null
+++ b/frontend/src/composables/usePendingSendRefresh.spec.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { effectScope, type EffectScope } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+
+import type { MessageSummary, PendingSend } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailStore } from '@/stores/mail.store'
+import { useToastStore } from '@/stores/toast.store'
+import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
+import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
+import { usePendingSendRefresh } from './usePendingSendRefresh'
+
+// 엔진이 붙잡은 발송을 풀 때(보낼 시각) 다시 보기 (계약 2절 "보내기 취소 · 예약 발송" · 2026-10-29 리뷰 #2)
+// 새 메일 확인 · 실시간 알림의 state 는 엔진이 풀어도 바뀌지 않는다 — 보낼 시각 + 2초에 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다.
+// 손으로 돌리는 시계로 시간을 넘긴다 (진짜 타이머 · 고정 대기 없이)
+
+const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
+const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
+
+function summary(id: string, pendingSend: PendingSend | null): MessageSummary {
+ return {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: ['sent'],
+ from: { name: '김아라', email: USER.email },
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ subject: `제목 ${id}`,
+ preview: '',
+ receivedAt: '2026-10-29T05:59:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend,
+ }
+}
+
+const at = (ms: number) => new Date(NOW + ms).toISOString()
+const page = (items: MessageSummary[]) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
+
+describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', () => {
+ let clock: ManualClock
+ let scope: EffectScope
+ let requests: MockRequest[]
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated(USER)
+ clock = createManualClock(NOW)
+ scope = effectScope()
+ })
+
+ afterEach(() => {
+ scope.stop()
+ restoreApiAdapter()
+ useToastStore().clear()
+ })
+
+ /** 보낸편지함 목록에 붙잡은 발송 하나를 두고 시작한다 — 서버는 sent() 가 주는 목록을 돌려준다 */
+ function start(initial: MessageSummary[], sent: () => MessageSummary[] = () => initial) {
+ requests = installMockApi((request) => {
+ if (request.url === '/scheduled-messages') return page([])
+ if (request.url === '/messages') return page(sent())
+ return ok({})
+ })
+ const store = useMailStore()
+ store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
+ store.messages = initial
+ store.pageInfo = { hasNext: false, nextCursor: null, total: initial.length }
+ store.listStatus = 'ready'
+ scope.run(() => usePendingSendRefresh({ clock }))
+ return store
+ }
+
+ const listRequests = () => requests.filter((request) => request.url === '/messages')
+ const countRequests = () => requests.filter((request) => request.url === '/scheduled-messages')
+
+ async function advance(ms: number) {
+ clock.advance(ms)
+ await flushPromises()
+ }
+
+ it('보낼 시각 + 2초에 한 번 — 보이는 목록 · 예약됨 수를 알리지 않고 다시 보고, 풀렸으면 "보내는 중"이 내려간다', async () => {
+ const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
+ const store = start([held], () => [summary('u1', null)])
+
+ await advance(11_999)
+ expect(listRequests()).toEqual([])
+
+ await advance(1)
+ expect(listRequests()).toHaveLength(1)
+ expect(listRequests()[0]?.config.silent).toBe(true)
+ expect(countRequests()).toHaveLength(1)
+ expect(store.messages[0]?.pendingSend).toBeNull()
+ // 지켜볼 것이 없다 — 더 걸지 않는다
+ expect(clock.pendingCount()).toBe(0)
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('다시 본 뒤에도 시각이 지난 채 남아 있으면 5초마다 세 번 더 보고, 그래도 남으면 멈춘다', async () => {
+ const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
+ start([held])
+
+ await advance(12_000)
+ expect(listRequests()).toHaveLength(1)
+ for (let recheck = 1; recheck <= 3; recheck += 1) {
+ await advance(4_999)
+ expect(listRequests()).toHaveLength(recheck)
+ await advance(1)
+ expect(listRequests()).toHaveLength(recheck + 1)
+ }
+
+ await advance(60_000)
+ expect(listRequests()).toHaveLength(4)
+ expect(clock.pendingCount()).toBe(0)
+ })
+
+ it('처음부터 시각이 지나 보이는 발송(이 PC 시계가 빠름)은 바로 묻지 않고 5초 뒤부터 다시 본다', async () => {
+ start([summary('u1', { kind: 'undo', sendAt: at(-60_000) })], () => [summary('u1', null)])
+
+ await advance(4_999)
+ expect(listRequests()).toEqual([])
+ await advance(1)
+ expect(listRequests()).toHaveLength(1)
+ })
+
+ it('가장 이른 시각부터 차례로 — 앞 것이 풀리면 다음 것의 시각에 다시 잡는다', async () => {
+ let released = new Set()
+ const items = [summary('u1', { kind: 'undo', sendAt: at(5_000) }), summary('s1', { kind: 'scheduled', sendAt: at(60_000) })]
+ start(items, () => items.map((item) => (released.has(item.id) ? { ...item, pendingSend: null } : item)))
+
+ released = new Set(['u1'])
+ await advance(7_000)
+ expect(listRequests()).toHaveLength(1)
+
+ released = new Set(['u1', 's1'])
+ await advance(54_999)
+ expect(listRequests()).toHaveLength(1)
+ await advance(1)
+ expect(listRequests()).toHaveLength(2)
+ expect(clock.pendingCount()).toBe(0)
+ })
+
+ it('목록 밖의 메일도 — 열린 메일 · 가장 이른 예약(예약됨 수의 첫 줄)의 시각도 지켜본다', async () => {
+ const store = start([])
+ store.nextScheduled = summary('s9', { kind: 'scheduled', sendAt: at(30_000) })
+ await flushPromises()
+
+ await advance(32_000)
+ expect(countRequests()).toHaveLength(1)
+ })
+
+ it('메일 화면이 사라지면 멈춘다', async () => {
+ start([summary('u1', { kind: 'undo', sendAt: at(10_000) })])
+ expect(clock.pendingCount()).toBe(1)
+
+ scope.stop()
+ await advance(20_000)
+
+ expect(listRequests()).toEqual([])
+ expect(clock.pendingCount()).toBe(0)
+ })
+})
diff --git a/frontend/src/composables/usePendingSendRefresh.ts b/frontend/src/composables/usePendingSendRefresh.ts
new file mode 100644
index 0000000..e2b21ef
--- /dev/null
+++ b/frontend/src/composables/usePendingSendRefresh.ts
@@ -0,0 +1,110 @@
+import { computed, onScopeDispose, watch } from 'vue'
+
+import { systemClock, type Clock, type ClockTimer } from './useClock'
+import {
+ PENDING_RELEASE_GRACE_MS,
+ PENDING_RELEASE_MAX_RECHECKS,
+ PENDING_RELEASE_RECHECK_MS,
+} from '@/shared/constants/mail-send-later'
+import {
+ nextPendingCheckAt,
+ overduePendingWatches,
+ pendingWatchesOf,
+ type PendingWatch,
+} from '@/shared/utils/pending-send'
+import { useMailStore } from '@/stores/mail.store'
+
+// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
+// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
+// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
+// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
+// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
+// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
+// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
+// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
+
+export interface PendingSendRefreshOptions {
+ clock?: Clock
+}
+
+export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
+ const mailStore = useMailStore()
+ const clock = options.clock ?? systemClock
+
+ let timer: ClockTimer | null = null
+ let refreshing = false
+ let stopped = false
+ /** 다시 본 뒤에도 시각이 지난 채 남은 차례 (연달아) — 넘으면 그때 남은 발송은 그만 본다 */
+ let overdueStreak = 0
+ /** 그만 보는 발송 (PendingWatch.key) — 지켜볼 것에서 사라지면 잊는다 */
+ const givenUp = new Set()
+
+ const watches = computed(() =>
+ pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
+ )
+
+ /** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
+ function activeWatches(): PendingWatch[] {
+ const current = watches.value
+ const keys = new Set(current.map((watch) => watch.key))
+ for (const key of givenUp) if (!keys.has(key)) givenUp.delete(key)
+ return current.filter((watch) => !givenUp.has(watch.key))
+ }
+
+ function cancelTimer() {
+ if (timer !== null) clock.clearTimeout(timer)
+ timer = null
+ }
+
+ /** 다음에 다시 볼 때를 잡는다 — 다시 보는 중이면 끝난 뒤에 잡는다 */
+ function plan() {
+ cancelTimer()
+ if (stopped || refreshing) return
+ const now = clock.now()
+ const at = nextPendingCheckAt(activeWatches(), now, {
+ graceMs: PENDING_RELEASE_GRACE_MS,
+ recheckMs: PENDING_RELEASE_RECHECK_MS,
+ })
+ if (at === null) return
+ timer = clock.setTimeout(
+ () => {
+ timer = null
+ void refresh()
+ },
+ Math.max(0, at - now),
+ )
+ }
+
+ async function refresh(): Promise {
+ refreshing = true
+ try {
+ await mailStore.refreshPendingSends()
+ } finally {
+ refreshing = false
+ }
+ if (stopped) return
+ const overdue = overduePendingWatches(activeWatches(), clock.now(), PENDING_RELEASE_GRACE_MS)
+ if (overdue.length === 0) {
+ overdueStreak = 0
+ } else {
+ overdueStreak += 1
+ if (overdueStreak > PENDING_RELEASE_MAX_RECHECKS) {
+ for (const watch of overdue) givenUp.add(watch.key)
+ overdueStreak = 0
+ }
+ }
+ plan()
+ }
+
+ // 지켜볼 것이 바뀌면(새로 보낸 메일 · 다른 목록 · 다른 메일 · 다시 받은 예약됨 수) 다시 잡는다 — 같은 것이면 그대로 둔다
+ watch(
+ () => watches.value.map((watch) => watch.key).join(' '),
+ () => plan(),
+ { immediate: true },
+ )
+
+ onScopeDispose(() => {
+ stopped = true
+ cancelTimer()
+ })
+}
diff --git a/frontend/src/composables/usePopupPlacement.ts b/frontend/src/composables/usePopupPlacement.ts
new file mode 100644
index 0000000..3e8c20c
--- /dev/null
+++ b/frontend/src/composables/usePopupPlacement.ts
@@ -0,0 +1,68 @@
+import { ref, watch, type Ref } from 'vue'
+
+import { opensUpward } from '@/shared/utils/popup-placement'
+
+// 펼침 메뉴를 아래로 열지 위로 열지 — 메뉴를 그린 직후(초점을 주기 전) 아래로 그린 메뉴를 재어, 보이는 영역 아래가 모자라면 위로 연다
+// (판단은 순수 함수 popup-placement.ts 의 opensUpward, 여기서는 재기만 한다).
+// 쓰는 곳: 메일함 · 주소록 그룹 줄의 ⋯ (MailboxMoreButton — 스크롤되는 패널 안), 쓰기 화면의 [보내기 ▾] (ComposeFooter — 화면 맨 아래 단추 줄.
+// 아래로 열면 메뉴가 화면 밖에 생기고 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
+// 메뉴는 EditorPopup 이 연 뒤 다음 틱에 초점을 준다 — 그 전에 위아래가 정해진다 (같은 그리기 차례 안의 post 감시)
+
+/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
+const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
+
+/** 요소가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분 */
+function visibleArea(element: Element): { top: number; bottom: number } {
+ let top = 0
+ let bottom = window.innerHeight
+ for (let node = element.parentElement; node; node = node.parentElement) {
+ if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
+ const rect = node.getBoundingClientRect()
+ top = Math.max(top, rect.top)
+ bottom = Math.min(bottom, rect.bottom)
+ }
+ return { top, bottom }
+}
+
+export interface PopupPlacementOptions {
+ /** 메뉴가 열려 있다 */
+ open: Ref
+ /** 연 단추 */
+ trigger: () => HTMLElement | null
+ /** 그린 메뉴 (열린 동안) */
+ popup: () => HTMLElement | null
+}
+
+/** upward — 위로 연다 (쓰는 쪽이 메뉴에 위로 여는 class 를 붙인다). 닫으면 다음에 열 때 다시 아래로 재기 시작한다 */
+export function usePopupPlacement(options: PopupPlacementOptions) {
+ const upward = ref(false)
+
+ /** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
+ function place() {
+ const button = options.trigger()
+ const menu = options.popup()
+ if (!button || !menu) return
+ const buttonRect = button.getBoundingClientRect()
+ const menuRect = menu.getBoundingClientRect()
+ const area = visibleArea(button)
+ upward.value = opensUpward({
+ triggerTop: buttonRect.top,
+ triggerBottom: buttonRect.bottom,
+ popupHeight: menuRect.height,
+ areaTop: area.top,
+ areaBottom: area.bottom,
+ gap: Math.max(0, menuRect.top - buttonRect.bottom),
+ })
+ }
+
+ watch(
+ options.open,
+ (isOpen) => {
+ upward.value = false
+ if (isOpen) place()
+ },
+ { flush: 'post' },
+ )
+
+ return { upward }
+}
diff --git a/frontend/src/composables/useScheduledMessageActions.ts b/frontend/src/composables/useScheduledMessageActions.ts
new file mode 100644
index 0000000..90e6ec3
--- /dev/null
+++ b/frontend/src/composables/useScheduledMessageActions.ts
@@ -0,0 +1,118 @@
+import { isNavigationFailure, useRouter, type RouteLocationRaw } from 'vue-router'
+
+import { notifyApiError } from './useApi'
+import { useMessageRemoval } from './useMessageRemoval'
+import { useToast } from './useToast'
+import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
+import type { MessageSummary } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailStore } from '@/stores/mail.store'
+
+/**
+ * 붙잡아 둔 발송을 다루는 일 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계") — 모두 DELETE /scheduled-messages/{id} 한 길이다.
+ * 취소한 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id — draftId). 실패(이미 보냄 409 MAIL_014 — "이미 보낸 메일이라 취소할 수 없습니다." ·
+ * 없음 404)는 인터셉터가 알린다 — 여기서는 아무것도 바꾸지 않는다(MAIL_014 면 스토어가 보이는 목록 · 열린 메일 · 예약됨 수를 다시 맞춘다).
+ * - [보내기 취소](보낸 뒤 알림): 쓰기 화면을 ?draft= 로 다시 연다(새 발송 키 — 새 양식) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."
+ * - [고치기](예약 안내 줄): 예약을 취소하고 쓰기 화면으로 + "예약을 취소했습니다. 고친 뒤 다시 보내 주세요."
+ * 두 경우 모두 쓰기 화면으로 가지 못했으면(지금 화면의 나가기 확인에서 머물기로 함 등) 그 자리에 두고
+ * "…취소하고 임시보관함으로 옮겼습니다." + [고치기](그 메일로 쓰기 화면) — 취소는 이미 됐다
+ * - [예약 취소](예약 안내 줄): 임시보관함으로 + "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] — 삭제처럼 목록에서 빠진 뒤의 이동
+ * - [삭제](아직 보내지 않은 메일 — 보내는 중 · 예약, 확인 창 뒤): 발송을 취소한 뒤 휴지통으로 + "보내지 않고 휴지통으로 옮겼습니다."
+ * 같은 메일의 취소가 이미 진행 중이면(빠르게 두 번 누름 · 두 곳에서 누름) 두 번째는 아무것도 하지 않는다 (스토어도 요청을 하나만 보낸다).
+ * 세션은 일을 시작할 때 잡는다 — 기다리는 사이 바뀌었으면(로그아웃 · 다른 계정) 이동 · 알림을 하지 않는다(앞 사용자의 메일을 새 세션에 열지 않게).
+ * 목록에서 빠진 뒤의 이동은 그 메일을 아직 그 목록에서 보고 있을 때만 한다 (useMessageRemoval 의 leaveRemoved — 그사이 다른 곳으로 간 사용자를 끌고 가지 않게)
+ */
+export function useScheduledMessageActions() {
+ const router = useRouter()
+ const authStore = useAuthStore()
+ const mailStore = useMailStore()
+ const toast = useToast()
+ const removal = useMessageRemoval()
+
+ function draftLocation(draftId: string): RouteLocationRaw {
+ return { name: 'mail-compose', query: { draft: draftId } }
+ }
+
+ /** 쓰기 화면으로 그 메일을 연다 — 가지 못했으면(나가기 확인에서 머묾 · 다른 이동에 밀림) false */
+ async function openDraft(draftId: string): Promise {
+ return !isNavigationFailure(await router.push(draftLocation(draftId)))
+ }
+
+ /**
+ * 취소 — 성공하면 draftId, 실패(인터셉터가 알렸다) · 그사이 세션이 바뀌었으면 · 같은 메일의 취소가 이미 진행 중이면 null.
+ * session 은 부르는 쪽이 일을 시작할 때 잡은 세대다
+ */
+ async function cancel(messageId: string, session: number): Promise {
+ if (mailStore.isCancellingPendingSend(messageId)) return null
+ try {
+ const { draftId } = await mailStore.cancelPendingSend(messageId)
+ return authStore.generation === session ? draftId : null
+ } catch {
+ return null
+ }
+ }
+
+ /** [보내기 취소] — 보낸 뒤 알림의 단추 */
+ async function undoSend(messageId: string): Promise {
+ const session = authStore.generation
+ const draftId = await cancel(messageId, session)
+ if (!draftId) return false
+ if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.undone)
+ else toast.success(SEND_LATER_TEXT.undoneStayed, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
+ return true
+ }
+
+ /** [고치기] — 예약을 취소하고 쓰기 화면으로 */
+ async function editScheduled(messageId: string): Promise {
+ const session = authStore.generation
+ const draftId = await cancel(messageId, session)
+ if (!draftId) return false
+ if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.editing)
+ else toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
+ return true
+ }
+
+ /** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
+ function removedFromList(messageId: string): boolean {
+ return !mailStore.messages.some((message) => message.id === messageId)
+ }
+
+ /** [예약 취소] — 보내지 않고 임시보관함으로 */
+ async function cancelScheduled(message: MessageSummary, layout: 'split' | 'list'): Promise {
+ const session = authStore.generation
+ const source = mailStore.listSource
+ const nextId = mailStore.neighborAfterRemoval(message.id)
+ const draftId = await cancel(message.id, session)
+ if (!draftId) return false
+ toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
+ const removed = removedFromList(message.id)
+ await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
+ return true
+ }
+
+ /**
+ * [삭제] — 아직 보내지 않은 메일(보내는 중 · 예약)의 확인 창에서 「삭제」를 누른 뒤에만 부른다.
+ * 발송을 취소한 뒤 휴지통으로 옮긴다 (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다)
+ */
+ async function deletePendingSend(message: MessageSummary, layout: 'split' | 'list'): Promise {
+ const session = authStore.generation
+ const source = mailStore.listSource
+ const nextId = mailStore.neighborAfterRemoval(message.id)
+ const draftId = await cancel(message.id, session)
+ if (!draftId) return false
+ const trash = await mailStore.resolveMailboxByRole('trash')
+ if (authStore.generation !== session) return false
+ if ('error' in trash) {
+ // 발송은 취소했다 — 메일은 임시보관함에 있다 (휴지통을 몰라 옮기지 못했다)
+ notifyApiError(trash.error)
+ return false
+ }
+ if (!(await mailStore.moveMessage(draftId, trash.mailbox.id)) || authStore.generation !== session) return false
+ toast.success(SEND_LATER_TEXT.deleted)
+ const removed = removedFromList(message.id)
+ await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
+ return true
+ }
+
+ return { undoSend, editScheduled, cancelScheduled, deletePendingSend }
+}
diff --git a/frontend/src/composables/useSenderBlock.ts b/frontend/src/composables/useSenderBlock.ts
new file mode 100644
index 0000000..353ec5a
--- /dev/null
+++ b/frontend/src/composables/useSenderBlock.ts
@@ -0,0 +1,115 @@
+import { notifyApiError } from './useApi'
+import { useToast } from './useToast'
+import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
+import type { MessageSummary } from '@/shared/types/mail'
+import type { BulkOrigin } from '@/shared/utils/mail-bulk'
+import { objectParticle } from '@/shared/utils/korean'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailBlockStore } from '@/stores/mail-block.store'
+import { useMailStore } from '@/stores/mail.store'
+
+/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다) */
+export interface SenderBlockOutcome {
+ blocked: boolean
+ moved: boolean
+ /**
+ * 옮기기가 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있었다(여러 통 처리의 stale) — 옛 목록에 기대어 다음 메일로 넘어가지 않는다
+ * (옮긴 것은 맞다 — 알림 · 되돌리기는 그대로)
+ */
+ stale: boolean
+}
+
+/**
+ * 보낸 사람 차단 · 풀기와 그 알림 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면 · 설정 › 차단한 주소").
+ * - 차단(읽기 화면의 확인 창): POST /mail-blocks → 「이 메일도 스팸메일함으로 옮기기」를 켰으면 그 메일을 스팸메일함으로(isJunk: true — 스팸 신고와 같은 길) →
+ * "‘주소’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 받은편지함에서 막았으면 받은편지함)
+ * - 풀기(⋯ 「보낸 사람 차단 풀기」 · 스팸메일함의 [차단 풀기] · 설정의 [차단 풀기]): DELETE → "‘주소’ 차단을 풀었습니다." + [되돌리기](다시 차단).
+ * 이미 받은 메일은 옮기지 않는다. 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 (스토어가 가른다)
+ * 요청 실패는 인터셉터가 알린다 — 여기서는 알리지 않는다. 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast).
+ * 기다리는 사이 세션이 바뀌었으면(로그아웃 · 다른 계정) 그 뒤의 일(메일 옮기기 · 알림)을 하지 않는다 — 앞 사용자의 주소가 새 세션에 보이지 않게
+ */
+export function useSenderBlock() {
+ const authStore = useAuthStore()
+ const blockStore = useMailBlockStore()
+ const mailStore = useMailStore()
+ const toast = useToast()
+
+ /** 지금 세션에서 시작한 일이 그대로인지 — 시작할 때 잡아 둔 세대와 견준다 */
+ function sessionGuard(): () => boolean {
+ const session = authStore.generation
+ return () => authStore.generation === session
+ }
+
+ async function undoBlock(address: string, origins: readonly BulkOrigin[]): Promise {
+ const sameSession = sessionGuard()
+ try {
+ await blockStore.remove(address)
+ } catch {
+ return
+ }
+ if (!sameSession()) return
+ if (origins.length > 0) await mailStore.restoreMoved(origins, { isJunk: false })
+ if (!sameSession()) return
+ toast.success(origins.length > 0 ? MAIL_BLOCK_TEXT.blockRestored(address) : MAIL_BLOCK_TEXT.unblocked(address))
+ }
+
+ /**
+ * 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다)
+ */
+ async function block(message: MessageSummary, target: string, moveToJunk: boolean): Promise {
+ const sameSession = sessionGuard()
+ let address: string
+ try {
+ address = (await blockStore.add(target)).address
+ } catch {
+ return { blocked: false, moved: false, stale: false }
+ }
+ if (!sameSession()) return { blocked: true, moved: false, stale: false }
+ let origins: BulkOrigin[] = []
+ let moved = false
+ let stale = false
+ if (moveToJunk) {
+ const junk = await mailStore.resolveMailboxByRole('junk')
+ if ('error' in junk) {
+ notifyApiError(junk.error)
+ } else if (!message.mailboxIds.includes(junk.mailbox.id)) {
+ const result = await mailStore.moveMessages([message], junk.mailbox.id, { isJunk: true })
+ moved = result.doneIds.includes(message.id)
+ stale = result.stale
+ origins = result.origins
+ }
+ }
+ if (!sameSession()) return { blocked: true, moved: false, stale }
+ const particle = objectParticle(address)
+ toast.success(
+ moved ? MAIL_BLOCK_TEXT.blockedAndMoved(address, particle) : MAIL_BLOCK_TEXT.blocked(address, particle),
+ { label: MAIL_BLOCK_TEXT.undo, run: () => void undoBlock(address, origins) },
+ )
+ return { blocked: true, moved, stale }
+ }
+
+ async function reblock(address: string): Promise {
+ const sameSession = sessionGuard()
+ try {
+ await blockStore.add(address)
+ } catch {
+ return
+ }
+ if (sameSession()) toast.success(MAIL_BLOCK_TEXT.unblockRestored(address, objectParticle(address)))
+ }
+
+ /** 차단 풀기 — 풀었으면 true (다른 곳에서 이미 풀었어도 — 404. 그 밖의 실패는 인터셉터가 알렸다) */
+ async function unblock(address: string): Promise {
+ const sameSession = sessionGuard()
+ try {
+ await blockStore.remove(address)
+ } catch {
+ return false
+ }
+ if (!sameSession()) return false
+ toast.success(MAIL_BLOCK_TEXT.unblocked(address), { label: MAIL_BLOCK_TEXT.undo, run: () => void reblock(address) })
+ return true
+ }
+
+ return { block, unblock }
+}
diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts
index 3a75bee..ff84274 100644
--- a/frontend/src/composables/useToast.ts
+++ b/frontend/src/composables/useToast.ts
@@ -16,6 +16,11 @@ export interface ProgressToast {
dismiss: () => void
}
+/** 알림 옵션 — expiresAt(ms — Date.now)에 닫는다. 붙잡아도 멈추지 않고 남은 시간 막대가 보인다 (9단계 [보내기 취소]) */
+export interface ToastOptions {
+ expiresAt?: number
+}
+
/**
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
@@ -38,17 +43,27 @@ export function useToast() {
}
}
- function success(message: string, action?: ToastAction): number {
- return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
+ function success(message: string, action?: ToastAction, options: ToastOptions = {}): number {
+ return store.show({
+ kind: 'success',
+ message,
+ action: action ? sessionBound(action) : undefined,
+ expiresAt: options.expiresAt ?? null,
+ })
}
/**
* 화면이 직접 띄우는 오류 알림 — API 오류가 아닌 실패를 알릴 때만 쓴다: 브라우저 상태(예: 바탕화면 알림을 막아 둠 + [설정 열기]),
- * 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함]).
+ * 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함] · 붙잡아 둔 발송이면 [보내기 취소]).
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
*/
- function error(message: string, action?: ToastAction): number {
- return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
+ function error(message: string, action?: ToastAction, options: ToastOptions = {}): number {
+ return store.show({
+ kind: 'error',
+ message,
+ action: action ? sessionBound(action) : undefined,
+ expiresAt: options.expiresAt ?? null,
+ })
}
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
diff --git a/frontend/src/pages/mail/ComposePage.sendlater.spec.ts b/frontend/src/pages/mail/ComposePage.sendlater.spec.ts
new file mode 100644
index 0000000..a5f89ff
--- /dev/null
+++ b/frontend/src/pages/mail/ComposePage.sendlater.spec.ts
@@ -0,0 +1,402 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+
+import { CLOCK } from '@/composables/useClock'
+import type { Draft, Mailbox, RejectedRecipient } from '@/shared/types/mail'
+import { formatScheduleTime } from '@/shared/utils/format'
+import { formatDate } from '@/shared/utils/format'
+import { schedulePresets, sendAtOf } from '@/shared/utils/schedule-send'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailSettingsStore } from '@/stores/mail-settings.store'
+import { useMailStore } from '@/stores/mail.store'
+import { useToastStore, type Toast } from '@/stores/toast.store'
+import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
+import {
+ fail,
+ flushPromises,
+ installMockApi,
+ ok,
+ restoreApiAdapter,
+ type MockHandler,
+ type MockRequest,
+ type MockResult,
+} from '@/test-utils/mock-api'
+import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
+import { createTestRouter, routeViewProvide } from '@/test-utils/router'
+import ComposePage from './ComposePage.vue'
+
+// 쓰기 화면의 9단계 — 보내기 취소(보낸 뒤 알림 · [보내기 취소]) · [보내기 ▾] › 예약 발송
+// (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 */
+const EDITOR_LOAD_TIMEOUT_MS = 15_000
+/** 2026-10-29(목) 15:00 (서울) */
+const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
+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' }
+/** 보내기 취소로 임시보관함에 돌아온 메일 */
+const RETURNED: Draft = {
+ id: 'sent-1',
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
+ cc: [],
+ bcc: [],
+ subject: '일정 공유',
+ body: '확인 부탁드립니다.',
+ bodyHtml: null,
+ attachments: [],
+ inReplyTo: null,
+ updatedAt: '2026-10-29T06:00:00Z',
+}
+
+describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
+ let pinia: Pinia
+ let clock: ManualClock
+ const mountedWrappers: VueWrapper[] = []
+
+ beforeAll(installProseMirrorDom)
+ beforeAll(async () => {
+ await import('@/components/mail/editor/MailEditor.vue')
+ }, EDITOR_LOAD_TIMEOUT_MS)
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().markAuthenticated(USER)
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(NOW)
+ clock = createManualClock(NOW)
+ })
+
+ afterEach(() => {
+ for (const wrapper of mountedWrappers.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ useToastStore().clear()
+ restoreApiAdapter()
+ vi.useRealTimers()
+ document.body.innerHTML = ''
+ })
+
+ /** 보내기 응답을 차례로 — 메일함 · 예약됨 수 · 되돌아온 저장본은 늘 같다 */
+ function server(sendReplies: MockResult[], cancelReply: MockResult = ok({ draftId: 'sent-1' })): MockHandler {
+ return (request) => {
+ if (request.method === 'POST' && request.url === '/messages') return sendReplies.shift() ?? ok({ id: 'sent-9' }, 201)
+ if (request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/')) return cancelReply
+ if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
+ if (request.url === '/drafts/sent-1') return ok(RETURNED)
+ return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
+ }
+ }
+
+ async function mountCompose(handler: MockHandler, attach = false) {
+ const requests = installMockApi(handler)
+ const router = createTestRouter()
+ await router.push('/mail/inbox')
+ await router.push('/mail/compose')
+ await router.isReady()
+ useMailStore().rememberBrowseLocation('/mail/inbox')
+ await useMailStore().loadMailboxes()
+ const wrapper = mount(ComposePage, {
+ attachTo: attach ? document.body : undefined,
+ global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
+ })
+ mountedWrappers.push(wrapper)
+ await flushPromises()
+ await vi.waitFor(
+ () => {
+ if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
+ },
+ { timeout: EDITOR_LOAD_TIMEOUT_MS },
+ )
+ await flushPromises()
+ return { wrapper, router, requests }
+ }
+
+ 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()
+ }
+
+ const sends = (requests: MockRequest[]) => requests.filter((request) => request.method === 'POST' && request.url === '/messages')
+ const toasts = (): Toast[] => useToastStore().toasts
+ /** 알림의 단추를 누른다 — 알림 틀(ToastHost)처럼 알림을 닫고 할 일을 한다 */
+ function runAction(toast: Toast | undefined) {
+ if (!toast?.action || !('run' in toast.action)) throw new Error('단추가 없다')
+ useToastStore().dismiss(toast.id)
+ toast.action.run()
+ }
+
+ describe('보내기 취소 — 엔진이 붙잡아 둔 발송 (pendingSend: undo)', () => {
+ const undoReply = (rejected: RejectedRecipient[] = []) =>
+ ok({ id: 'sent-1', rejectedRecipients: rejected, pendingSend: { kind: 'undo', sendAt: new Date(NOW + 10_000).toISOString() } }, 201)
+
+ it('"메일을 보냈습니다." + [보내기 취소] — 서버가 보낼 시각(sendAt)에 닫힌다(마감 · 남은 시간 막대)', async () => {
+ const { wrapper, router } = await mountCompose(server([undoReply()]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+
+ await submit(wrapper)
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(toasts()).toEqual([
+ expect.objectContaining({
+ kind: 'success',
+ message: '메일을 보냈습니다.',
+ action: expect.objectContaining({ label: '보내기 취소' }),
+ expiresAt: NOW + 10_000,
+ }),
+ ])
+ })
+
+ it('[보내기 취소] → DELETE → 그 메일로 쓰기 화면을 다시 연다(받는 사람 · 제목 그대로) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."', async () => {
+ const { wrapper, router, requests } = await mountCompose(server([undoReply()]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ await wrapper.get('#compose-subject').setValue('일정 공유')
+ await submit(wrapper)
+
+ runAction(toasts()[0])
+ await flushPromises()
+
+ expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
+ '/scheduled-messages/sent-1',
+ ])
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=sent-1')
+ await vi.waitFor(() => {
+ if (!requests.some((request) => request.url === '/drafts/sent-1')) throw new Error('저장본을 받는 중')
+ })
+ await flushPromises()
+ expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('일정 공유')
+ expect(toasts().map((toast) => toast.message)).toEqual(['보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.'])
+
+ // 다시 보내면 새 발송 키다 — 취소한 발송의 키로 다시 보내면 서버가 MAIL_005 로 막는다 (계약)
+ await submit(wrapper)
+ const keys = sends(requests).map((request) => request.config.headers.get('Idempotency-Key'))
+ expect(keys).toHaveLength(2)
+ expect(keys[1]).not.toBe(keys[0])
+ })
+
+ it('[보내기 취소] 뒤 쓰기 화면으로 가지 못하면(지금 화면의 나가기 확인에서 머묾) 그 자리에 두고 "보내기를 취소하고 임시보관함으로 옮겼습니다." + [고치기]', async () => {
+ const { wrapper, router } = await mountCompose(server([undoReply()]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ await submit(wrapper)
+ router.beforeEach((to) => to.name !== 'mail-compose')
+
+ runAction(toasts()[0])
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(toasts()).toEqual([
+ expect.objectContaining({
+ kind: 'success',
+ message: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
+ action: { label: '고치기', to: { name: 'mail-compose', query: { draft: 'sent-1' } } },
+ }),
+ ])
+ })
+
+ it('늦게 누르면(이미 보냄 — 409) 쓰기 화면으로 가지 않는다 — 알림은 인터셉터', async () => {
+ const { wrapper, router } = await mountCompose(server([undoReply()], fail(409, 'MAIL_014')))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ await submit(wrapper)
+
+ runAction(toasts()[0])
+ await flushPromises()
+
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(toasts().map((toast) => toast.kind)).toEqual(['error'])
+ })
+
+ it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => {
+ const { wrapper } = await mountCompose(server([undoReply([NOBODY])]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ await addRecipient(wrapper, NOBODY.email)
+
+ await submit(wrapper)
+
+ expect(toasts()).toEqual([
+ expect.objectContaining({
+ kind: 'error',
+ message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
+ action: expect.objectContaining({ label: '보내기 취소' }),
+ expiresAt: NOW + 10_000,
+ }),
+ ])
+ })
+
+ it('보낼 시각이 이 PC 시계로 이미 지났으면 취소할 수 없다 — 지금처럼 [보낸편지함 보기]', async () => {
+ const past = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW - 1_000).toISOString() } }, 201)
+ const { wrapper } = await mountCompose(server([past]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+
+ await submit(wrapper)
+
+ expect(toasts()).toEqual([
+ expect.objectContaining({
+ message: '메일을 보냈습니다.',
+ action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
+ expiresAt: null,
+ }),
+ ])
+ })
+
+ it('이 PC 시계가 늦어 보낼 시각이 멀리 보여도 알림은 30초(설정의 가장 긴 값)까지만 남는다', async () => {
+ const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
+ const { wrapper } = await mountCompose(server([far]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ const sentAt = Date.now()
+
+ await submit(wrapper)
+
+ expect(toasts()[0]?.expiresAt).toBe(sentAt + 30_000)
+ })
+
+ it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
+ const settings = useMailSettingsStore()
+ settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
+ settings.status = 'ready'
+ const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
+ const { wrapper } = await mountCompose(server([far]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ const sentAt = Date.now()
+
+ await submit(wrapper)
+
+ expect(toasts()[0]?.expiresAt).toBe(sentAt + 4_000)
+ })
+
+ it('세션이 바뀌면(로그아웃) 알림을 닫는다 — 메일은 그대로 보내진다', async () => {
+ const { wrapper, requests } = await mountCompose(server([undoReply()]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+ await submit(wrapper)
+ expect(toasts()).toHaveLength(1)
+
+ useAuthStore().clearAuth()
+ await flushPromises()
+
+ expect(toasts()).toEqual([])
+ expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
+ })
+ })
+
+ describe('[보내기 ▾] › 「예약 발송…」', () => {
+ async function openSchedule(wrapper: VueWrapper) {
+ await wrapper.get('.compose__send-more').trigger('click')
+ await flushPromises()
+ const item = wrapper.get('[role="menu"] [role="menuitem"]')
+ expect(item.text()).toBe('예약 발송…')
+ await item.trigger('click')
+ await flushPromises()
+ }
+
+ it('▾ 는 보내기와 같은 규칙으로 꺼진다 — 메뉴는 「예약 발송…」(시계) 하나', async () => {
+ const { wrapper } = await mountCompose(server([]))
+ const more = wrapper.get('.compose__send-more')
+ expect(more.attributes('aria-label')).toBe('보내기 방법 더 보기')
+ expect(more.attributes('aria-haspopup')).toBe('menu')
+ expect(more.attributes('disabled')).toBeUndefined()
+
+ await more.trigger('click')
+ await flushPromises()
+ const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
+ expect(items.map((item) => item.text())).toEqual(['예약 발송…'])
+ expect(items[0]?.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
+ })
+
+ it('받는 사람이 없으면 창을 열지 않고 받는 사람 칸에 알린다 (보내기와 같은 검사)', async () => {
+ const { wrapper } = await mountCompose(server([]))
+
+ await openSchedule(wrapper)
+
+ expect(wrapper.find('[data-schedule-dialog]').exists()).toBe(true)
+ expect(wrapper.get('#compose-schedule-title').element.closest('dialog')?.hasAttribute('open')).toBe(false)
+ expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
+ })
+
+ it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
+ const preset = schedulePresets(NOW)[0]
+ if (!preset) throw new Error('고르기가 없다')
+ const sendAt = sendAtOf(preset.at)
+ const { wrapper, router, requests } = await mountCompose(
+ server([ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'scheduled', sendAt } }, 201)]),
+ true,
+ )
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+
+ await openSchedule(wrapper)
+ const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
+ expect(dialog?.hasAttribute('open')).toBe(true)
+ expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
+ await wrapper.get('[data-schedule-confirm]').trigger('click')
+ await flushPromises()
+
+ expect(sends(requests).map((request) => (request.data as { sendAt?: string }).sendAt)).toEqual([sendAt])
+ expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
+ expect(toasts()).toEqual([
+ expect.objectContaining({
+ kind: 'success',
+ message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
+ action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
+ expiresAt: null,
+ }),
+ ])
+ expect(requests.some((request) => request.url === '/scheduled-messages' && request.params.limit === 1)).toBe(true)
+ })
+
+ it('서버가 예약 시각을 받지 않으면(VAL_001) 알림 대신 예약 창을 다시 연다 — 그 시각을 직접 고르기에 채우고 고르기 아래에 까닭 (리뷰 #11)', async () => {
+ const preset = schedulePresets(NOW)[0]
+ if (!preset) throw new Error('고르기가 없다')
+ const { wrapper, router, requests } = await mountCompose(server([fail(400, 'VAL_001')]), true)
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+
+ await openSchedule(wrapper)
+ await wrapper.get('[data-schedule-confirm]').trigger('click')
+ await flushPromises()
+
+ expect(sends(requests)).toHaveLength(1)
+ expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
+ // 사전 문구 알림은 없다 — 창이 안내한다
+ expect(toasts()).toEqual([])
+ const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
+ expect(dialog?.hasAttribute('open')).toBe(true)
+ // 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다
+ expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
+ expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
+ const chosen = new Date(preset.at)
+ expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen))
+ expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(
+ String(chosen.getHours() * 60 + chosen.getMinutes()),
+ )
+ expect(wrapper.get('[data-schedule-day]').attributes('aria-describedby')).toBe('compose-schedule-error')
+
+ // 닫으면 까닭도 지운다 — 다시 열면 처음 값
+ await wrapper.get('[data-schedule-cancel]').trigger('click')
+ await flushPromises()
+ await openSchedule(wrapper)
+ expect(wrapper.get('#compose-schedule-error').text()).toBe('')
+ expect((wrapper.get(`[data-schedule-preset="${preset.id}"]`).element as HTMLInputElement).checked).toBe(true)
+ })
+
+ it('예약 창의 [취소] 는 보내지 않는다 · [보내기]는 sendAt 없이 보낸다', async () => {
+ const { wrapper, requests } = await mountCompose(server([ok({ id: 'sent-1', rejectedRecipients: [] }, 201)]))
+ await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
+
+ await openSchedule(wrapper)
+ await wrapper.get('[data-schedule-cancel]').trigger('click')
+ await flushPromises()
+ expect(sends(requests)).toEqual([])
+
+ await submit(wrapper)
+ expect(sends(requests).map((request) => request.data)).toEqual([expect.not.objectContaining({ sendAt: expect.anything() })])
+ })
+ })
+})
diff --git a/frontend/src/pages/mail/ComposePage.spec.ts b/frontend/src/pages/mail/ComposePage.spec.ts
index 6143055..b91ca72 100644
--- a/frontend/src/pages/mail/ComposePage.spec.ts
+++ b/frontend/src/pages/mail/ComposePage.spec.ts
@@ -1244,8 +1244,11 @@ describe('ComposePage', () => {
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
const { wrapper, router, requests } = await mountCompose('/mail/compose', savable(replyApi))
expect(wrapper.find('.compose__cancel').exists()).toBe(false)
- expect(wrapper.findAll('.compose__footer a, .compose__footer > button').map((item) => item.text())).toEqual([
+ // [보내기 ▾](9단계) — 보내기 · 보내기 방법(▾) · 임시 저장뿐이다
+ const footerButtons = wrapper.findAll('.compose__footer a, .compose__footer > button, .compose__send-group > button')
+ expect(footerButtons.map((item) => item.text() || item.attributes('aria-label'))).toEqual([
'보내기',
+ '보내기 방법 더 보기',
'임시 저장',
])
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue
index b417afb..f7d6322 100644
--- a/frontend/src/pages/mail/ComposePage.vue
+++ b/frontend/src/pages/mail/ComposePage.vue
@@ -31,6 +31,15 @@
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
// 보내는 중에 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 결과를 알리지도 저장본으로 남기지도 않는다 — 앞 사용자의 받는 사람이
// 로그인 화면 · 다른 계정에 보이거나 그 계정의 임시보관함에 들어가지 않게. 임시 저장을 기다리는 사이 바뀌었으면 보내지도 않는다.
+// 보내기 취소 · 예약 발송 (9단계 — 계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계"):
+// - 엔진이 발송을 붙잡아 두면(응답의 pendingSend) 알림 "메일을 보냈습니다." + [보내기 취소] + 남은 시간 막대 — 설정한 초(받아 둔 보내기 · 읽기 설정 —
+// 이 PC 시계가 서버와 어긋나도 늘어나지 않게, 모르면 그 시각 sendAt)에 닫히고 붙잡아도 멈추지 않는다 (pending-send.ts 의 undoToastDeadline).
+// 일부가 받지 못했으면 그 실패 알림("…받지 못합니다")에 [보내기 취소]. [보내기 취소]는 쓰기 화면을 그 메일로 다시 연다
+// (useScheduledMessageActions — 새 양식이라 새 발송 키)
+// - [보내기 ▾] › 「예약 발송…」 — 받는 사람 · 제목을 먼저 보고(보내기와 같은 검사) 예약 창(ComposeScheduleDialog)을 연다.
+// [예약]은 보내기와 같은 흐름(임시 저장 기다리기 · 길이 검사 · 같은 발송 키)에 sendAt 을 더해 보낸다 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기].
+// 서버가 그 시각을 받지 않으면(VAL_001 — 그사이 5분 안이 됐거나 시계가 어긋남) 알림 대신 예약 창을 다시 열고 고르기 아래에 까닭
+// ("지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." — 이 화면의 검사로 고른다, schedule-send.ts 의 rejectedScheduleProblem)
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
// 휴지통 #compose-discard · 닫기 .compose__close
import {
@@ -52,6 +61,7 @@ import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue
import ComposeFooter from '@/components/mail/ComposeFooter.vue'
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
+import ComposeScheduleDialog from '@/components/mail/ComposeScheduleDialog.vue'
import RecipientField from '@/components/mail/RecipientField.vue'
import {
MAIL_EDITOR_RETRY,
@@ -68,6 +78,7 @@ import { useDraftTrash } from '@/composables/useDraftTrash'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
+import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
@@ -84,6 +95,8 @@ import {
type ComposeMode,
} from '@/shared/constants/mail'
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
+import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS } from '@/shared/constants/mail-send-later'
+import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { Draft, DraftPayload, SendMessagePayload, SentMessage } from '@/shared/types/mail'
import {
@@ -93,6 +106,7 @@ import {
} from '@/shared/utils/compose-changes'
import { composePrefillOf } from '@/shared/utils/compose-prefill'
import { isImageFile } from '@/shared/utils/editor-files'
+import { formatScheduleTime } from '@/shared/utils/format'
import {
createIdempotencyKey,
shouldRenewSendKey,
@@ -111,7 +125,11 @@ 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 { MAIL_SCHEDULED_ROUTE } from '@/shared/utils/mail-list-source'
+import { undoToastDeadline } from '@/shared/utils/pending-send'
+import { rejectedScheduleProblem, sendAtOf, type ScheduleRejection } from '@/shared/utils/schedule-send'
import { useAuthStore } from '@/stores/auth.store'
+import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
import { useMailStore } from '@/stores/mail.store'
@@ -136,13 +154,21 @@ type ComposeSource = ComposeMode | 'draft'
/** 서버가 받는 사람을 거부한 코드 — 받는 사람 칸 아래 띠로 안내한다 */
type RecipientRejectionCode = Extract
+/**
+ * [보내기 취소] 알림이 남아 있을 수 있는 가장 긴 시간 — 설정의 가장 긴 값(30초). 설정한 초를 모를 때만 쓴다(undoToastDeadline).
+ * 이 PC 의 시계가 서버보다 늦어 sendAt 이 멀리 보여도 알림이 그보다 오래 남지 않게 한다 (늦게 누르면 MAIL_014 — 인터셉터)
+ */
+const UNDO_SEND_MAX_MS = Math.max(...UNDO_SEND_OPTIONS) * 1000
+
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
+const settingsStore = useMailSettingsStore()
const signatureStore = useMailSignatureStore()
const { getDraft, getMessage, sendMessage } = useMail()
const draftTrash = useDraftTrash()
+const scheduledActions = useScheduledMessageActions()
const toast = useToast()
const clock = useClock()
const {
@@ -186,6 +212,10 @@ const composeKind = ref('new')
/** 발송 · 임시 저장의 inReplyTo — 답장할 메일 id (이어 쓰는 저장본은 저장본의 값) */
const inReplyTo = ref(null)
const sending = ref(false)
+/** 예약 창 (9단계) — ▾ › 「예약 발송…」 */
+const scheduleOpen = ref(false)
+/** 서버가 받지 않은 예약 시각과 까닭 — 예약 창을 다시 열 때 고르기 아래에 보인다 (창을 닫으면 지운다) */
+const scheduleRejection = ref(null)
/** 머리의 휴지통 — 저장본을 버리는 중 · 버렸다 (버렸으면 떠날 때 묻지도 저장하지도 않는다) */
const discarding = ref(false)
const discarded = ref(false)
@@ -226,6 +256,8 @@ let formGeneration = 0
let resetCount = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
+/** 예약 발송에서 이 화면이 직접 안내하는 코드 — 위에 더해 VAL_001(서버가 그 시각을 받지 않음 — 예약 창을 다시 연다) */
+const SCHEDULE_INLINE_CODES: readonly ErrorCode[] = [...SEND_INLINE_CODES, 'VAL_001']
/**
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
@@ -711,11 +743,41 @@ function settleSendKey(outcome: SendOutcome) {
}
/**
- * 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — onSubmit). 받는 사람 일부가 받지 못했으면
+ * 보냈다고 알린다 — 보내는 중에 떠났어도 알린다(세션이 바뀌었으면 부르지 않는다 — send). 받는 사람 일부가 받지 못했으면
* "보냈습니다" 대신 오류 알림 모양(흰 카드)으로 받지 못한 사람을 적는다 — 링크는 같은 보낸편지함 (계약 2절 "받는 사람별 결과").
- * 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS)
+ * 링크가 있는 알림은 둘 다 10초다 (toast.store 의 TOAST_ACTION_DURATION_MS).
+ * 엔진이 붙잡아 둔 발송(9단계 — pendingSend):
+ * - 보내기 취소를 기다리는 중(undo): 단추가 [보내기 취소]이고 알림은 마감에 닫힌다 · 남은 시간 막대. 마감은 받아 둔 설정의 초(− 1초)로 센다 —
+ * 이 PC 시계가 서버와 어긋나도 설정보다 오래 남지 않게. 설정을 모르면 그 시각(sendAt — 이 PC 시계로 30초를 넘지 않게).
+ * 마감이 이미 지났으면(이 PC 시계가 서버보다 빠르다) 취소할 수 없으니 지금처럼 알린다
+ * - 예약(scheduled): "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기] — 일부가 받지 못했으면 "예약했지만 … 받지 못합니다" + [예약됨 보기]
*/
function notifySent(result: SentMessage) {
+ const pending = result.pendingSend
+ if (pending?.kind === 'scheduled') {
+ const scheduledLink = { label: SEND_LATER_TEXT.viewScheduled, to: { name: MAIL_SCHEDULED_ROUTE } }
+ const partial = partialSendMessage(result.rejectedRecipients, { pendingKind: 'scheduled' })
+ if (partial) toast.error(partial, scheduledLink)
+ else toast.success(SEND_LATER_TEXT.scheduled(formatScheduleTime(pending.sendAt)), scheduledLink)
+ return
+ }
+ const now = Date.now()
+ const expiresAt = pending
+ ? undoToastDeadline({
+ sendAt: pending.sendAt,
+ now,
+ undoSendSeconds: settingsStore.knownUndoSendSeconds,
+ marginMs: UNDO_SEND_DEADLINE_MARGIN_MS,
+ maxMs: UNDO_SEND_MAX_MS,
+ })
+ : Number.NaN
+ if (pending && expiresAt > now) {
+ const undo = { label: SEND_LATER_TEXT.undo, run: () => void scheduledActions.undoSend(result.id) }
+ const partial = partialSendMessage(result.rejectedRecipients, { pendingKind: 'undo' })
+ if (partial) toast.error(partial, undo, { expiresAt })
+ else toast.success(MAIL_SENT_MESSAGE, undo, { expiresAt })
+ return
+ }
const sentMailbox = mailStore.mailboxByRole('sent')
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
const partial = partialSendMessage(result.rejectedRecipients)
@@ -734,24 +796,61 @@ function endSending(generation: number) {
syncDraftUrl(generation)
}
-async function onSubmit() {
- // 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
+/**
+ * 보내기 전 검사 — 받는 사람(비었음 · 형식 · 100명 초과)과 제목 길이. 걸리면 그 칸에 안내하고 초점을 옮긴다(false).
+ * [보내기]와 ▾ › 「예약 발송…」(예약 창을 열기 전)이 함께 쓴다. 입력칸에 남은 주소도 토큰으로 확정한 뒤 검사한다
+ */
+function precheckSend(): boolean {
commitRecipientInputs()
attempted.value = true
rejectedByServer.value = null
if (to.value.length === 0 || hasInvalidRecipient.value || tooManyRecipients.value) {
toField.value?.focus()
- return
+ return false
}
- if (!canSend.value || !editorRef.value) return
+ if (!canSend.value || !editorRef.value) return false
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
- const subjectText = subject.value.trim()
- if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
+ if (subject.value.trim().length > MAIL_SUBJECT_MAX_LENGTH) {
lengthError.value = 'subject'
subjectInputRef.value?.focus()
- return
+ return false
}
+ return true
+}
+
+function onSubmit() {
+ void send()
+}
+
+/** ▾ › 「예약 발송…」 — 보내기와 같은 검사를 먼저 하고 예약 창을 연다 */
+function openSchedule() {
+ if (!precheckSend()) return
+ scheduleRejection.value = null
+ scheduleOpen.value = true
+}
+
+/** 예약 창의 [예약] — 고른 시각(ms)으로 보낸다 */
+function onScheduleConfirm(at: number) {
+ closeSchedule()
+ void send(sendAtOf(at))
+}
+
+function closeSchedule() {
+ scheduleOpen.value = false
+ scheduleRejection.value = null
+}
+
+/** 서버가 예약 시각을 받지 않았다(VAL_001) — 예약 창을 다시 열고 고르기 아래에 까닭을 보인다 (까닭은 이 화면의 검사로 고른다) */
+function reopenSchedule(at: number) {
+ scheduleRejection.value = { at, problem: rejectedScheduleProblem(at, clock.now()) }
+ scheduleOpen.value = true
+}
+
+/** 보내기 — sendAt 이 있으면 예약 발송 (9단계). 흐름은 같다 */
+async function send(sendAt?: string) {
+ if (!precheckSend() || !editorRef.value) return
+ const subjectText = subject.value.trim()
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
@@ -801,6 +900,8 @@ async function onSubmit() {
return
}
const draftId = drafts.draftId.value
+ /** 이 화면이 직접 안내하는 코드 — 인터셉터 알림을 끈다 (예약이면 VAL_001 도 — 예약 창을 다시 연다) */
+ const inlineCodes = sendAt ? SCHEDULE_INLINE_CODES : SEND_INLINE_CODES
const payload: SendMessagePayload = {
...recipients,
subject: subjectText,
@@ -814,6 +915,8 @@ async function onSubmit() {
...(replyTo ? { inReplyTo: replyTo } : {}),
// 이어 쓴 저장본 — 보낸 뒤 서버가 지운다
...(draftId ? { draftId } : {}),
+ // 예약 발송 — 지금부터 5분 뒤 ~ 7일 안 (예약 창이 검사했다 · 서버도 같은 한도)
+ ...(sendAt ? { sendAt } : {}),
}
try {
@@ -823,12 +926,13 @@ async function onSubmit() {
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애, MAIL_011 받는 사람 모두 지금은 받을 수 없음(보내지 않았다).
// REQ_001 · MAIL_011 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
- const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
+ const result = await sendMessage(payload, { idempotencyKey: sendKey, silent: inlineCodes })
if (sameSession()) {
// 받는 사람 일부가 받지 못했어도 보낸 것이다 — 아래는 모두 받았을 때와 같고, 알림만 받지 못한 사람을 적는다
notifySent(result)
- // 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다
+ // 보낸편지함 · 임시보관함 개수 · 사용량을 서버 기준으로 다시 맞춘다 — 예약했으면 패널의 「예약됨」 수도
mailStore.syncCounts()
+ if (result.pendingSend?.kind === 'scheduled') void mailStore.loadScheduledCount()
}
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
if (generation !== formGeneration) return
@@ -845,7 +949,7 @@ async function onSubmit() {
// 그 사람의 임시보관함에 이 메일이 들어가지 않게)
if (!sameSession()) return
// 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
- if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
+ if (inlineCodes.includes(apiError.code)) notifyApiError(apiError)
// 마지막 자동 저장 뒤에 쓴 글을 잃지 않게 보내려던 내용을 조용히 저장본으로 남긴다 (코드와 상관없이 — 알림은 위의 실패 알림뿐)
void draftLink.keepUnsent(payload, session)
return
@@ -865,6 +969,9 @@ async function onSubmit() {
} else if (code === 'MAIL_004') {
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
await guideMissingAttachments(markMissing())
+ } else if (code === 'VAL_001' && sendAt) {
+ // 서버가 예약 시각을 받지 않았다(그사이 5분 안이 됨 · 시계가 어긋남) — 알림 대신 예약 창을 다시 열어 고르게 한다
+ reopenSchedule(Date.parse(sendAt))
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
}
@@ -1004,6 +1111,11 @@ watch(hasMissing, (missing) => {
watch(allRecipients, () => {
rejectedByServer.value = null
})
+
+// 보낼 수 없게 되면(보내는 중 · 양식을 새로 채움 · 첨부를 올리기 시작함) 예약 창을 닫는다. 세션이 바뀌어도 닫는다 — 세션 만료 대화상자와 겹치지 않게
+watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
+ if (!can || authStore.generation !== previousSession) closeSchedule()
+})
@@ -1166,9 +1278,18 @@ watch(allRecipients, () => {
:draft-saved-at="draftSavedAt"
:sender="sender"
@save-draft="onSaveDraft"
+ @schedule="openSchedule"
/>
+
+
+
{
mailStore.syncCounts()
void mailStore.syncVisibleList()
+ void mailStore.loadScheduledCount()
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
useMailTabTitle()
+// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
+usePendingSendRefresh({ clock })
diff --git a/frontend/src/pages/mail/MailPrintPage.spec.ts b/frontend/src/pages/mail/MailPrintPage.spec.ts
new file mode 100644
index 0000000..fe2bf10
--- /dev/null
+++ b/frontend/src/pages/mail/MailPrintPage.spec.ts
@@ -0,0 +1,270 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+import { createMemoryHistory, createRouter } from 'vue-router'
+
+import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
+import { routes } from '@/router/routes'
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import type { MessageDetail } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
+import { createTestRouter } from '@/test-utils/router'
+import MailPrintPage from './MailPrintPage.vue'
+import printPageSource from './MailPrintPage.vue?raw'
+
+// 인쇄 화면 (디자인 원본 MailPrint · README "9단계 › 인쇄 화면" · 계약 2절 "인쇄") — 새 탭, 앱 틀 없이 메일 한 통
+// 인쇄 창(window.print)은 가짜로 바꿔 부른 횟수만 센다. 화면은 다음 그리기 차례(requestAnimationFrame)에 인쇄 창을 띄운다 —
+// 테스트가 그 차례를 손으로 돌린다(runFrames — 진짜 시간을 기다리지 않는다)
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const MESSAGE: MessageDetail = {
+ id: 'm1',
+ threadId: 't1',
+ mailboxIds: ['inbox'],
+ from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
+ to: [{ name: '김아라', email: USER.email }],
+ subject: 'Re: 메일 서버 이전 일정 공유',
+ preview: '',
+ receivedAt: '2026-10-26T01:42:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: true,
+ size: 100,
+ pendingSend: null,
+ cc: [{ name: '인프라팀', email: 'infra@arasystem.it.kr' }],
+ bcc: [{ name: '숨은 사람', email: 'hidden@arasystem.it.kr' }],
+ replyTo: [],
+ sentAt: null,
+ messageId: null,
+ references: [],
+ htmlBody: '김아라님, 안녕하세요. ',
+ textBody: '김아라님, 안녕하세요.',
+ hasBlockedImages: false,
+ imagesShown: false,
+ attachments: [
+ { id: 'a1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49_152 },
+ { id: 'a2', name: '서명.png', type: 'image/png', size: 2_000, isInline: true },
+ ],
+}
+
+describe('MailPrintPage', () => {
+ let pinia: Pinia
+ let printed: number
+ /** 다음 그리기 차례를 기다리는 일 — runFrames 가 돌린다 */
+ let frames: FrameRequestCallback[]
+ const mounted: VueWrapper[] = []
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().startSession(USER)
+ printed = 0
+ frames = []
+ vi.spyOn(window, 'print').mockImplementation(() => {
+ printed += 1
+ })
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+ frames.push(callback)
+ return frames.length
+ })
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ vi.restoreAllMocks()
+ useToastStore().clear()
+ restoreApiAdapter()
+ document.body.innerHTML = ''
+ })
+
+ async function mountAt(
+ path: string,
+ reply: (request: MockRequest) => MockResult = () => ok(MESSAGE),
+ options: { stubFrame?: boolean } = {},
+ ) {
+ const requests = installMockApi(reply)
+ const router = createTestRouter()
+ await router.push(path)
+ await router.isReady()
+ const wrapper = mount(MailPrintPage, {
+ attachTo: document.body,
+ // 본문 틀을 가짜로 — jsdom 은 srcdoc 를 열자마자 높이를 재므로, 잰 때를 테스트가 정한다
+ global: { plugins: [pinia, router], stubs: options.stubFrame ? { MailBodyFrame: true } : {} },
+ })
+ mounted.push(wrapper)
+ await flushPromises()
+ return { wrapper, router, requests }
+ }
+
+ /** 본문 틀이 높이를 다 쟀다고 알린다 (jsdom 은 배치를 하지 않는다) */
+ async function measure(wrapper: VueWrapper) {
+ wrapper.findComponent(MailBodyFrame).vm.$emit('measured', 1200)
+ await flushPromises()
+ }
+
+ /** 기다리는 그리기 차례를 모두 돌린다 — 돌리는 동안 새로 잡은 차례도 */
+ async function runFrames() {
+ await flushPromises()
+ for (let round = 0; round < 5 && frames.length > 0; round += 1) {
+ for (const callback of frames.splice(0)) callback(0)
+ await flushPromises()
+ }
+ }
+
+ it('메일 한 통만 — 머리 줄(ARA SYSTEM 메일 · 인쇄한 직원) · 제목 · 보낸 사람 · 받는 사람 · 참조 · 날짜 · 첨부(이름 · 크기) — 숨은참조는 없다', async () => {
+ const { wrapper, requests } = await mountAt('/mail/print/m1')
+
+ expect(requests.map((request) => [request.url, request.params])).toEqual([['/messages/m1', {}]])
+ expect(wrapper.get('.print-sheet__brand').text()).toBe('ARA SYSTEM 메일김아라 ')
+ expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
+ const meta = wrapper.findAll('.print-sheet__meta dt').map((dt) => [dt.text(), dt.element.nextElementSibling?.textContent?.trim()])
+ expect(meta).toEqual([
+ ['보낸 사람', '이도윤 '],
+ ['받는 사람', '김아라 '],
+ ['참조', '인프라팀 '],
+ ['날짜', expect.stringMatching(/^2026년 10월 26일 \(월\) (오전|오후) \d{1,2}:42$/)],
+ ['첨부', '메일서버_전환_체크리스트.xlsx (48 KB)'],
+ ])
+ expect(wrapper.text()).not.toContain('hidden@arasystem.it.kr')
+ expect(wrapper.find('iframe').attributes('srcdoc')).toContain('김아라님, 안녕하세요.')
+ expect(document.title).toBe('Re: 메일 서버 이전 일정 공유 · ARA SYSTEM')
+ })
+
+ it('본문 높이를 다 잰 뒤에 한 번 인쇄 창을 띄운다 — 다시 재도 또 띄우지 않고, [인쇄]는 언제든 다시 띄운다', async () => {
+ const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
+ const printButton = wrapper.get('[data-print-now]')
+ expect(printButton.attributes('disabled')).toBeDefined()
+ // 재기 전에는 그리기 차례를 다 돌려도 띄우지 않는다
+ await runFrames()
+ expect(printed).toBe(0)
+
+ await measure(wrapper)
+ // 잰 높이가 그려진 다음 차례에 띄운다
+ expect(printed).toBe(0)
+ await runFrames()
+ expect(printed).toBe(1)
+ expect(printButton.attributes('disabled')).toBeUndefined()
+
+ // 다시 재고(폭이 바뀜 등) 그다음 차례까지 돌려도 또 띄우지 않는다
+ await measure(wrapper)
+ await runFrames()
+ expect(printed).toBe(1)
+
+ await printButton.trigger('click')
+ await runFrames()
+ expect(printed).toBe(2)
+ })
+
+ it('읽기 화면에서 외부 이미지를 보이고 있었으면(?images=show) 그대로 받아 본문 틀이 그림을 그린다', async () => {
+ const shown = { ...MESSAGE, htmlBody: '본문 ', imagesShown: true }
+ const { wrapper, requests } = await mountAt('/mail/print/m1?images=show', () => ok(shown))
+
+ expect(requests[0]?.params).toEqual({ images: 'show' })
+ expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
+ })
+
+ it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
+ const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
+ const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
+
+ expect(wrapper.text()).toContain('메일을 찾을 수 없습니다')
+ expect(wrapper.find('.print-sheet').exists()).toBe(false)
+ expect(wrapper.get('[data-print-now]').attributes('disabled')).toBeDefined()
+ // 화면이 직접 그린다 — 알림은 띄우지 않는다
+ expect(useToastStore().toasts).toEqual([])
+
+ await wrapper.get('.print-page__state-action').trigger('click')
+ await flushPromises()
+ expect(close).toHaveBeenCalledTimes(1)
+ // 스크립트가 연 탭이 아니어서 닫히지 않았으면 메일 화면으로
+ expect(router.currentRoute.value.name).toBe('mail')
+ expect(printed).toBe(0)
+ })
+
+ it('받지 못하면 사전 문구와 [다시 시도] — 인쇄 창은 띄우지 않고, 다시 받아 본문을 다 재면 그때 한 번 띄운다', async () => {
+ let reply: MockResult = fail(503, 'MAIL_001')
+ const { wrapper } = await mountAt('/mail/print/m1', () => reply, { stubFrame: true })
+
+ expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
+ await runFrames()
+ expect(printed).toBe(0)
+
+ reply = ok(MESSAGE)
+ await wrapper.get('[data-print-retry]').trigger('click')
+ await flushPromises()
+ expect(wrapper.get('#print-subject').text()).toBe('Re: 메일 서버 이전 일정 공유')
+ await runFrames()
+ expect(printed).toBe(0)
+
+ await measure(wrapper)
+ await runFrames()
+ expect(printed).toBe(1)
+ })
+
+ it('인쇄 창을 기다리는 사이 화면을 내리면 띄우지 않는다', async () => {
+ const { wrapper } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
+
+ await measure(wrapper)
+ expect(frames).toHaveLength(1)
+ wrapper.unmount()
+ await runFrames()
+
+ expect(printed).toBe(0)
+ })
+
+ it('인쇄 창을 기다리는 사이 다른 메일로 바뀌면(다시 받음) 옛 차례는 띄우지 않는다', async () => {
+ const { wrapper, router } = await mountAt('/mail/print/m1', () => ok(MESSAGE), { stubFrame: true })
+
+ await measure(wrapper)
+ await router.push('/mail/print/m2')
+ await flushPromises()
+ await runFrames()
+
+ expect(printed).toBe(0)
+ })
+
+ it('[닫기] 는 이 탭을 닫는다', async () => {
+ const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
+ const { wrapper } = await mountAt('/mail/print/m1')
+
+ await wrapper.get('[data-print-close]').trigger('click')
+
+ expect(close).toHaveBeenCalledTimes(1)
+ })
+
+ it('인쇄 폭 — 화면의 종이 안쪽 폭(210mm − 좌우 여백)과 인쇄 쪽의 안쪽 폭(@page A4 − 여백)이 같다 (2026-10-29 빈 쪽 10장 회귀)', () => {
+ const css = printPageSource.slice(printPageSource.indexOf('
diff --git a/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts b/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts
new file mode 100644
index 0000000..292e89d
--- /dev/null
+++ b/frontend/src/pages/mail/MailboxPage.scheduled.spec.ts
@@ -0,0 +1,245 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+
+import MailboxPanel from '@/components/mail/MailboxPanel.vue'
+import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
+import { formatScheduleShortTime, formatScheduleTime } from '@/shared/utils/format'
+import { useAuthStore } from '@/stores/auth.store'
+import { useMailStore } from '@/stores/mail.store'
+import { mailViewModeStorageKey } from '@/stores/mail-view.store'
+import { useToastStore } from '@/stores/toast.store'
+import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
+import { createTestRouter } from '@/test-utils/router'
+import MailboxPage from './MailboxPage.vue'
+
+// 예약됨 (9단계 — 디자인 원본 MailSendLater · README "9단계 › 「예약됨」 · 보낸편지함") — /mail/scheduled · /mail/scheduled/
+// 목록(받는 사람 · 제목 · 보낼 시각 — 고르는 칸 · 별 없음) · 메일함 패널의 「예약됨」 · 보낸편지함의 "예약 · …" · "보내는 중"
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const FIRST_SEND_AT = '2026-10-30T00:00:00.000Z'
+const SECOND_SEND_AT = '2026-11-02T00:00:00.000Z'
+
+function scheduled(id: string, sendAt: string, partial: Partial = {}): MessageSummary {
+ return {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: ['sent'],
+ from: USER,
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
+ subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
+ preview: '',
+ receivedAt: '2026-10-29T05:00:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend: { kind: 'scheduled', sendAt },
+ ...partial,
+ }
+}
+
+const S1 = scheduled('s1', FIRST_SEND_AT)
+const S2 = scheduled('s2', SECOND_SEND_AT, {
+ subject: '[공지] 메일 서버 전환 완료 안내',
+ to: [
+ { name: '인프라팀', email: 'infra@arasystem.it.kr' },
+ { name: null, email: 'a@arasystem.it.kr' },
+ { name: null, email: 'b@arasystem.it.kr' },
+ ],
+})
+const MAILBOXES: Mailbox[] = [
+ { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 5 },
+ { id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 3, unreadCount: 0 },
+ { id: 'drafts', name: '임시보관함', role: 'drafts', parentId: null, sortOrder: 2, totalCount: 0, unreadCount: 0 },
+ { id: 'custom', name: '거래처', role: null, parentId: null, sortOrder: 3, totalCount: 0, unreadCount: 0 },
+]
+
+function detailOf(summary: MessageSummary): MessageDetail {
+ return {
+ ...summary,
+ cc: [],
+ bcc: [],
+ replyTo: [],
+ sentAt: null,
+ messageId: null,
+ references: [],
+ htmlBody: null,
+ textBody: '본문',
+ hasBlockedImages: false,
+ attachments: [],
+ }
+}
+
+describe('예약됨 · 보낸편지함 (9단계)', () => {
+ let pinia: Pinia
+ let requests: MockRequest[]
+ const mounted: VueWrapper[] = []
+
+ beforeEach(() => {
+ window.localStorage.clear()
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().markAuthenticated(USER)
+ requests = installMockApi((request) => {
+ if (request.url === '/scheduled-messages') {
+ const items = request.params.limit === 1 ? [S1] : [S1, S2]
+ return ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
+ }
+ if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 4 } })
+ if (request.url === '/messages') {
+ const sending = scheduled('u1', '2026-10-29T06:00:10.000Z', { pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } })
+ const plain = scheduled('p1', FIRST_SEND_AT, { pendingSend: null })
+ return ok({ items: [S1, sending, plain], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
+ }
+ if (request.url.startsWith('/messages/')) return ok(detailOf(S1))
+ return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
+ })
+ })
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ vi.restoreAllMocks()
+ useToastStore().clear()
+ restoreApiAdapter()
+ })
+
+ async function mountAt(path: string) {
+ const router = createTestRouter()
+ await router.push(path)
+ await router.isReady()
+ await useMailStore().loadMailboxes()
+ const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
+ mounted.push(wrapper)
+ await flushPromises()
+ return { wrapper, router }
+ }
+
+ function rows(wrapper: VueWrapper) {
+ return wrapper.findAll('.scheduled-row').map((row) => ({
+ who: row.get('.scheduled-row__who').text(),
+ subject: row.get('.scheduled-row__subject').text(),
+ time: row.get('.scheduled-row__time').text(),
+ href: row.get('a').attributes('href'),
+ }))
+ }
+
+ it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => {
+ const { wrapper } = await mountAt('/mail/scheduled')
+
+ expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 })
+ expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨')
+ expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례')
+ expect(rows(wrapper)).toEqual([
+ {
+ who: '받는 사람: 이도윤',
+ subject: '11월 첫 주 배포 일정 확인 부탁드립니다',
+ time: formatScheduleTime(FIRST_SEND_AT),
+ href: '/mail/scheduled/s1',
+ },
+ {
+ who: '받는 사람: 인프라팀 외 2명',
+ subject: '[공지] 메일 서버 전환 완료 안내',
+ time: formatScheduleTime(SECOND_SEND_AT),
+ href: '/mail/scheduled/s2',
+ },
+ ])
+ // 고르는 칸 · 별 · 알림 권하기 줄은 없다
+ expect(wrapper.find('.mail-checkbox').exists()).toBe(false)
+ expect(wrapper.find('[aria-label="중요 표시"]').exists()).toBe(false)
+ expect(wrapper.find('.reader').exists()).toBe(true)
+ // 패널의 「예약됨」 수도 이 목록의 total 로 맞춘다
+ expect(useMailStore().scheduledCount).toBe(2)
+ })
+
+ it('목록 보기 — 한 줄 세 칸 · 메일을 열면 본문을 넓게(예약한 메일 — 청록 안내 줄)', async () => {
+ window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
+ const { wrapper } = await mountAt('/mail/scheduled')
+ expect(wrapper.findAll('.scheduled-row--list')).toHaveLength(2)
+ expect(wrapper.find('table').exists()).toBe(false)
+
+ const opened = await mountAt('/mail/scheduled/s1')
+ expect(opened.wrapper.find('.scheduled-row').exists()).toBe(false)
+ expect(opened.wrapper.get('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)} 에 보낼 메일입니다.`)
+ expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
+ })
+
+ it('보낸편지함 — 아직 보내지 않은 메일은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · 보내기 취소를 기다리는 동안 "보내는 중"', async () => {
+ const { wrapper } = await mountAt('/mail/sent')
+
+ const times = wrapper.findAll('.message-row__time')
+ expect(times.map((time) => time.text())).toEqual([
+ `예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`,
+ '보내는 중',
+ expect.not.stringContaining('예약'),
+ ])
+ expect(times.map((time) => time.classes().includes('is-pending'))).toEqual([true, true, false])
+ })
+
+ it('보낸편지함(목록 보기) — 날짜 칸도 같은 글, 칸보다 길면 전체를 title 로', async () => {
+ window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
+ const { wrapper } = await mountAt('/mail/sent')
+
+ const dates = wrapper.findAll('td.mail-table__date')
+ expect(dates[0]?.text()).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
+ expect(dates[0]?.attributes('title')).toBe(`예약 · ${formatScheduleShortTime(FIRST_SEND_AT)}`)
+ expect(dates[1]?.text()).toBe('보내는 중')
+ expect(dates[2]?.attributes('title')).toBeUndefined()
+ })
+
+ describe('메일함 패널의 「예약됨」 — 예약한 메일이 있을 때만 보낸편지함 아래 (시계 · 통 수)', () => {
+ async function mountPanel(path: string) {
+ const router = createTestRouter()
+ await router.push(path)
+ await router.isReady()
+ await useMailStore().loadMailboxes()
+ const wrapper = mount(MailboxPanel, { global: { plugins: [pinia, router] } })
+ mounted.push(wrapper)
+ await flushPromises()
+ return wrapper
+ }
+
+ const labels = (wrapper: VueWrapper) =>
+ wrapper.findAll('.mailbox-tree__list').at(0)?.findAll('li').map((item) => item.text()) ?? []
+
+ it('없으면 그리지 않고, 생기면 보낸편지함 바로 아래 — 오른쪽에 통 수', async () => {
+ const wrapper = await mountPanel('/mail/inbox')
+ expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
+
+ await useMailStore().loadScheduledCount()
+ await flushPromises()
+
+ expect(labels(wrapper)).toEqual(['받은편지함안 읽음 5', '보낸편지함', '예약됨2통', '임시보관함'])
+ const link = wrapper.get('[data-scheduled-mailbox]')
+ expect(link.attributes('href')).toBe('/mail/scheduled')
+ expect(link.find('polyline').attributes('points')).toBe('12 6 12 12 16 14')
+ expect(link.attributes('aria-current')).toBeUndefined()
+ })
+
+ it('예약됨을 보고 있으면 지금 메일함으로 표시한다 — 다른 메일함은 표시하지 않는다', async () => {
+ useMailStore().openScheduled()
+ const wrapper = await mountPanel('/mail/scheduled')
+
+ expect(wrapper.get('[data-scheduled-mailbox]').attributes('aria-current')).toBe('page')
+ expect(wrapper.findAll('[aria-current="page"]')).toHaveLength(1)
+ })
+
+ it('예약됨을 보는 동안은 0통이 되어도 줄이 남는다(숫자 없이) — 떠나면 사라진다 (리뷰 #12)', async () => {
+ const store = useMailStore()
+ const wrapper = await mountPanel('/mail/scheduled')
+ store.scheduledCount = 0
+ await flushPromises()
+
+ const link = wrapper.get('[data-scheduled-mailbox]')
+ expect(link.attributes('aria-current')).toBe('page')
+ expect(link.find('.scheduled-mailbox__count').exists()).toBe(false)
+
+ const router = wrapper.vm.$router
+ await router.push('/mail/inbox')
+ await flushPromises()
+ expect(wrapper.find('[data-scheduled-mailbox]').exists()).toBe(false)
+ })
+ })
+})
diff --git a/frontend/src/pages/mail/MailboxPage.vue b/frontend/src/pages/mail/MailboxPage.vue
index 51996e6..f03d1c4 100644
--- a/frontend/src/pages/mail/MailboxPage.vue
+++ b/frontend/src/pages/mail/MailboxPage.vue
@@ -1,10 +1,12 @@
+
+
+
+
+ {{ countText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+ {{ notice }}
+
+
+
+
+ -
+
+ {{ sender.address }}
+ {{ dayText(sender) }}
+
+
+
+
+
+ {{ MAIL_BLOCK_TEXT.empty }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/MailSendReadPage.spec.ts b/frontend/src/pages/settings/MailSendReadPage.spec.ts
new file mode 100644
index 0000000..5293109
--- /dev/null
+++ b/frontend/src/pages/settings/MailSendReadPage.spec.ts
@@ -0,0 +1,303 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia, type Pinia } from 'pinia'
+
+import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
+import type { MailSettings } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
+import { createTestRouter } from '@/test-utils/router'
+import MailSendReadPage from './MailSendReadPage.vue'
+
+// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
+
+const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
+const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
+
+interface Server {
+ settings?: MailSettings
+ /** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
+ load?: MockResult
+ patch?: (request: MockRequest) => MockResult | Promise
+}
+
+describe('MailSendReadPage', () => {
+ let pinia: Pinia
+ let requests: MockRequest[]
+ const mounted: VueWrapper[] = []
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ useAuthStore().markAuthenticated(USER)
+ })
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) {
+ if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
+ }
+ useToastStore().clear()
+ restoreApiAdapter()
+ document.body.innerHTML = ''
+ })
+
+ async function mountPage(server: Server = {}) {
+ let stored: MailSettings = { ...DEFAULTS, ...server.settings }
+ requests = installMockApi((request) => {
+ if (request.method === 'GET') return server.load ?? ok(stored)
+ if (server.patch) return server.patch(request)
+ stored = { ...stored, ...(request.data as Partial) }
+ return ok(stored)
+ })
+ const router = createTestRouter()
+ await router.push('/settings/mail/send-read')
+ await router.isReady()
+ const wrapper = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
+ mounted.push(wrapper)
+ await flushPromises()
+ return wrapper
+ }
+
+ const pressed = (wrapper: VueWrapper) =>
+ wrapper.findAll('[data-undo-seconds]').map((button) => [button.text(), button.attributes('aria-pressed')])
+ const checkedImages = (wrapper: VueWrapper) =>
+ wrapper.findAll('[data-external-images]').filter((input) => (input.element as HTMLInputElement).checked).map((input) => input.attributes('value'))
+ const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
+
+ it('들어올 때 받은 값으로 그린다 — 보내기 취소(끔 · 5초 · 10초 · 20초 · 30초) · 외부 이미지 라디오 셋 · 안내 상자', async () => {
+ const wrapper = await mountPage({ settings: { undoSendSeconds: 20, externalImages: 'trusted' } })
+
+ expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-settings'])
+ expect(wrapper.get('#send-read-page-title').text()).toBe('보내기 · 읽기')
+ expect(pressed(wrapper)).toEqual([
+ ['끔', 'false'],
+ ['5초', 'false'],
+ ['10초', 'false'],
+ ['20초', 'true'],
+ ['30초', 'false'],
+ ])
+ expect(wrapper.get('#send-read-undo-hint').text()).toBe(
+ '[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
+ )
+ expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([
+ '늘 묻기',
+ '주소록 · 사내 직원의 메일은 늘 보기',
+ '늘 보기',
+ ])
+ expect(checkedImages(wrapper)).toEqual(['trusted'])
+ expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ })
+
+ it('고르는 대로 그 칸만 PATCH 하고 "저장했습니다"(role=status) — 다시 고르면 내렸다가 저장되면 다시', async () => {
+ let release: () => void = () => undefined
+ const wrapper = await mountPage({
+ patch: (request) =>
+ new Promise((resolve) => {
+ release = () => resolve(ok({ ...DEFAULTS, ...(request.data as Partial) }))
+ }),
+ })
+
+ await wrapper.get('[data-undo-seconds="30"]').trigger('click')
+ // 화면을 먼저 바꾼다
+ expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('30초')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ release()
+ await flushPromises()
+ expect(wrapper.get('[data-settings-status]').attributes('role')).toBe('status')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+
+ await wrapper.get('[data-external-images="always"]').setValue(true)
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ release()
+ await flushPromises()
+
+ expect(patches()).toEqual([{ undoSendSeconds: 30 }, { externalImages: 'always' }])
+ expect(checkedImages(wrapper)).toEqual(['always'])
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+
+ it('지금 값을 다시 누르면 보내지 않는다', async () => {
+ const wrapper = await mountPage()
+
+ await wrapper.get('[data-undo-seconds="10"]').trigger('click')
+ await flushPromises()
+
+ expect(patches()).toEqual([])
+ })
+
+ it('저장하지 못하면 고르기 전 값으로 되돌린다 — 알림은 인터셉터', async () => {
+ const wrapper = await mountPage({ patch: () => fail(400, 'VAL_001') })
+
+ await wrapper.get('[data-undo-seconds="0"]').trigger('click')
+ await flushPromises()
+ await wrapper.get('[data-external-images="always"]').setValue(true)
+ await flushPromises()
+
+ expect(pressed(wrapper).find(([, value]) => value === 'true')?.[0]).toBe('10초')
+ expect(checkedImages(wrapper)).toEqual(['ask'])
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ })
+
+ describe('연달아 고르면 — 칸마다 저장은 한 번에 하나, 앞 저장이 끝난 뒤 마지막으로 고른 것만 보낸다 (2026-10-29 리뷰 #3)', () => {
+ /** 보낸 PATCH 를 차례로 잡아 둔다 — 테스트가 응답을 정한다. 서버는 받은 차례대로 저장한다(stored) */
+ async function mountHeld() {
+ const held: { data: Partial; resolve: (result: MockResult) => void }[] = []
+ let stored: MailSettings = { ...DEFAULTS }
+ const wrapper = await mountPage({
+ patch: (request) =>
+ new Promise((resolve) => {
+ const data = request.data as Partial
+ held.push({ data, resolve })
+ }),
+ })
+ /** n 번째 PATCH 를 서버가 저장하고 답한다 */
+ function save(index: number) {
+ const request = held[index]
+ if (!request) throw new Error(`PATCH ${index} 가 없다`)
+ stored = { ...stored, ...request.data }
+ request.resolve(ok(stored))
+ }
+ function reject(index: number, result: MockResult) {
+ const request = held[index]
+ if (!request) throw new Error(`PATCH ${index} 가 없다`)
+ request.resolve(result)
+ }
+ return { wrapper, held, save, reject, stored: () => stored }
+ }
+ const pressedLabel = (wrapper: VueWrapper) => pressed(wrapper).find(([, value]) => value === 'true')?.[0]
+
+ it('기다리는 동안 고른 것은 보내지 않고 기억했다가, 앞 저장이 끝나면 마지막 것 하나만 보낸다 — 화면 · 서버가 같다', async () => {
+ const { wrapper, save, stored } = await mountHeld()
+
+ await wrapper.get('[data-undo-seconds="5"]').trigger('click')
+ await wrapper.get('[data-undo-seconds="30"]').trigger('click')
+ await wrapper.get('[data-undo-seconds="20"]').trigger('click')
+ // 화면은 마지막으로 고른 값 · 요청은 하나
+ expect(pressedLabel(wrapper)).toBe('20초')
+ expect(patches()).toEqual([{ undoSendSeconds: 5 }])
+
+ save(0)
+ await flushPromises()
+ expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 20 }])
+ expect(pressedLabel(wrapper)).toBe('20초')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+
+ save(1)
+ await flushPromises()
+ expect(stored().undoSendSeconds).toBe(20)
+ expect(pressedLabel(wrapper)).toBe('20초')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+
+ it('외부 이미지 「늘 보기」 → 「늘 묻기」를 빠르게 — 늦게 끝난 앞 저장 뒤에 「늘 묻기」를 보내 DB 도 늘 묻기다 (화면 늘 묻기 · DB 늘 보기로 갈리지 않는다)', async () => {
+ const { wrapper, save, stored } = await mountHeld()
+
+ await wrapper.get('[data-external-images="always"]').setValue(true)
+ await wrapper.get('[data-external-images="ask"]').setValue(true)
+ expect(checkedImages(wrapper)).toEqual(['ask'])
+ expect(patches()).toEqual([{ externalImages: 'always' }])
+
+ save(0)
+ await flushPromises()
+ save(1)
+ await flushPromises()
+
+ expect(patches()).toEqual([{ externalImages: 'always' }, { externalImages: 'ask' }])
+ expect(stored().externalImages).toBe('ask')
+ expect(checkedImages(wrapper)).toEqual(['ask'])
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+
+ it('기다리는 동안 처음 값으로 되돌려 고르면 — 앞 저장이 끝난 뒤 처음 값을 다시 보낸다 (서버는 앞 값을 저장했으므로)', async () => {
+ const { wrapper, save, stored } = await mountHeld()
+
+ await wrapper.get('[data-undo-seconds="30"]').trigger('click')
+ await wrapper.get('[data-undo-seconds="10"]').trigger('click')
+ save(0)
+ await flushPromises()
+ save(1)
+ await flushPromises()
+
+ expect(patches()).toEqual([{ undoSendSeconds: 30 }, { undoSendSeconds: 10 }])
+ expect(stored().undoSendSeconds).toBe(10)
+ expect(pressedLabel(wrapper)).toBe('10초')
+ })
+
+ it('앞 저장이 실패해도 그사이 고른 것을 보낸다 — 마지막이 성공하면 그 값 · "저장했습니다"', async () => {
+ const { wrapper, save, reject } = await mountHeld()
+
+ await wrapper.get('[data-undo-seconds="5"]').trigger('click')
+ await wrapper.get('[data-undo-seconds="30"]').trigger('click')
+ reject(0, fail(503, 'MAIL_001'))
+ await flushPromises()
+ expect(patches()).toEqual([{ undoSendSeconds: 5 }, { undoSendSeconds: 30 }])
+ expect(pressedLabel(wrapper)).toBe('30초')
+
+ save(1)
+ await flushPromises()
+ expect(pressedLabel(wrapper)).toBe('30초')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+
+ it('마지막으로 고른 것의 저장이 실패하면 서버가 마지막으로 확인해 준 값으로 되돌린다 — "저장했습니다"는 없다', async () => {
+ const { wrapper, save, reject } = await mountHeld()
+
+ await wrapper.get('[data-undo-seconds="5"]').trigger('click')
+ await wrapper.get('[data-undo-seconds="30"]').trigger('click')
+ save(0)
+ await flushPromises()
+ // 같은 코드의 알림은 3초 동안 한 번이다(인터셉터) — 이 파일에서 아직 쓰지 않은 코드로 실패시킨다
+ reject(1, fail(500, 'SYS_001'))
+ await flushPromises()
+
+ // 서버가 확인한 값은 5초 (앞 저장)
+ expect(pressedLabel(wrapper)).toBe('5초')
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.SYS_001])
+ })
+
+ it('두 칸은 따로 저장한다 — 둘 다 끝나야 "저장했습니다"', async () => {
+ const { wrapper, save } = await mountHeld()
+
+ await wrapper.get('[data-undo-seconds="0"]').trigger('click')
+ await wrapper.get('[data-external-images="trusted"]').setValue(true)
+ expect(patches()).toEqual([{ undoSendSeconds: 0 }, { externalImages: 'trusted' }])
+
+ save(0)
+ await flushPromises()
+ expect(wrapper.get('[data-settings-status]').text()).toBe('')
+ save(1)
+ await flushPromises()
+ expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+
+ it('저장하는 중에 떠났다가 돌아오면(다시 받음) 남은 저장을 세지 않는다 — 그 뒤 저장하면 "저장했습니다" (리뷰 #7)', async () => {
+ const { wrapper, save } = await mountHeld()
+ await wrapper.get('[data-undo-seconds="5"]').trigger('click')
+ wrapper.unmount()
+
+ // 다시 들어온다 — 화면이 설정을 새로 받는다
+ const again = mount(MailSendReadPage, { attachTo: document.body, global: { plugins: [pinia, createTestRouter()] } })
+ mounted.push(again)
+ await flushPromises()
+ save(0)
+ await flushPromises()
+
+ await again.get('[data-undo-seconds="20"]').trigger('click')
+ save(1)
+ await flushPromises()
+ expect(again.get('[data-settings-status]').text()).toBe('저장했습니다')
+ })
+ })
+
+ it('못 받으면 사전 문구와 "다시 시도"', async () => {
+ const wrapper = await mountPage({ load: fail(503, 'MAIL_001') })
+
+ expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
+ expect(wrapper.find('[data-undo-seconds]').exists()).toBe(false)
+ expect(useToastStore().toasts).toEqual([])
+ })
+})
diff --git a/frontend/src/pages/settings/MailSendReadPage.vue b/frontend/src/pages/settings/MailSendReadPage.vue
new file mode 100644
index 0000000..7aadc1d
--- /dev/null
+++ b/frontend/src/pages/settings/MailSendReadPage.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+ {{ MAIL_SETTINGS_TEXT.saved }}
+
+
+
+
+
+
+
+
+
+ {{ MAIL_SETTINGS_TEXT.undoTitle }}
+
+
+
+
+
+ {{ MAIL_SETTINGS_TEXT.undoHint }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/settings/SettingsLayout.spec.ts b/frontend/src/pages/settings/SettingsLayout.spec.ts
index cc1cb23..dfb5709 100644
--- a/frontend/src/pages/settings/SettingsLayout.spec.ts
+++ b/frontend/src/pages/settings/SettingsLayout.spec.ts
@@ -9,7 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
-// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
+// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림
+// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -34,7 +35,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
- it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
+ it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -47,6 +48,8 @@ describe('SettingsLayout', () => {
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
['필터', '/settings/mail/filters'],
+ ['보내기 · 읽기', '/settings/mail/send-read'],
+ ['차단한 주소', '/settings/mail/blocks'],
['알림', '/settings/mail/notify'],
],
])
@@ -82,6 +85,27 @@ describe('SettingsLayout', () => {
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
})
+ it('설정 › 메일 › 보내기 · 읽기 · 차단한 주소 (9단계) — 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
+ const router = createRouter({ history: createMemoryHistory(), routes })
+
+ await router.push('/settings/mail/send-read')
+ expect(router.currentRoute.value.name).toBe('settings-mail-send-read')
+ expect(router.currentRoute.value.meta).toMatchObject({ title: '보내기 · 읽기', requiresAuth: true })
+
+ await router.push('/settings/mail/blocks')
+ expect(router.currentRoute.value.name).toBe('settings-mail-blocks')
+ expect(router.currentRoute.value.meta).toMatchObject({ title: '차단한 주소', requiresAuth: true })
+ })
+
+ it('차단한 주소 메뉴는 원 + 빗금 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
+ const { wrapper } = await mountAt('/settings/mail/blocks')
+ const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
+
+ expect(current.text()).toBe('차단한 주소')
+ expect(current.find('circle').attributes()).toMatchObject({ cx: '12', cy: '12', r: '10' })
+ expect(current.find('line').attributes()).toMatchObject({ x1: '4.93', y1: '4.93', x2: '19.07', y2: '19.07' })
+ })
+
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/filters')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts
index 7f2c596..1c44958 100644
--- a/frontend/src/router/routes.ts
+++ b/frontend/src/router/routes.ts
@@ -1,8 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
+import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
-import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
+import {
+ MAIL_SCHEDULED_MESSAGE_ROUTE,
+ MAIL_SCHEDULED_ROUTE,
+ MAIL_SEARCH_MESSAGE_ROUTE,
+ MAIL_SEARCH_ROUTE,
+} from '@/shared/utils/mail-list-source'
// 앱의 라우트 표 — 라우터(index.ts)와 라우트 테스트가 함께 쓴다 (이 파일은 라우터를 만들지 않는다)
@@ -25,6 +31,15 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/LoginPage.vue'),
meta: { guestOnly: true, title: '로그인' },
},
+ {
+ // 인쇄 화면 (9단계 — 디자인 README "9단계 › 인쇄 화면") — 앱 틀 없이 메일 한 통. 읽기 화면 ⋯ › 인쇄 · Ctrl+P 가 새 탭으로 연다.
+ // 읽기 화면에서 외부 이미지를 보이고 있었으면 ?images=show. /mail 의 하위가 아니다(메일 레이아웃 · 앱 틀을 그리지 않는다) —
+ // 고정 경로라 /mail/:mailboxId/:messageId 보다 앞선다
+ path: '/mail/print/:messageId',
+ name: MAIL_PRINT_ROUTE,
+ component: () => import('@/pages/mail/MailPrintPage.vue'),
+ meta: { requiresAuth: true, title: '메일 인쇄' },
+ },
{
path: '/mail',
component: () => import('@/pages/mail/MailLayout.vue'),
@@ -47,6 +62,15 @@ export const routes: RouteRecordRaw[] = [
component: MailboxPage,
meta: { title: '메일 검색' },
},
+ // 예약됨 /mail/scheduled · 예약됨에서 연 메일 /mail/scheduled/<메일 id> (9단계 — 계약 2절 "보내기 취소 · 예약 발송")
+ // 검색과 같이 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
+ { path: 'scheduled', name: MAIL_SCHEDULED_ROUTE, component: MailboxPage, meta: { title: '예약됨' } },
+ {
+ path: 'scheduled/:messageId',
+ name: MAIL_SCHEDULED_MESSAGE_ROUTE,
+ component: MailboxPage,
+ meta: { title: '예약됨' },
+ },
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
@@ -71,7 +95,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
- // 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림) + 하위 화면.
+ // 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -103,6 +127,18 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailFilterPage.vue'),
meta: { title: '메일 필터' },
},
+ {
+ path: 'mail/send-read',
+ name: SETTINGS_ROUTES.sendRead,
+ component: () => import('@/pages/settings/MailSendReadPage.vue'),
+ meta: { title: '보내기 · 읽기' },
+ },
+ {
+ path: 'mail/blocks',
+ name: SETTINGS_ROUTES.blocks,
+ component: () => import('@/pages/settings/MailBlocksPage.vue'),
+ meta: { title: '차단한 주소' },
+ },
{
path: 'mail/notify',
name: SETTINGS_ROUTES.notify,
diff --git a/frontend/src/shared/constants/icons.ts b/frontend/src/shared/constants/icons.ts
index 1d15ba9..14e7e1f 100644
--- a/frontend/src/shared/constants/icons.ts
+++ b/frontend/src/shared/constants/icons.ts
@@ -283,6 +283,18 @@ export const ICONS = {
],
/** 연락처 ⋯ 「주소 복사」 */
copy: [rect(9, 9, 13, 13, 2), path('M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1')],
+
+ // ── 9단계: 보내기 취소 · 예약 발송 · 인쇄 · 보낸 사람 차단 (디자인 원본 MailReadTools · MailPrint · MailSendLater) ──
+ /** 읽기 화면 ⋯ 「인쇄」 · 인쇄 화면의 [인쇄] */
+ printer: [
+ polyline('6 9 6 2 18 2 18 9'),
+ path('M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'),
+ rect(6, 14, 12, 8),
+ ],
+ /** 「예약 발송…」 · 메일함 패널의 「예약됨」 · 예약 안내 줄 · 예약 알림 */
+ clock: [circle(12, 12, 10), polyline('12 6 12 12 16 14')],
+ /** 「보낸 사람 차단」 · 차단한 보낸 사람의 메일 안내 줄 · 설정의 차단한 주소 (원 + 빗금) */
+ block: [circle(12, 12, 10), line(4.93, 4.93, 19.07, 19.07)],
} as const satisfies Record
export type IconName = keyof typeof ICONS
diff --git a/frontend/src/shared/constants/mail-block.ts b/frontend/src/shared/constants/mail-block.ts
new file mode 100644
index 0000000..6bb8db9
--- /dev/null
+++ b/frontend/src/shared/constants/mail-block.ts
@@ -0,0 +1,112 @@
+// 보낸 사람 차단 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면" · "설정 · 차단한 주소", 원본 MailReadTools · SettingsSendRead)
+
+/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 서버도 400 VAL_001 */
+export const MAIL_BLOCK_MAX = 500
+
+/** 도메인 차단의 앞 글자 — "@spam.example" 은 그 도메인에서 오는 메일 모두 */
+export const MAIL_BLOCK_DOMAIN_PREFIX = '@'
+
+/** 도메인 길이 — 서버와 같다 (253자) */
+export const MAIL_BLOCK_DOMAIN_MAX_LENGTH = 253
+
+/**
+ * 여러 사람이 쓰는 메일 서비스 — 도메인으로는 막을 수 없다(주소로는 된다).
+ * ⚠️ 짝: backend `src/modules/mail/mail.constants.ts` 의 `PUBLIC_MAIL_DOMAINS`(계약 2절 "보낸 사람 차단"의 "검사") — 한쪽을 고치면 둘 다, 이 차례 그대로
+ * (서버의 mail-blocks.spec.ts 가 글자 · 차례를 그대로 본다).
+ */
+export const PUBLIC_MAIL_DOMAINS: readonly string[] = [
+ 'gmail.com',
+ 'googlemail.com',
+ 'naver.com',
+ 'daum.net',
+ 'hanmail.net',
+ 'kakao.com',
+ 'nate.com',
+ 'empas.com',
+ 'hanmir.com',
+ 'korea.com',
+ 'dreamwiz.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'yahoo.com',
+ 'yahoo.co.kr',
+ 'icloud.com',
+ 'me.com',
+ 'mac.com',
+ 'aol.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'gmx.com',
+ 'gmx.net',
+ 'mail.com',
+ 'zoho.com',
+ 'yandex.com',
+ 'qq.com',
+ '163.com',
+]
+
+export const MAIL_BLOCK_TEXT = {
+ // ── 읽기 화면 ⋯ ──
+ menuBlock: '보낸 사람 차단',
+ menuUnblock: '보낸 사람 차단 풀기',
+ /** 500개를 다 막았을 때 항목 아래 까닭 (보드에 없다) */
+ fullNote: `차단한 주소는 ${MAIL_BLOCK_MAX}개까지 둘 수 있습니다. 쓰지 않는 차단을 풀어 주세요.`,
+
+ // ── 차단 확인 창 ──
+ dialogTitle: '보낸 사람 차단',
+ dialogMessage: '앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.',
+ scopeGroup: '차단할 범위',
+ scopeAddress: '이 주소만',
+ scopeDomain: '이 도메인 전체',
+ /** "@cloudbrief.example 에서 오는 메일 모두" */
+ scopeDomainHint: (domain: string) => `${domain} 에서 오는 메일 모두`,
+ /** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
+ scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
+ moveToJunk: '이 메일도 스팸메일함으로 옮기기',
+ settingsHint: '차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.',
+ confirm: '차단',
+ cancel: '취소',
+
+ // ── 알림 (차단 · 풀기 · 되돌리기) ──
+ /** particle 은 korean.ts 의 objectParticle — 영문 주소는 "을(를)" */
+ blockedAndMoved: (address: string, particle: string) =>
+ `‘${address}’${particle} 차단하고 이 메일을 스팸메일함으로 옮겼습니다.`,
+ /** 옮기지 않았을 때 (「이 메일도 …」를 껐다 · 이미 스팸메일함) — 보드에 없다 */
+ blocked: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다.`,
+ unblocked: (address: string) => `‘${address}’ 차단을 풀었습니다.`,
+ /** [되돌리기] 뒤 — 보드에 없다 */
+ blockRestored: (address: string) => `‘${address}’ 차단을 풀고 메일을 되돌렸습니다.`,
+ unblockRestored: (address: string, particle: string) => `‘${address}’${particle} 다시 차단했습니다.`,
+ undo: '되돌리기',
+
+ // ── 스팸메일함에서 읽을 때 ──
+ senderNotice: '차단한 보낸 사람의 메일입니다.',
+ unblock: '차단 풀기',
+
+ // ── 설정 › 메일 › 차단한 주소 ──
+ title: '차단한 주소',
+ /** "3 / 500" */
+ count: (count: string, max: string) => `${count} / ${max}`,
+ description:
+ '이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.',
+ addLabel: '차단할 주소',
+ addPlaceholder: 'kim@example.com 또는 @example.com',
+ add: '차단',
+ /** 칸 아래 오류 */
+ invalid: '메일 주소나 @도메인 형식으로 써 주세요.',
+ duplicate: '이미 차단한 주소입니다.',
+ own: '내 주소 · 사내 도메인은 차단할 수 없습니다.',
+ publicDomain: 'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
+ full: `${MAIL_BLOCK_MAX}개까지 차단할 수 있습니다.`,
+ /** 목록 줄 아래 작은 글 — "10월 29일 차단" · "도메인 전체 · 10월 21일 차단" */
+ blockedOn: (day: string) => `${day} 차단`,
+ domainBlockedOn: (day: string) => `도메인 전체 · ${day} 차단`,
+ empty: '차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
+ /**
+ * 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
+ * (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
+ */
+ inactive: '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.',
+} as const
diff --git a/frontend/src/shared/constants/mail-print.ts b/frontend/src/shared/constants/mail-print.ts
new file mode 100644
index 0000000..016276b
--- /dev/null
+++ b/frontend/src/shared/constants/mail-print.ts
@@ -0,0 +1,32 @@
+// 인쇄 화면 (계약 2절 "인쇄" · 디자인 README "9단계 › 인쇄 화면", 원본 MailPrint)
+
+import { APP_NAME } from '@/shared/constants/brand'
+
+/** 인쇄 화면의 라우트 이름 — 앱 틀 없이 메일 한 통 (router/routes.ts) */
+export const MAIL_PRINT_ROUTE = 'mail-print'
+
+/** 인쇄 단축키 — 읽기 화면에서 Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 연다 */
+export const MAIL_PRINT_SHORTCUT_KEYS: readonly string[] = ['Mod', 'P']
+
+export const MAIL_PRINT_TEXT = {
+ /** 읽기 화면 ⋯ 의 첫 항목 */
+ menu: '인쇄',
+ /** 화면에만 보이는 위 줄 */
+ bar: '인쇄 화면',
+ print: '인쇄',
+ close: '닫기',
+ /** 인쇄되는 머리 줄 왼쪽 */
+ brand: `${APP_NAME} 메일`,
+ from: '보낸 사람',
+ to: '받는 사람',
+ cc: '참조',
+ date: '날짜',
+ attachments: '첨부',
+ /** 첨부 한 줄 — "체크리스트.xlsx (48 KB)" */
+ attachment: (name: string, size: string) => `${name} (${size})`,
+ noSubject: '(제목 없음)',
+ noSender: '(보낸 사람 없음)',
+ loading: '메일을 불러오는 중',
+ notFound: '메일을 찾을 수 없습니다',
+ retry: '다시 시도',
+} as const
diff --git a/frontend/src/shared/constants/mail-send-later.ts b/frontend/src/shared/constants/mail-send-later.ts
new file mode 100644
index 0000000..e655d68
--- /dev/null
+++ b/frontend/src/shared/constants/mail-send-later.ts
@@ -0,0 +1,113 @@
+// 보내기 취소 · 예약 발송 · 예약됨 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
+
+/** 예약할 수 있는 가장 이른 때 — 지금부터 5분 뒤 (서버도 같은 한도로 400 VAL_001) */
+export const SCHEDULE_MIN_LEAD_MS = 5 * 60_000
+/** 예약할 수 있는 가장 늦은 때 — 7일 안 (엔진 한도 — 계약 2절) */
+export const SCHEDULE_MAX_AHEAD_MS = 7 * 24 * 60 * 60_000
+/** 「직접 고르기」의 시각 단위 — 30분 */
+export const SCHEDULE_SLOT_MINUTES = 30
+/** 하루의 분 */
+export const MINUTES_PER_DAY = 24 * 60
+
+/** 고르기의 정해 둔 시각(브라우저 시간대) — 오늘 오후 6:00 · 내일 오전 9:00 · 내일 오후 1:00 · 다음 주 월요일 오전 9:00 */
+export const SCHEDULE_PRESET_HOURS = {
+ todayEvening: 18,
+ tomorrowMorning: 9,
+ tomorrowAfternoon: 13,
+ nextMonday: 9,
+} as const
+export type SchedulePresetId = keyof typeof SCHEDULE_PRESET_HOURS
+/** 「직접 고르기」를 처음 열 때의 시각 — 내일 오전 9:00 (보드의 처음 값) */
+export const SCHEDULE_CUSTOM_DEFAULT_HOUR = 9
+/** 월요일 — Date.getDay() */
+export const MONDAY = 1
+export const DAYS_PER_WEEK = 7
+
+/** 예약됨 목록 · 패널 개수를 물을 때의 한 쪽 — 패널은 total 만 쓴다 (limit=1) */
+export const SCHEDULED_COUNT_PAGE_SIZE = 1
+
+export const SEND_LATER_TEXT = {
+ // ── 쓰기 화면의 [보내기 ▾] ──
+ /** ▾ 단추의 이름 */
+ menuButton: '보내기 방법 더 보기',
+ menuLabel: '보내기 방법',
+ scheduleItem: '예약 발송…',
+
+ // ── 예약 창 ──
+ dialogTitle: '예약 발송',
+ presetGroup: '보낼 시각',
+ presets: {
+ todayEvening: '오늘 오후',
+ tomorrowMorning: '내일 오전',
+ tomorrowAfternoon: '내일 오후',
+ nextMonday: '다음 주 월요일',
+ } satisfies Record,
+ custom: '직접 고르기',
+ customDate: '날짜',
+ customTime: '시각',
+ /** zone 은 브라우저가 알려 주는 시간대 이름(Intl — "한국 표준시" 등) */
+ note: (zone: string) => `이 PC 의 시간(${zone})으로 보냅니다 · 7일 안에서 고를 수 있습니다.`,
+ /** 시간대 이름을 모를 때 */
+ noteWithoutZone: '이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.',
+ tooSoon: '지금부터 5분 뒤 이후의 시각을 고르세요.',
+ tooLate: '7일 안의 시각만 고를 수 있습니다.',
+ confirm: '예약',
+ cancel: '취소',
+
+ // ── 보낸 뒤 알림 ──
+ /** [보내기 취소] — 붙잡아 둔 동안만 (남은 시간 막대) */
+ undo: '보내기 취소',
+ undone: '보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.',
+ /** 쓰기 화면으로 가지 못했을 때(지금 화면의 나가기 확인에서 머묾 등 — 보드에 없다) — 단추는 [고치기](edit) */
+ undoneStayed: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
+ /** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00 에 보냅니다." (보드 그대로 띄어 쓴다) */
+ scheduled: (time: string) => `${time} 에 보냅니다.`,
+ viewScheduled: '예약됨 보기',
+
+ // ── 예약됨 (메일함 패널 · 목록 · 읽기 화면) ──
+ /** 메일함 패널의 「예약됨」 · 목록 머리 */
+ mailbox: '예약됨',
+ /** 목록 머리의 개수 — "2통 · 보낼 시각 차례" */
+ listCount: (count: string) => `${count}통 · 보낼 시각 차례`,
+ /** 목록이 비었을 때 (보드에 없다 — 보낸 · 취소한 뒤 그 자리에서 비면) */
+ emptyTitle: '예약한 메일이 없습니다',
+ emptyHint: '쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.',
+ /** 표 · 목록의 보낼 시각 칸 이름 */
+ sendAtColumn: '보낼 시각',
+ /** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
+ notice: (time: string) => `${time} 에 보낼 메일입니다.`,
+ edit: '고치기',
+ cancelSchedule: '예약 취소',
+ canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
+ /** [고치기] 뒤 — 쓰기 화면이 그 메일로 열린다 (보드에 없다) */
+ editing: '예약을 취소했습니다. 고친 뒤 다시 보내 주세요.',
+ /**
+ * 아직 보내지 않은 메일(보내는 중 · 예약)의 [삭제] 확인 창 (보드에 없다 — 되돌릴 수 없는 발송 취소라 처음 초점은 「취소」).
+ * 보내기 취소를 기다리는 메일도 같은 창이다 — 취소하지 않고 옮기면 엔진은 그대로 보낸다
+ */
+ deleteTitle: '보내지 않은 메일을 삭제할까요?',
+ deleteMessage: '보내지 않고 휴지통으로 옮깁니다.',
+ deleteConfirm: '삭제',
+ deleteCancel: '취소',
+ deleted: '보내지 않고 휴지통으로 옮겼습니다.',
+
+ // ── 보낸편지함 · 검색 결과의 줄 ──
+ /** time 은 formatScheduleShortTime — "예약 · 10월 30일 오전 9:00" */
+ rowScheduled: (time: string) => `예약 · ${time}`,
+ /** 보내기 취소를 기다리는 동안 */
+ rowSending: '보내는 중',
+ /** 아직 보내지 않은 메일의 고르기 칸(꺼짐)의 까닭 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (보드에 없다) */
+ heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
+} as const
+
+/** 보낼 시각 뒤 이만큼 기다렸다 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다 — 엔진이 풀어도 새 메일 확인 · 실시간 알림은 바뀌지 않는다 */
+export const PENDING_RELEASE_GRACE_MS = 2_000
+/** 다시 본 뒤에도 시각이 지난 채 붙잡힌 것으로 보이면(서버가 늦음 · 이 PC 시계가 빠름) 이 간격으로 다시 본다 */
+export const PENDING_RELEASE_RECHECK_MS = 5_000
+/** 그렇게 다시 보는 횟수 — 넘으면 그 발송은 그만 본다 (다음 새 메일 확인 · 화면 이동이 맞춘다) */
+export const PENDING_RELEASE_MAX_RECHECKS = 3
+/**
+ * [보내기 취소] 알림의 마감을 설정한 초로 셀 때 빼는 몫 — 서버가 붙잡기 시작한 뒤 응답이 오기까지 지난 시간.
+ * 마감이 서버보다 늦으면 늦게 누른 [보내기 취소]가 "이미 보낸 메일"(MAIL_014)이 된다
+ */
+export const UNDO_SEND_DEADLINE_MARGIN_MS = 1_000
diff --git a/frontend/src/shared/constants/mail-settings.ts b/frontend/src/shared/constants/mail-settings.ts
new file mode 100644
index 0000000..88b4647
--- /dev/null
+++ b/frontend/src/shared/constants/mail-settings.ts
@@ -0,0 +1,34 @@
+// 설정 › 메일 › 보내기 · 읽기 (계약 2절 "보내기 · 읽기 설정" · 디자인 README "9단계 › 설정 · 보내기 · 읽기", 원본 SettingsSendRead)
+
+import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
+
+/** 보내기 취소 시간 — 고르는 차례 그대로 (끔 · 5 · 10 · 20 · 30초) */
+export const UNDO_SEND_OPTIONS: readonly UndoSendSeconds[] = [0, 5, 10, 20, 30]
+
+/** 외부 이미지 — 고르는 차례 그대로 */
+export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask', 'trusted', 'always']
+
+export const MAIL_SETTINGS_TEXT = {
+ title: '보내기 · 읽기',
+ /** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
+ description: '고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.',
+ saved: '저장했습니다',
+
+ undoTitle: '보내기 취소',
+ undoOff: '끔',
+ undoSeconds: (seconds: number) => `${seconds}초`,
+ undoHint:
+ '[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
+
+ imagesTitle: '외부 이미지',
+ images: {
+ ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
+ trusted: {
+ label: '주소록 · 사내 직원의 메일은 늘 보기',
+ hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
+ },
+ always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
+ } satisfies Record,
+ imagesNote:
+ '외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
+} as const
diff --git a/frontend/src/shared/constants/mail.ts b/frontend/src/shared/constants/mail.ts
index aca3481..b7eeec1 100644
--- a/frontend/src/shared/constants/mail.ts
+++ b/frontend/src/shared/constants/mail.ts
@@ -151,6 +151,13 @@ export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
export const MAIL_PARTIAL_SEND_TEXT = {
/** count 는 formatCount 로 만든 글자, recipients 는 아래 recipient 를 separator 로 이은 것 (+ others) */
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다 — ${recipients}`,
+ /**
+ * 엔진이 붙잡아 둔 발송(보내기 취소를 기다리는 중 · 9단계)이면 아직 나가지 않았다 — "받지 못합니다" + [보내기 취소]
+ * (디자인 원본 MailSendLater: "보냈지만 1명은 받지 못합니다 — 주소 (까닭)")
+ */
+ pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다 — ${recipients}`,
+ /** 예약 발송이면 — "예약했지만 N명은 받지 못합니다 — …" + [예약됨 보기] (보드에 없다) */
+ scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다 — ${recipients}`,
/** 주소와 까닭 사이를 띄운다 — 긴 주소 뒤에서 줄을 바꿔 까닭 낱말이 쪼개지지 않게 */
recipient: (email: string, reason: string) => `${email} (${reason})`,
separator: ', ',
@@ -321,6 +328,22 @@ export const MAIL_READER_MORE_TEXT = {
createFilter: '이런 메일로 필터 만들기',
} as const
+// ── 외부 이미지 보기 (계약 2절 "외부 이미지 보기" · 디자인 README "9단계 › 읽기 화면") ─────────
+/** GET /messages/{id} 의 쿼리 — images=show 면 외부 이미지를 중계 주소로 바꿔 넣은 본문 (인쇄 화면 주소도 같은 쿼리를 쓴다) */
+export const MAIL_IMAGES_PARAM = 'images'
+export const MAIL_IMAGES_SHOW = 'show'
+/**
+ * 서버가 바꿔 넣는 중계 주소의 앞부분 — 본문 틀(MailBodyFrame)은 외부 이미지를 보일 때만 이 주소로 시작하는 그림을 남긴다
+ * (서버의 전역 prefix /api 와 같다 — 화면과 API 는 같은 주소, 계약 머리말).
+ * 짝: backend `src/modules/mail/mail.constants.ts` 의 `MAIL_IMAGES_ROUTE`(라우트 경로) — 경로를 바꾸면 둘 다
+ */
+export const MAIL_IMAGE_PROXY_PATH = '/api/mail-images/'
+/** 읽기 화면의 노란 안내 줄 + [이미지 보기](36 — 밀도 예외 --size-notice-action) */
+export const MAIL_IMAGES_TEXT = {
+ blocked: '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.',
+ show: '이미지 보기',
+} as const
+
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
diff --git a/frontend/src/shared/constants/settings-menu.ts b/frontend/src/shared/constants/settings-menu.ts
index 1cedef9..24edbe6 100644
--- a/frontend/src/shared/constants/settings-menu.ts
+++ b/frontend/src/shared/constants/settings-menu.ts
@@ -1,4 +1,5 @@
-// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터")
+// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
+// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
@@ -10,6 +11,10 @@ export const SETTINGS_ROUTES = {
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
filters: 'settings-mail-filters',
+ /** 보내기 취소 · 외부 이미지 (9단계) */
+ sendRead: 'settings-mail-send-read',
+ /** 차단한 주소 (9단계) */
+ blocks: 'settings-mail-blocks',
notify: 'settings-mail-notify',
} as const
@@ -39,6 +44,8 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
{ routeName: SETTINGS_ROUTES.filters, label: '필터', icon: 'filter' },
+ { routeName: SETTINGS_ROUTES.sendRead, label: '보내기 · 읽기', icon: 'send' },
+ { routeName: SETTINGS_ROUTES.blocks, label: '차단한 주소', icon: 'block' },
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
diff --git a/frontend/src/shared/types/mail.ts b/frontend/src/shared/types/mail.ts
index b0ca8a3..ca22e42 100644
--- a/frontend/src/shared/types/mail.ts
+++ b/frontend/src/shared/types/mail.ts
@@ -22,6 +22,18 @@ export interface Mailbox {
unreadCount: number
}
+/**
+ * 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계).
+ * undo: [보내기]를 누른 뒤 보내기 취소를 기다리는 중(설정한 초) · scheduled: 예약 발송
+ */
+export type PendingSendKind = 'undo' | 'scheduled'
+
+export interface PendingSend {
+ /** 엔진이 보낼 시각(UTC ISO) — 화면은 남은 시간을 설정한 초가 아니라 이 시각으로 센다 */
+ sendAt: string
+ kind: PendingSendKind
+}
+
export interface MessageSummary {
id: string
threadId: string
@@ -37,6 +49,11 @@ export interface MessageSummary {
hasAttachment: boolean
/** 바이트 */
size: number
+ /**
+ * 아직 보내지 않은 내 메일(보내기 취소를 기다리는 중 · 예약)이면 그 정보 — 보낸편지함 날짜 칸의 "보내는 중" · "예약 · 시각",
+ * 읽기 화면의 예약 안내 줄 (9단계). 그 밖에는 null. 필드가 없으면(9단계 앞 서버) null 로 본다
+ */
+ pendingSend?: PendingSend | null
}
export interface Attachment {
@@ -66,8 +83,13 @@ export interface MessageDetail extends MessageSummary {
/** 서버가 정화한 HTML — 그래도 격리된 iframe 에만 그린다 (MailBodyFrame) */
htmlBody: string | null
textBody: string | null
- /** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 */
+ /** 외부 이미지를 지웠으면 true → "외부 이미지 차단" 안내 + [이미지 보기] (보이게 했으면 false) */
hasBlockedImages: boolean
+ /**
+ * 외부 이미지를 중계 주소(/api/mail-images/…)로 바꿔 넣었으면 true — `images=show` 로 물었거나 설정이 보이게 했다 (9단계 "외부 이미지 보기").
+ * 본문 틀이 이때만 그 주소의 그림을 그린다(MailBodyFrame). 필드가 없으면(9단계 앞 서버) false 로 본다
+ */
+ imagesShown?: boolean
/**
* HTML 이 있었지만 서버의 정화 한도(너무 길거나 깊게 중첩됨)를 넘어 htmlBody 를 비웠으면 true
* → textBody 를 서식 없이 보이며 안내한다 (textBody 도 없으면 "표시할 수 없음"). 필드가 없으면(이전 서버) false 로 본다
@@ -170,6 +192,8 @@ export interface SendMessagePayload {
inReplyTo?: string
/** 이어 쓴 임시 저장본 id — 보낸 뒤 서버가 그 초안을 지운다 (계약 2절 "임시 저장") */
draftId?: string
+ /** 예약 발송(UTC ISO) — 지금부터 5분 뒤 ~ 7일 안. 밖이면 400 VAL_001 (9단계 "보내기 취소 · 예약 발송") */
+ sendAt?: string
}
/** 엔진이 받는 사람을 받지 않은 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등) */
@@ -190,6 +214,49 @@ export interface SentMessage {
id: string
/** 엔진이 받지 않은 사람 — 없으면 빈 배열 */
rejectedRecipients: RejectedRecipient[]
+ /**
+ * 엔진이 붙잡아 둔 발송이면 그 정보(보내기 취소 · 예약 — 9단계), 바로 보냈으면 null.
+ * useMail().sendMessage 가 늘 값(또는 null)으로 맞춘다 — 필드가 없는 옛 서버면 null
+ */
+ pendingSend: PendingSend | null
+}
+
+// ── 보내기 취소 · 예약 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) ─────────────
+
+/** DELETE /scheduled-messages/{id} 응답 — 붙잡은 발송을 취소하고 임시보관함으로 옮긴 그 메일(같은 id) */
+export interface ScheduledMessageCancel {
+ draftId: string
+}
+
+// ── 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) ─────────────
+
+/** 보내기 취소 시간(초) — 0 이면 바로 보낸다 */
+export type UndoSendSeconds = 0 | 5 | 10 | 20 | 30
+
+/** 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 */
+export type ExternalImagesSetting = 'ask' | 'trusted' | 'always'
+
+/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 */
+export interface MailSettings {
+ undoSendSeconds: UndoSendSeconds
+ externalImages: ExternalImagesSetting
+}
+
+// ── 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) ─────────────
+
+export interface BlockedSender {
+ /** 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두. 하위 도메인은 따로) */
+ address: string
+ /** 차단한 시각(UTC ISO) */
+ blockedAt: string
+}
+
+/** GET /mail-blocks */
+export interface MailBlockList {
+ /** 최근에 차단한 것부터, 500개까지 */
+ senders: BlockedSender[]
+ /** 엔진에서 우리 규칙(필터 · 부재중과 같은 스크립트)이 켜져 있는지 — 필터의 isActive 와 같다 */
+ isActive: boolean
}
// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
diff --git a/frontend/src/shared/utils/format.spec.ts b/frontend/src/shared/utils/format.spec.ts
index 17bfd55..c92b5d8 100644
--- a/frontend/src/shared/utils/format.spec.ts
+++ b/frontend/src/shared/utils/format.spec.ts
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
+ formatBlockedDay,
formatDraftSavedTime,
+ formatPrintDateTime,
+ formatScheduleShortTime,
+ formatScheduleTime,
formatFileSize,
formatMailDateTime,
formatMailListTime,
@@ -159,3 +163,29 @@ describe('initialOf', () => {
expect(initialOf(null, 'ara.kim@arasystem.test')).toBe('A')
})
})
+
+describe('9단계 — 예약 시각 · 인쇄 날짜 · 차단한 날', () => {
+ /** 2026-10-30(금) 오전 9시 (서울) */
+ const FRIDAY_NINE = '2026-10-30T00:00:00.000Z'
+
+ it('예약 시각은 "10월 30일 (금) 오전 9:00" — 숫자(ms)로도 받는다', () => {
+ expect(formatScheduleTime(FRIDAY_NINE, SEOUL)).toBe('10월 30일 (금) 오전 9:00')
+ expect(formatScheduleTime(Date.parse(FRIDAY_NINE), SEOUL)).toBe('10월 30일 (금) 오전 9:00')
+ expect(formatScheduleTime('나중에', SEOUL)).toBe('')
+ })
+
+ it('보낸편지함 날짜 칸의 예약 시각은 요일 없이 "10월 30일 오전 9:00"', () => {
+ expect(formatScheduleShortTime(FRIDAY_NINE, SEOUL)).toBe('10월 30일 오전 9:00')
+ })
+
+ it('인쇄 화면의 날짜는 "2026년 10월 26일 (월) 오전 10:42" (보드 그대로 — 요일은 괄호 안)', () => {
+ expect(formatPrintDateTime('2026-10-26T01:42:00Z', SEOUL)).toBe('2026년 10월 26일 (월) 오전 10:42')
+ expect(formatPrintDateTime('', SEOUL)).toBe('')
+ })
+
+ it('차단한 날은 "10월 29일", 올해가 아니면 연도를 붙인다', () => {
+ const now = new Date('2026-10-29T06:00:00Z')
+ expect(formatBlockedDay('2026-10-29T01:00:00Z', now, SEOUL)).toBe('10월 29일')
+ expect(formatBlockedDay('2025-12-31T01:00:00Z', now, SEOUL)).toBe('2025년 12월 31일')
+ })
+})
diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts
index 84685c8..f50b6a5 100644
--- a/frontend/src/shared/utils/format.ts
+++ b/frontend/src/shared/utils/format.ts
@@ -225,6 +225,66 @@ export function formatMailDateTime(value: string, timeZone?: string): string {
}).format(date)
}
+function dateOf(value: string | number): Date | null {
+ return typeof value === 'number' ? (Number.isFinite(value) ? new Date(value) : null) : parseDate(value)
+}
+
+/**
+ * 예약 발송 시각 (디자인 원본 MailSendLater: "10월 30일 (금) 오전 9:00") — 예약 창의 고르기 · 예약 알림 · 예약됨 목록 · 읽기 화면의 예약 안내 줄.
+ * 예약은 7일 안이라 연도를 넣지 않는다
+ */
+export function formatScheduleTime(value: string | number, timeZone?: string): string {
+ const date = dateOf(value)
+ if (!date) return ''
+ return new Intl.DateTimeFormat('ko-KR', {
+ timeZone,
+ month: 'long',
+ day: 'numeric',
+ weekday: 'short',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(date)
+}
+
+/** 보낸편지함 날짜 칸의 예약 시각 — 요일 없이 (디자인 원본 MailSendLater: "예약 · 10월 30일 오전 9:00") */
+export function formatScheduleShortTime(value: string | number, timeZone?: string): string {
+ const date = dateOf(value)
+ if (!date) return ''
+ return new Intl.DateTimeFormat('ko-KR', {
+ timeZone,
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(date)
+}
+
+/**
+ * 인쇄 화면의 날짜 (디자인 원본 MailPrint: "2026년 10월 26일 (월) 오전 10:42").
+ * Intl 은 연도를 넣으면 요일 괄호를 빼므로("… 26일 월 …") 날짜 · 요일 · 시각을 따로 만들어 잇는다
+ */
+export function formatPrintDateTime(value: string, timeZone?: string): string {
+ const date = parseDate(value)
+ if (!date) return ''
+ const day = new Intl.DateTimeFormat('ko-KR', { timeZone, year: 'numeric', month: 'long', day: 'numeric' }).format(date)
+ const weekday = new Intl.DateTimeFormat('ko-KR', { timeZone, weekday: 'short' }).format(date)
+ const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
+ return `${day} (${weekday}) ${time}`
+}
+
+/** 차단한 날 (디자인 원본 SettingsSendRead: "10월 29일 차단") — 올해가 아니면 연도를 붙인다 */
+export function formatBlockedDay(value: string, now: Date = new Date(), timeZone?: string): string {
+ const date = parseDate(value)
+ if (!date) return ''
+ const sameYear = calendarDayOf(date, timeZone).year === calendarDayOf(now, timeZone).year
+ return new Intl.DateTimeFormat('ko-KR', {
+ timeZone,
+ year: sameYear ? undefined : 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(date)
+}
+
/** 문의 코드로 보여 줄 traceId 앞자리 수 (디자인 원본 States: "문의 코드 3f1c0a2e") */
export const TRACE_CODE_LENGTH = 8
diff --git a/frontend/src/shared/utils/keyboard-shortcut.spec.ts b/frontend/src/shared/utils/keyboard-shortcut.spec.ts
index 1eacc55..8609f15 100644
--- a/frontend/src/shared/utils/keyboard-shortcut.spec.ts
+++ b/frontend/src/shared/utils/keyboard-shortcut.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { buildEditorShortcuts } from '@/shared/constants/mail-editor'
-import { formatShortcut, isApplePlatform } from './keyboard-shortcut'
+import { formatShortcut, isApplePlatform, isShortcutEvent } from './keyboard-shortcut'
describe('isApplePlatform — TipTap(prosemirror-keymap)의 Mod 판정과 같다', () => {
it.each(['MacIntel', 'iPhone', 'iPad', 'iPod'])('맥 · 아이폰 · 아이패드: %s', (platform) => {
@@ -44,3 +44,36 @@ describe('buildEditorShortcuts — 편집기 단축키 (디자인 README "단축
expect(Object.values(shortcuts).every((shortcut) => !shortcut.label.includes('Ctrl'))).toBe(true)
})
})
+
+describe('isShortcutEvent — 누른 키가 그 단축키인가 (9단계 읽기 화면의 Ctrl+P)', () => {
+ const press = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init)
+ const PRINT = ['Mod', 'P']
+
+ it('윈도우 · 리눅스는 Ctrl, 맥은 ⌘ — 대소문자는 가리지 않는다', () => {
+ expect(isShortcutEvent(press({ key: 'p', ctrlKey: true }), PRINT, false)).toBe(true)
+ expect(isShortcutEvent(press({ key: 'P', ctrlKey: true }), PRINT, false)).toBe(true)
+ expect(isShortcutEvent(press({ key: 'p', metaKey: true }), PRINT, true)).toBe(true)
+ expect(isShortcutEvent(press({ key: 'p', metaKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'p', ctrlKey: true }), PRINT, true)).toBe(false)
+ })
+
+ it('적지 않은 수정 글쇠를 함께 눌렀거나 다른 글쇠면 아니다', () => {
+ expect(isShortcutEvent(press({ key: 'p', ctrlKey: true, shiftKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'p', ctrlKey: true, altKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'p' }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'o', ctrlKey: true }), PRINT, false)).toBe(false)
+ })
+
+ it('라틴 글자가 아닌 배열(러시아어 · 그리스어 등)은 자판의 자리(code)로 본다 — 라틴 배열(드보락)은 글자 그대로 (리뷰 #12)', () => {
+ // 러시아어 배열의 Ctrl+P 는 'з', 그리스어는 'π' 로 온다 — 브라우저 인쇄처럼 P 자리면 인쇄다
+ expect(isShortcutEvent(press({ key: 'з', code: 'KeyP', ctrlKey: true }), PRINT, false)).toBe(true)
+ expect(isShortcutEvent(press({ key: 'π', code: 'KeyP', metaKey: true }), PRINT, true)).toBe(true)
+ // 드보락의 P 자리는 'l' — 그 배열의 Ctrl+L 이지 인쇄가 아니다. 드보락의 'p'(R 자리)는 인쇄다
+ expect(isShortcutEvent(press({ key: 'l', code: 'KeyP', ctrlKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'p', code: 'KeyR', ctrlKey: true }), PRINT, false)).toBe(true)
+ // 다른 자리 · 수정 글쇠 규칙은 그대로
+ expect(isShortcutEvent(press({ key: 'з', code: 'KeyO', ctrlKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'з', code: 'KeyP', ctrlKey: true, shiftKey: true }), PRINT, false)).toBe(false)
+ expect(isShortcutEvent(press({ key: 'з', code: 'KeyP' }), PRINT, false)).toBe(false)
+ })
+})
diff --git a/frontend/src/shared/utils/keyboard-shortcut.ts b/frontend/src/shared/utils/keyboard-shortcut.ts
index d4a5206..f44e50c 100644
--- a/frontend/src/shared/utils/keyboard-shortcut.ts
+++ b/frontend/src/shared/utils/keyboard-shortcut.ts
@@ -46,6 +46,27 @@ export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText
return { label: [...modifiers, ...rest].join(''), aria }
}
+/** 라틴 글자 하나 — 글자 단축키의 키 이름 · 라틴 배열에서 누른 글자 */
+const LATIN_LETTER = /^[a-z]$/i
+
+/**
+ * 누른 키가 그 단축키인가 — 'Mod' 는 맥에서 ⌘(metaKey), 그 밖에서 Ctrl. 적지 않은 수정 글쇠(Shift · Alt)를 함께 눌렀으면 아니다.
+ * 마지막 글쇠는 대소문자를 가리지 않는다 (예: ['Mod', 'P'] — 읽기 화면의 인쇄).
+ * 라틴 글자가 아닌 배열(러시아어 · 그리스어 등 — Ctrl+P 가 'з' · 'π' 로 온다)이면 자판의 자리(event.code 'KeyP')로 본다 — 브라우저의 인쇄 단축키와 같다.
+ * 라틴 배열(드보락 등)은 글자 그대로 본다 — 자리로 보면 그 배열의 Ctrl+L 이 인쇄가 된다
+ */
+export function isShortcutEvent(event: KeyboardEvent, keys: ShortcutKeys, apple: boolean): boolean {
+ const wantsMod = keys.includes('Mod')
+ const modPressed = apple ? event.metaKey : event.ctrlKey
+ const otherMod = apple ? event.ctrlKey : event.metaKey
+ if (wantsMod !== modPressed || otherMod) return false
+ if (keys.includes('Shift') !== event.shiftKey || keys.includes('Alt') !== event.altKey) return false
+ const key = keys.find((name) => name !== 'Mod' && name !== 'Shift' && name !== 'Alt')
+ if (key === undefined) return false
+ if (event.key.toLowerCase() === key.toLowerCase()) return true
+ return LATIN_LETTER.test(key) && !LATIN_LETTER.test(event.key) && event.code === `Key${key.toUpperCase()}`
+}
+
/** 입력기가 처리 중인 키의 keyCode — Safari · macOS 한글 입력은 조합을 끝내는 Enter 를 isComposing 없이 이 값으로 보낸다 */
const IME_PROCESS_KEY_CODE = 229
diff --git a/frontend/src/shared/utils/mail-block.spec.ts b/frontend/src/shared/utils/mail-block.spec.ts
new file mode 100644
index 0000000..829be39
--- /dev/null
+++ b/frontend/src/shared/utils/mail-block.spec.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest'
+
+import { MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
+import type { BlockedSender } from '@/shared/types/mail'
+import {
+ blockOfSender,
+ domainBlockOf,
+ emailDomainOf,
+ isInternalDomain,
+ isPublicMailDomain,
+ isUnblockableSender,
+ mailBlockInputOf,
+} from './mail-block'
+
+// 보낸 사람 차단 (계약 2절 "보낸 사람 차단") — 차단 값 · 검사 · 찾기
+const OWN = 'ara.kim@arasystem.it.kr'
+const blocked = (address: string): BlockedSender => ({ address, blockedAt: '2026-10-29T01:00:00.000Z' })
+
+describe('여러 사람이 쓰는 메일 서비스 — 서버와 같은 목록 (짝)', () => {
+ it('목록은 서버 상수와 같은 30개, 이 차례 그대로', () => {
+ expect(PUBLIC_MAIL_DOMAINS).toEqual([
+ 'gmail.com',
+ 'googlemail.com',
+ 'naver.com',
+ 'daum.net',
+ 'hanmail.net',
+ 'kakao.com',
+ 'nate.com',
+ 'empas.com',
+ 'hanmir.com',
+ 'korea.com',
+ 'dreamwiz.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'yahoo.com',
+ 'yahoo.co.kr',
+ 'icloud.com',
+ 'me.com',
+ 'mac.com',
+ 'aol.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'gmx.com',
+ 'gmx.net',
+ 'mail.com',
+ 'zoho.com',
+ 'yandex.com',
+ 'qq.com',
+ '163.com',
+ ])
+ })
+
+ it('대소문자 · 앞뒤 공백을 가리지 않는다 — 하위 도메인은 따로다', () => {
+ expect(isPublicMailDomain('Naver.COM ')).toBe(true)
+ expect(isPublicMailDomain('mail.naver.com')).toBe(false)
+ expect(isPublicMailDomain('cloudbrief.example')).toBe(false)
+ })
+})
+
+describe('사내 · 내 주소 — 막을 수 없다 (서버의 MAIL_DOMAIN 과 그 하위)', () => {
+ it('내 주소의 도메인과 그 하위는 사내다', () => {
+ expect(isInternalDomain('arasystem.it.kr', OWN)).toBe(true)
+ expect(isInternalDomain('dev.arasystem.it.kr', OWN)).toBe(true)
+ expect(isInternalDomain('notarasystem.it.kr', OWN)).toBe(false)
+ })
+
+ it('내 주소 · 사내 직원은 차단 항목이 없다', () => {
+ expect(isUnblockableSender('ARA.KIM@arasystem.it.kr', OWN)).toBe(true)
+ expect(isUnblockableSender('doyun.lee@arasystem.it.kr', OWN)).toBe(true)
+ expect(isUnblockableSender('news@cloudbrief.example', OWN)).toBe(false)
+ })
+})
+
+describe('blockOfSender — 주소가 같은 것 먼저, 그다음 그 도메인', () => {
+ const blocks = [blocked('@promo-mail.example'), blocked('news@cloudbrief.example')]
+
+ it('대소문자를 가리지 않고 주소 · 도메인을 찾는다', () => {
+ expect(blockOfSender(blocks, 'News@CloudBrief.example')?.address).toBe('news@cloudbrief.example')
+ expect(blockOfSender(blocks, 'sale@promo-mail.example')?.address).toBe('@promo-mail.example')
+ expect(blockOfSender([...blocks, blocked('@cloudbrief.example')], 'news@cloudbrief.example')?.address).toBe(
+ 'news@cloudbrief.example',
+ )
+ })
+
+ it('하위 도메인 · 다른 주소 · 빈 주소는 막지 않는다', () => {
+ expect(blockOfSender(blocks, 'x@mail.promo-mail.example')).toBeNull()
+ expect(blockOfSender(blocks, 'other@cloudbrief.example')).toBeNull()
+ expect(blockOfSender(blocks, '')).toBeNull()
+ })
+
+ it('도메인 값 · 도메인 고르기', () => {
+ expect(emailDomainOf('News@CloudBrief.example')).toBe('cloudbrief.example')
+ expect(domainBlockOf('CloudBrief.example')).toBe('@cloudbrief.example')
+ })
+})
+
+describe('mailBlockInputOf — 설정 › 차단한 주소의 칸 (서버보다 좁게)', () => {
+ it('주소 · @도메인을 소문자로 맞춰 받는다', () => {
+ expect(mailBlockInputOf(' Kim@Spam.Example ', OWN, [])).toEqual({ address: 'kim@spam.example' })
+ expect(mailBlockInputOf('@Spam.Example', OWN, [])).toEqual({ address: '@spam.example' })
+ })
+
+ it.each([
+ ['@ 만', '@'],
+ ['@ 앞뒤가 빈 주소', 'kim@'],
+ ['점이 없는 도메인', '@localhost'],
+ ['IP 주소', '@192.168.0.1'],
+ ['@ 가 둘', 'a@b@c.example'],
+ ['도메인 안의 @', '@a@b.example'],
+ ['빈 글', ' '],
+ ])('형식이 틀리면 invalid — %s', (_label, input) => {
+ expect(mailBlockInputOf(input, OWN, [])).toEqual({ problem: 'invalid' })
+ })
+
+ it('내 주소 · 사내 도메인(하위 포함) · 사내 주소는 own', () => {
+ expect(mailBlockInputOf(OWN, OWN, [])).toEqual({ problem: 'own' })
+ expect(mailBlockInputOf('@arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
+ expect(mailBlockInputOf('@dev.arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
+ expect(mailBlockInputOf('doyun.lee@arasystem.it.kr', OWN, [])).toEqual({ problem: 'own' })
+ })
+
+ it('여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다 — 주소로는 된다', () => {
+ expect(mailBlockInputOf('@gmail.com', OWN, [])).toEqual({ problem: 'publicDomain' })
+ expect(mailBlockInputOf('spammer@gmail.com', OWN, [])).toEqual({ address: 'spammer@gmail.com' })
+ })
+
+ it('이미 차단한 값은 duplicate, 500개면 full', () => {
+ expect(mailBlockInputOf('News@cloudbrief.example', OWN, [blocked('news@cloudbrief.example')])).toEqual({
+ problem: 'duplicate',
+ })
+ const full = Array.from({ length: MAIL_BLOCK_MAX }, (_, index) => blocked(`s${index}@spam.example`))
+ expect(mailBlockInputOf('new@spam.example', OWN, full)).toEqual({ problem: 'full' })
+ })
+})
diff --git a/frontend/src/shared/utils/mail-block.ts b/frontend/src/shared/utils/mail-block.ts
new file mode 100644
index 0000000..6e9ac7c
--- /dev/null
+++ b/frontend/src/shared/utils/mail-block.ts
@@ -0,0 +1,82 @@
+// 보낸 사람 차단 — 차단할 값 · 검사 · 찾기 (계약 2절 "보낸 사람 차단" — 순수 함수만 둔다)
+// 차단 값은 소문자로 맞춘 주소("kim@spam.example") 또는 도메인("@spam.example" — 그 도메인에서 오는 메일 모두, 하위 도메인은 따로).
+// 내 주소와 사내 도메인(내 주소의 도메인과 그 하위 — 서버의 MAIL_DOMAIN)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다.
+// 검사는 서버보다 좁게 본다(isStrictEmail) — 화면이 통과시킨 값은 서버도 받는다.
+
+import { MAIL_BLOCK_DOMAIN_PREFIX, MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
+import type { BlockedSender } from '@/shared/types/mail'
+import { isStrictEmail, normalizeEmail } from '@/shared/utils/mail-address'
+
+/** 칸에 쓴 값의 문제 — 형식 · 이미 차단함 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 500개 */
+export type MailBlockInputProblem = 'invalid' | 'duplicate' | 'own' | 'publicDomain' | 'full'
+
+/** 주소의 도메인(소문자) — @ 가 없으면 빈 글 */
+export function emailDomainOf(email: string): string {
+ const at = email.lastIndexOf('@')
+ return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase()
+}
+
+/** 도메인 차단 값인가 ("@spam.example") */
+export function isDomainBlock(address: string): boolean {
+ return address.startsWith(MAIL_BLOCK_DOMAIN_PREFIX)
+}
+
+/** 도메인 차단 값 — "@" + 도메인 */
+export function domainBlockOf(domain: string): string {
+ return `${MAIL_BLOCK_DOMAIN_PREFIX}${domain.trim().toLowerCase()}`
+}
+
+/** 여러 사람이 쓰는 메일 서비스인가 (gmail.com · naver.com 등 — 서버와 같은 목록) */
+export function isPublicMailDomain(domain: string): boolean {
+ return PUBLIC_MAIL_DOMAINS.includes(domain.trim().toLowerCase())
+}
+
+/** 사내 도메인인가 — 내 주소의 도메인 또는 그 하위 (서버의 MAIL_DOMAIN 과 그 하위) */
+export function isInternalDomain(domain: string, ownEmail: string): boolean {
+ const own = emailDomainOf(ownEmail)
+ const target = domain.trim().toLowerCase()
+ return own !== '' && (target === own || target.endsWith(`.${own}`))
+}
+
+/** 차단할 수 없는 보낸 사람인가 — 내 주소 · 사내 직원(사내 도메인). 읽기 화면 ⋯ 에 차단 항목을 두지 않는다 */
+export function isUnblockableSender(email: string, ownEmail: string): boolean {
+ if (normalizeEmail(email) === normalizeEmail(ownEmail)) return true
+ return isInternalDomain(emailDomainOf(email), ownEmail)
+}
+
+/**
+ * 그 보낸 사람을 막고 있는 차단 — 주소가 같은 것 먼저, 없으면 그 도메인(하위 도메인은 따로 — 서버와 같다). 없으면 null.
+ * 대소문자는 가리지 않는다
+ */
+export function blockOfSender(blocks: readonly BlockedSender[], email: string): BlockedSender | null {
+ const address = normalizeEmail(email)
+ if (!address) return null
+ const byAddress = blocks.find((block) => block.address === address)
+ if (byAddress) return byAddress
+ const domain = emailDomainOf(address)
+ if (!domain) return null
+ return blocks.find((block) => block.address === domainBlockOf(domain)) ?? null
+}
+
+/**
+ * 설정 › 차단한 주소의 칸 — 앞뒤 공백을 빼고 소문자로 맞춘 차단 값, 또는 그 값의 문제.
+ * 차례: 형식 → 내 주소 · 사내 도메인 → 여러 사람이 쓰는 메일 서비스의 도메인 → 이미 차단함 → 500개
+ */
+export function mailBlockInputOf(
+ input: string,
+ ownEmail: string,
+ blocks: readonly BlockedSender[],
+): { address: string } | { problem: MailBlockInputProblem } {
+ const value = input.trim().toLowerCase()
+ const domainOnly = isDomainBlock(value)
+ const domain = domainOnly ? value.slice(MAIL_BLOCK_DOMAIN_PREFIX.length) : emailDomainOf(value)
+ // 도메인은 주소의 @ 뒤와 같은 규칙으로 본다(isStrictEmail 에 "a@" 를 붙여 — 서버의 253자보다 조금 좁다) —
+ // 점이 하나 이상 · 칸마다 앞뒤 하이픈 없음 · 마지막 칸은 글자 2개 이상(IP 주소가 아니다)
+ const wellFormed = domainOnly ? domain !== '' && !domain.includes('@') && isStrictEmail(`a@${domain}`) : isStrictEmail(value)
+ if (!wellFormed) return { problem: 'invalid' }
+ if (normalizeEmail(value) === normalizeEmail(ownEmail) || isInternalDomain(domain, ownEmail)) return { problem: 'own' }
+ if (domainOnly && isPublicMailDomain(domain)) return { problem: 'publicDomain' }
+ if (blocks.some((block) => block.address === value)) return { problem: 'duplicate' }
+ if (blocks.length >= MAIL_BLOCK_MAX) return { problem: 'full' }
+ return { address: value }
+}
diff --git a/frontend/src/shared/utils/mail-html.spec.ts b/frontend/src/shared/utils/mail-html.spec.ts
index b38635e..7e0506d 100644
--- a/frontend/src/shared/utils/mail-html.spec.ts
+++ b/frontend/src/shared/utils/mail-html.spec.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
-import { MAIL_FRAME_CSP, buildMailDocument, displayedHtmlBody, escapeHtml, sanitizeMailHtml } from './mail-html'
+import {
+ MAIL_FRAME_CSP,
+ buildMailDocument,
+ displayedHtmlBody,
+ escapeHtml,
+ mailFrameCspImagesShown,
+ sanitizeMailHtml,
+} from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -145,3 +152,83 @@ describe('escapeHtml', () => {
expect(escapeHtml(`'&'`)).toBe('<a href="x">'&'</a>')
})
})
+
+describe('외부 이미지 보기 — 중계 주소의 그림만 보일 때 남긴다 (9단계 · 계약 2절 "외부 이미지 보기")', () => {
+ /** 서버의 표 모양 — base64url (backend `mail-image-token.ts`) */
+ const PROXIED = '/api/mail-images/AQx9-Zb_k3Lm0Qr7TuVw'
+ const srcsOf = (html: string) =>
+ Array.from(new DOMParser().parseFromString(html, 'text/html').querySelectorAll('img')).map((img) =>
+ img.getAttribute('src'),
+ )
+
+ it('보이게 했으면(imagesShown) 중계 주소의 그림을 남긴다 — data: 그림도 그대로', () => {
+ const html = sanitizeMailHtml(` `, { imagesShown: true })
+
+ expect(srcsOf(html)).toEqual([PROXIED, 'data:image/png;base64,AAAA'])
+ })
+
+ it('막아 둔 본문(기본)에서는 중계 주소의 그림도 지운다', () => {
+ expect(srcsOf(sanitizeMailHtml(` `))).toEqual([null])
+ })
+
+ it('보이게 했어도 그 밖의 주소는 지운다 — 바깥 주소 · 다른 API · 경로 넘기 · 쿼리 · 인코딩한 점 · base64url 밖의 글자', () => {
+ const html = sanitizeMailHtml(
+ [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ].join(''),
+ { imagesShown: true },
+ )
+
+ expect(srcsOf(html).every((src) => src === null)).toBe(true)
+ })
+
+ it('보이게 했으면 문서의 CSP 그림만 우리 출처의 중계 경로를 더 연다 — 같은 출처 전체(self)가 아니다 · 나머지는 그대로', () => {
+ const shown = buildMailDocument({
+ htmlBody: ` `,
+ textBody: null,
+ imagesShown: true,
+ origin: 'https://portal.arasystem.it.kr',
+ })
+ const blocked = buildMailDocument({ htmlBody: ` `, textBody: null })
+
+ const csp = "default-src 'none'; img-src data: https://portal.arasystem.it.kr/api/mail-images/; style-src 'unsafe-inline'"
+ expect(mailFrameCspImagesShown('https://portal.arasystem.it.kr')).toBe(csp)
+ expect(shown).toContain(`content="${csp}"`)
+ expect(shown).not.toContain("'self'")
+ expect(shown).toContain(`src="${PROXIED}"`)
+ expect(blocked).toContain(`content="${MAIL_FRAME_CSP}"`)
+ expect(blocked).not.toContain(PROXIED)
+ })
+
+ it('CSP 의 출처는 주지 않으면 이 화면의 출처(location.origin) — 개발 서버의 포트도 그대로', () => {
+ const shown = buildMailDocument({ htmlBody: ` `, textBody: null, imagesShown: true })
+
+ expect(shown).toContain(`img-src data: ${window.location.origin}/api/mail-images/;`)
+ expect(mailFrameCspImagesShown('http://localhost:5173')).toBe(
+ "default-src 'none'; img-src data: http://localhost:5173/api/mail-images/; style-src 'unsafe-inline'",
+ )
+ })
+
+ it('CSP 에 적을 수 없는 출처(IPv6 주소 · 빈 글 · 글자를 섞은 값)면 같은 출처(self)로 연다 — CSP 문법을 깨거나 다른 지시를 끼워 넣지 못한다', () => {
+ const fallback = "default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'"
+
+ expect(mailFrameCspImagesShown('http://[::1]:5173')).toBe(fallback)
+ expect(mailFrameCspImagesShown('')).toBe(fallback)
+ expect(mailFrameCspImagesShown('null')).toBe(fallback)
+ expect(mailFrameCspImagesShown("https://a.example; img-src *")).toBe(fallback)
+ expect(mailFrameCspImagesShown('https://a.example/path')).toBe(fallback)
+ expect(mailFrameCspImagesShown('javascript:alert(1)')).toBe(fallback)
+ })
+})
diff --git a/frontend/src/shared/utils/mail-html.ts b/frontend/src/shared/utils/mail-html.ts
index 5e71e4e..fc8549c 100644
--- a/frontend/src/shared/utils/mail-html.ts
+++ b/frontend/src/shared/utils/mail-html.ts
@@ -9,6 +9,8 @@
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
+import { MAIL_IMAGE_PROXY_PATH } from '@/shared/constants/mail'
+
/**
* iframe 문서 안에만 적용하는 CSP.
* 스크립트·글꼴·네트워크 요청을 전부 막고 data: 이미지(본문 안 인라인 이미지)와 인라인 스타일만 허용한다.
@@ -16,11 +18,33 @@ import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
*/
export const MAIL_FRAME_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'"
+/** CSP host-source 로 적을 수 있는 출처 — http(s)://호스트(글자 · 숫자 · - · .)[:포트] */
+const CSP_HOST_ORIGIN = /^https?:\/\/[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?::\d{1,5})?$/
+
+/**
+ * 외부 이미지를 보일 때(서버가 중계 주소로 바꿔 넣은 본문 — imagesShown, 9단계)의 CSP — 그림만 우리 서버의 **중계 경로**(/api/mail-images/)를 더 연다.
+ * srcdoc 문서는 부모(우리 앱)의 출처를 물려받는다 (sandbox 의 allow-same-origin — MailBodyFrame). 바깥 주소는 여전히 막힌다 — 그림은 서버가 대신 받아 준다
+ * (계약 2절 "외부 이미지 보기"). 'self' 로 열면 서버 정화를 빠져나온 CSS url() 이 같은 출처의 아무 API(/api/mail-events 등)나 부를 수 있다 —
+ * 셋째 겹(CSP)이 그것도 막게 경로까지 좁힌다 (2026-10-29 리뷰). 경로가 / 로 끝나 그 아래 주소만 맞는다(CSP host-source 의 경로 규칙).
+ * origin 이 CSP 에 적을 수 없는 모양이면(IPv6 주소 등 — host-source 는 [ ] 를 받지 않는다) 'self' 로 연다 — 정화 두 겹은 그대로다
+ */
+export function mailFrameCspImagesShown(origin: string): string {
+ const source = CSP_HOST_ORIGIN.test(origin) ? `${origin}${MAIL_IMAGE_PROXY_PATH}` : "'self'"
+ return `default-src 'none'; img-src data: ${source}; style-src 'unsafe-inline'`
+}
+
/** 링크로 남기는 주소 — 계약과 같다 (http · https · mailto). 상대 경로·javascript: 등은 지운다 */
const SAFE_LINK_PATTERN = /^(?:https?|mailto):/i
/** 이미지는 서버가 넣어 준 data: 이미지만 남긴다 (외부 이미지는 추적 픽셀이 될 수 있다) */
const DATA_IMAGE_PATTERN = /^data:image\//i
+/**
+ * 외부 이미지를 보일 때만 더 남기는 그림 — 서버의 중계 주소(/api/mail-images/<표>) 그대로.
+ * 표는 서버가 암호화한 불투명 글(base64url — 채움 `=` 없음)이다 — 한 칸(점 · 빗금 · 쿼리 · 조각 · % 없음)이라
+ * ../ · %2e%2e 로 다른 API 를 부를 수 없다. 짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` (표 모양을 바꾸면 둘 다)
+ */
+const PROXIED_IMAGE_PATTERN = new RegExp(`^${MAIL_IMAGE_PROXY_PATH.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}[A-Za-z0-9_-]+$`)
+
/** 서버 제거 목록(스크립트·스타일·폼·iframe·object·embed·svg)에 입력·미디어 요소를 더했다 */
const FORBIDDEN_TAGS = [
'style',
@@ -65,8 +89,14 @@ const SANITIZE_CONFIG: Config = {
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.:-]|$))/i,
}
-function createPurifier(): Purifier {
- // 이 모듈 전용 인스턴스 — 아래 훅이 다른 곳의 DOMPurify 사용에 번지지 않게 한다
+/** 남길 그림 주소인가 — 늘 data: 그림, 외부 이미지를 보일 때만 서버의 중계 주소도 */
+function isAllowedImageSource(src: string, imagesShown: boolean): boolean {
+ if (DATA_IMAGE_PATTERN.test(src)) return true
+ return imagesShown && PROXIED_IMAGE_PATTERN.test(src)
+}
+
+function createPurifier(imagesShown: boolean): Purifier {
+ // 이 모듈 전용 인스턴스 — 아래 훅이 다른 곳의 DOMPurify 사용에 번지지 않게 한다 (그림을 보일 때 · 막을 때 따로 둔다)
const purifier = DOMPurify(window)
purifier.addHook('afterSanitizeAttributes', (node) => {
const tag = node.tagName
@@ -83,17 +113,29 @@ function createPurifier(): Purifier {
}
if (tag === 'IMG') {
const src = node.getAttribute('src')?.trim() ?? ''
- if (!DATA_IMAGE_PATTERN.test(src)) node.removeAttribute('src')
+ if (isAllowedImageSource(src, imagesShown)) node.setAttribute('src', src)
+ else node.removeAttribute('src')
}
})
return purifier
}
-let purifier: Purifier | null = null
+const purifiers: { blocked: Purifier | null; shown: Purifier | null } = { blocked: null, shown: null }
+
+export interface SanitizeMailOptions {
+ /**
+ * 서버가 외부 이미지를 중계 주소로 바꿔 넣은 본문이다(MessageDetail.imagesShown) — 그 주소(/api/mail-images/…)의 그림만 더 남긴다.
+ * 그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다
+ */
+ imagesShown?: boolean
+}
/** 메일 HTML 정화 — 결과는 안에 넣을 조각 */
-export function sanitizeMailHtml(html: string): string {
- purifier ??= createPurifier()
+export function sanitizeMailHtml(html: string, options: SanitizeMailOptions = {}): string {
+ const shown = options.imagesShown === true
+ const purifier = shown
+ ? (purifiers.shown ??= createPurifier(true))
+ : (purifiers.blocked ??= createPurifier(false))
return purifier.sanitize(html, SANITIZE_CONFIG)
}
@@ -128,6 +170,15 @@ const MAIL_FRAME_STYLE = [
export interface MailBodySource {
htmlBody: string | null
textBody: string | null
+ /** 외부 이미지를 보이는 본문(서버의 imagesShown) — 문서의 CSP 와 정화가 중계 주소의 그림을 남긴다 (9단계) */
+ imagesShown?: boolean
+ /** 중계 주소를 여는 CSP 의 출처 — 주지 않으면 이 화면의 출처(location.origin). 그림이 그 출처의 /api/mail-images/ 로 풀린다 */
+ origin?: string
+}
+
+/** 이 화면의 출처 — 브라우저가 아니면 빈 글 (CSP 는 'self' 로 연다 — mailFrameCspImagesShown) */
+function currentOrigin(): string {
+ return typeof window === 'undefined' ? '' : window.location.origin
}
/**
@@ -144,19 +195,22 @@ export function displayedHtmlBody(htmlBody: string | null | undefined): string |
* 본문 → iframe srcdoc 문서.
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
* 로 남은 링크도 모두 새 창으로 연다.
+ * 외부 이미지를 보이는 본문(imagesShown)이면 CSP 의 그림만 우리 서버의 중계 경로를 열고 중계 주소의 그림을 남긴다 — 그 밖은 늘 같다
*/
export function buildMailDocument(source: MailBodySource): string {
+ const imagesShown = source.imagesShown === true
const html = displayedHtmlBody(source.htmlBody)
const content =
html !== null
- ? sanitizeMailHtml(html.trim())
+ ? sanitizeMailHtml(html.trim(), { imagesShown })
: `${escapeHtml(source.textBody ?? '')} `
+ const csp = imagesShown ? mailFrameCspImagesShown(source.origin ?? currentOrigin()) : MAIL_FRAME_CSP
return [
'',
'',
'',
- ``,
+ ``,
'',
'',
``,
diff --git a/frontend/src/shared/utils/mail-list-row.spec.ts b/frontend/src/shared/utils/mail-list-row.spec.ts
index 74950b7..7580490 100644
--- a/frontend/src/shared/utils/mail-list-row.spec.ts
+++ b/frontend/src/shared/utils/mail-list-row.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
-import { isRowUnread, mailboxOfMessage, rowWho, whoColumnLabel } from './mail-list-row'
+import { isRowUnread, mailboxOfMessage, pendingSendLabel, rowWho, whoColumnLabel } from './mail-list-row'
const MAILBOXES: Mailbox[] = [
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 },
@@ -69,3 +69,18 @@ describe('mail-list-row (목록 행의 표시)', () => {
expect(mailboxOfMessage({ mailboxIds: ['unknown'] }, MAILBOXES)).toBeNull()
})
})
+
+describe('pendingSendLabel — 아직 보내지 않은 내 메일의 날짜 칸 (9단계)', () => {
+ it('예약은 "예약 · 10월 30일 오전 9:00", 보내기 취소를 기다리는 동안은 "보내는 중"', () => {
+ expect(pendingSendLabel({ pendingSend: { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' } }, 'Asia/Seoul')).toBe(
+ '예약 · 10월 30일 오전 9:00',
+ )
+ expect(pendingSendLabel({ pendingSend: { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' } })).toBe('보내는 중')
+ })
+
+ it('붙잡힌 발송이 아니면(null · 필드가 없는 옛 서버 · 모양이 틀림) null — 받은 시각을 보인다', () => {
+ expect(pendingSendLabel({ pendingSend: null })).toBeNull()
+ expect(pendingSendLabel({})).toBeNull()
+ expect(pendingSendLabel({ pendingSend: { kind: 'undo', sendAt: '곧' } })).toBeNull()
+ })
+})
diff --git a/frontend/src/shared/utils/mail-list-row.ts b/frontend/src/shared/utils/mail-list-row.ts
index a999a3b..736d361 100644
--- a/frontend/src/shared/utils/mail-list-row.ts
+++ b/frontend/src/shared/utils/mail-list-row.ts
@@ -2,14 +2,18 @@
// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않고, 누르면 쓰기 화면에서 이어 쓴다
// - 보낸편지함: 받는 사람 — 분할 보기는 "받는 사람: …", 표는 머리가 "받는 사람"이라 이름만
// - 전체 메일함 검색 결과는 행마다 메일함이 다르다 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" (디자인 README "3단계 › 검색")
+// - 아직 보내지 않은 내 메일(9단계 — pendingSend)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(보내기 취소를 기다리는 동안)
import {
DRAFT_NO_RECIPIENTS,
MAIL_SEARCH_TEXT,
OUTGOING_MAILBOX_ROLES,
} from '@/shared/constants/mail'
+import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
+import { formatScheduleShortTime } from '@/shared/utils/format'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
+import { messagePendingSend } from '@/shared/utils/pending-send'
const NO_RECIPIENTS = '받는 사람 없음'
const NO_SENDER = '(보낸 사람 없음)'
@@ -75,3 +79,15 @@ export function rowWho(message: Pick, context: Ro
export function whoColumnLabel(role: MailboxRole | null, mixed: boolean): string {
return !mixed && role && OUTGOING_MAILBOX_ROLES.includes(role) ? '받는 사람' : '보낸 사람'
}
+
+/**
+ * 아직 보내지 않은 내 메일의 날짜 칸 (디자인 README "9단계 › 보낸편지함") — 예약 "예약 · 10월 30일 오전 9:00",
+ * 보내기 취소를 기다리는 동안 "보내는 중". 붙잡힌 발송이 아니면 null (받은 시각을 보인다)
+ */
+export function pendingSendLabel(message: Pick, timeZone?: string): string | null {
+ const pending = messagePendingSend(message)
+ if (!pending) return null
+ return pending.kind === 'scheduled'
+ ? SEND_LATER_TEXT.rowScheduled(formatScheduleShortTime(pending.sendAt, timeZone))
+ : SEND_LATER_TEXT.rowSending
+}
diff --git a/frontend/src/shared/utils/mail-list-source.ts b/frontend/src/shared/utils/mail-list-source.ts
index 6e3e6d7..c56068e 100644
--- a/frontend/src/shared/utils/mail-list-source.ts
+++ b/frontend/src/shared/utils/mail-list-source.ts
@@ -1,5 +1,5 @@
-// 보이는 메일 목록의 출처 — 메일함 하나 또는 검색 결과 (순수 함수만 둔다)
-// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색").
+// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
+// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
@@ -19,32 +19,50 @@ export interface SearchListSource {
scope: string
}
-export type MailListSource = MailboxListSource | SearchListSource
+/** 예약한 메일 — GET /scheduled-messages (보낼 시각 차례). 고르기 · 여러 통 처리는 없다 (디자인 README "9단계") */
+export interface ScheduledListSource {
+ kind: 'scheduled'
+}
+
+/** GET /messages 로 받는 목록 — 메일함 · 검색 */
+export type MessageListSource = MailboxListSource | SearchListSource
+
+export type MailListSource = MessageListSource | ScheduledListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
export const MAIL_SEARCH_MESSAGE_ROUTE = 'mail-search-message'
+/** 예약됨 목록 · 예약됨에서 연 메일의 라우트 이름 — /mail/scheduled · /mail/scheduled/<메일 id> */
+export const MAIL_SCHEDULED_ROUTE = 'mail-scheduled'
+export const MAIL_SCHEDULED_MESSAGE_ROUTE = 'mail-scheduled-message'
+
+/** 예약됨 출처 — 하나뿐이라 늘 같은 값 */
+export const SCHEDULED_LIST_SOURCE: ScheduledListSource = { kind: 'scheduled' }
export function isSearchRouteName(name: unknown): boolean {
return name === MAIL_SEARCH_ROUTE || name === MAIL_SEARCH_MESSAGE_ROUTE
}
+export function isScheduledRouteName(name: unknown): boolean {
+ return name === MAIL_SCHEDULED_ROUTE || name === MAIL_SCHEDULED_MESSAGE_ROUTE
+}
+
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
if (!a || !b) return a === b
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
if (a.kind === 'search' && b.kind === 'search') return a.q === b.q && a.scope === b.scope
- return false
+ return a.kind === 'scheduled' && b.kind === 'scheduled'
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
-export function listQueryOf(source: MailListSource): Pick {
+export function listQueryOf(source: MessageListSource): Pick {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
}
-/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색이면 null */
+/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
- if (!source) return null
+ if (!source || source.kind === 'scheduled') return null
if (source.kind === 'mailbox') return source.mailboxId
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
}
@@ -54,8 +72,14 @@ export function isAllMailboxSearch(source: MailListSource | null): boolean {
return source?.kind === 'search' && source.scope === SEARCH_SCOPE_ALL
}
+/** 예약됨 목록인가 */
+export function isScheduledSource(source: MailListSource | null): boolean {
+ return source?.kind === 'scheduled'
+}
+
/** 목록마다 다른 키 — 표 위치 기억 등 */
export function listKeyOf(source: MailListSource): string {
+ if (source.kind === 'scheduled') return 'scheduled'
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
}
@@ -67,14 +91,16 @@ export function searchRouteQuery(source: SearchListSource): Record?q=…&in=… */
+/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=…, 예약됨은 /mail/scheduled/<메일 id> */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
if (source.kind === 'mailbox') {
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
}
+ if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_MESSAGE_ROUTE, params: { messageId } }
return { name: MAIL_SEARCH_MESSAGE_ROUTE, params: { messageId }, query: searchRouteQuery(source) }
}
diff --git a/frontend/src/shared/utils/mail-quote.spec.ts b/frontend/src/shared/utils/mail-quote.spec.ts
index 2bf6e11..11b2d80 100644
--- a/frontend/src/shared/utils/mail-quote.spec.ts
+++ b/frontend/src/shared/utils/mail-quote.spec.ts
@@ -197,3 +197,19 @@ describe('buildQuoteHtml', () => {
)
})
})
+
+describe('originalBodyHtml — 외부 이미지를 보인 원문 (9단계 "외부 이미지 보기")', () => {
+ it('답장 · 전달 인용에는 중계 주소(/api/mail-images/…)의 그림도 실리지 않는다 — 설정으로 처음부터 보인 본문이어도', () => {
+ const html = originalBodyHtml({
+ htmlBody: '이번 주 소식  끝 ',
+ textBody: '이번 주 소식',
+ })
+
+ expect(html).toBe('이번 주 소식 끝 ')
+ expect(html).not.toContain('mail-images')
+ })
+
+ it('그림뿐이던 원문이면 글자 본문을 인용한다 (지금과 같다)', () => {
+ expect(originalBodyHtml({ htmlBody: ' ', textBody: '글자 본문' })).toBe('글자 본문 ')
+ })
+})
diff --git a/frontend/src/shared/utils/mail-send-result.spec.ts b/frontend/src/shared/utils/mail-send-result.spec.ts
index 51daf14..bd1ac5a 100644
--- a/frontend/src/shared/utils/mail-send-result.spec.ts
+++ b/frontend/src/shared/utils/mail-send-result.spec.ts
@@ -47,3 +47,25 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr')
})
})
+
+describe('partialSendMessage — 엔진이 붙잡아 둔 발송 (9단계 "보내기 취소 · 예약 발송")', () => {
+ const NOBODY: RejectedRecipient = { email: 'nobody@arasystem.it.kr', reason: 'not-found' }
+
+ it('보내기 취소를 기다리는 중이면 아직 나가지 않았다 — "받지 못합니다" (디자인 원본 MailSendLater)', () => {
+ expect(partialSendMessage([NOBODY], { pendingKind: 'undo' })).toBe(
+ '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
+ )
+ })
+
+ it('예약이면 "예약했지만 … 받지 못합니다"', () => {
+ expect(partialSendMessage([NOBODY], { pendingKind: 'scheduled' })).toBe(
+ '예약했지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
+ )
+ })
+
+ it('바로 보냈으면(null) 지금처럼 "받지 못했습니다"', () => {
+ expect(partialSendMessage([NOBODY], { pendingKind: null })).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
index fb62bb1..1510ba1 100644
--- a/frontend/src/shared/utils/mail-send-result.ts
+++ b/frontend/src/shared/utils/mail-send-result.ts
@@ -7,7 +7,7 @@ import {
MAIL_PARTIAL_SEND_TEXT,
MAIL_REJECTED_REASON_TEXT,
} from '@/shared/constants/mail'
-import type { RejectedRecipient } from '@/shared/types/mail'
+import type { PendingSendKind, RejectedRecipient } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
/** 한 사람 — "주소 (까닭)". 계약에 없는 까닭(나중에 생긴 값 등)이면 주소만 적는다 */
@@ -20,8 +20,13 @@ function recipientText(recipient: RejectedRecipient): string {
* 받는 사람 일부가 받지 못한 채 보냈을 때의 알림 문장 — 받지 못한 사람이 없으면 null (보냈다는 알림을 띄운다).
* 두 명까지 "주소 (까닭)"를 ", " 로 잇고, 더 있으면 " 외 N명":
* "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
+ * pendingKind — 엔진이 붙잡아 둔 발송(9단계)이면 아직 나가지 않았다: 보내기 취소를 기다리는 중 "보냈지만 … 받지 못합니다",
+ * 예약 "예약했지만 … 받지 못합니다"
*/
-export function partialSendMessage(rejected: readonly RejectedRecipient[]): string | null {
+export function partialSendMessage(
+ rejected: readonly RejectedRecipient[],
+ options: { pendingKind?: PendingSendKind | null } = {},
+): string | null {
if (rejected.length === 0) return null
const listed = rejected
.slice(0, MAIL_PARTIAL_SEND_LISTED)
@@ -29,5 +34,11 @@ export function partialSendMessage(rejected: readonly RejectedRecipient[]): stri
.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}`)
+ const message =
+ options.pendingKind === 'scheduled'
+ ? MAIL_PARTIAL_SEND_TEXT.scheduledMessage
+ : options.pendingKind === 'undo'
+ ? MAIL_PARTIAL_SEND_TEXT.pendingMessage
+ : MAIL_PARTIAL_SEND_TEXT.message
+ return message(formatCount(rejected.length), `${listed}${others}`)
}
diff --git a/frontend/src/shared/utils/pending-send.spec.ts b/frontend/src/shared/utils/pending-send.spec.ts
new file mode 100644
index 0000000..f4a73a6
--- /dev/null
+++ b/frontend/src/shared/utils/pending-send.spec.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ isHeldMessage,
+ isScheduledMessage,
+ messagePendingSend,
+ nextPendingCheckAt,
+ overduePendingWatches,
+ pendingWatchesOf,
+ samePendingSend,
+ undoToastDeadline,
+} from './pending-send'
+
+// 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수
+
+const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
+const at = (ms: number) => new Date(NOW + ms).toISOString()
+const TIMING = { graceMs: 2_000, recheckMs: 5_000 }
+
+describe('붙잡은 발송 읽기', () => {
+ it('모양이 맞을 때만 붙잡은 발송이다 — 없거나 틀린 값(9단계 앞 서버 · 게이트웨이)은 없는 것', () => {
+ expect(messagePendingSend({ pendingSend: { kind: 'undo', sendAt: at(10_000) } })).toEqual({ kind: 'undo', sendAt: at(10_000) })
+ expect(messagePendingSend({ pendingSend: null })).toBeNull()
+ expect(messagePendingSend({})).toBeNull()
+ expect(messagePendingSend({ pendingSend: { kind: 'later', sendAt: at(0) } as never })).toBeNull()
+ expect(messagePendingSend({ pendingSend: { kind: 'undo', sendAt: '언젠가' } })).toBeNull()
+ })
+
+ it('아직 보내지 않은 메일은 두 가지 다 — 예약은 그 가운데 예약뿐', () => {
+ const undo = { pendingSend: { kind: 'undo' as const, sendAt: at(10_000) } }
+ const scheduled = { pendingSend: { kind: 'scheduled' as const, sendAt: at(60_000) } }
+
+ expect([isHeldMessage(undo), isHeldMessage(scheduled), isHeldMessage({ pendingSend: null })]).toEqual([true, true, false])
+ expect([isScheduledMessage(undo), isScheduledMessage(scheduled)]).toEqual([false, true])
+ })
+
+ it('같은 붙잡은 발송인가 — 종류 · 시각이 같아야 같다 (둘 다 없음 포함)', () => {
+ const undo = { kind: 'undo' as const, sendAt: at(10_000) }
+ expect(samePendingSend(undo, { ...undo })).toBe(true)
+ expect(samePendingSend(undo, { ...undo, sendAt: at(11_000) })).toBe(false)
+ expect(samePendingSend(undo, { ...undo, kind: 'scheduled' })).toBe(false)
+ expect(samePendingSend(undo, null)).toBe(false)
+ expect(samePendingSend(null, null)).toBe(true)
+ })
+})
+
+describe('엔진이 풀 때 다시 보기 (리뷰 #2)', () => {
+ it('지켜볼 것 — 붙잡은 발송이 있는 메일만, 같은 메일 · 같은 시각은 하나 (목록의 줄 · 열린 메일 · 가장 이른 예약이 겹친다)', () => {
+ const held = { id: 's1', pendingSend: { kind: 'scheduled' as const, sendAt: at(60_000) } }
+ const watches = pendingWatchesOf([held, { id: 'p1', pendingSend: null }, null, undefined, { ...held }])
+
+ expect(watches).toEqual([{ key: `s1\n${at(60_000)}`, at: NOW + 60_000 }])
+ })
+
+ it('다음에 볼 때 — 아직 오지 않은 것은 그 시각 + 2초, 지난 것은 지금 + 5초 가운데 이른 때 · 없으면 null', () => {
+ const soon = { key: 'a', at: NOW + 10_000 }
+ const later = { key: 'b', at: NOW + 60_000 }
+ const past = { key: 'c', at: NOW - 30_000 }
+
+ expect(nextPendingCheckAt([later, soon], NOW, TIMING)).toBe(NOW + 12_000)
+ expect(nextPendingCheckAt([later, past], NOW, TIMING)).toBe(NOW + 5_000)
+ expect(nextPendingCheckAt([], NOW, TIMING)).toBeNull()
+ })
+
+ it('다시 본 뒤에도 시각(+ 2초)이 지난 채 남은 것', () => {
+ const watches = [
+ { key: 'a', at: NOW - 3_000 },
+ { key: 'b', at: NOW - 1_000 },
+ { key: 'c', at: NOW + 1_000 },
+ ]
+
+ expect(overduePendingWatches(watches, NOW, TIMING.graceMs).map((watch) => watch.key)).toEqual(['a'])
+ })
+})
+
+describe('보낸 뒤 알림의 [보내기 취소] 마감 (리뷰 #11)', () => {
+ const base = { now: NOW, marginMs: 1_000, maxMs: 30_000 }
+
+ it('설정한 초를 알면 지금 + 초 − 1초 — 이 PC 시계가 늦어 sendAt 이 멀리 보여도 늘어나지 않는다', () => {
+ expect(undoToastDeadline({ ...base, sendAt: at(600_000), undoSendSeconds: 5 })).toBe(NOW + 4_000)
+ // 이 PC 시계가 빨라 sendAt 이 이미 지나 보여도 설정한 만큼은 보인다
+ expect(undoToastDeadline({ ...base, sendAt: at(-3_000), undoSendSeconds: 10 })).toBe(NOW + 9_000)
+ })
+
+ it('설정을 모르거나 0 인데 서버가 붙잡았으면(다른 PC 에서 바꿈) sendAt 까지 — 다만 30초를 넘지 않는다', () => {
+ expect(undoToastDeadline({ ...base, sendAt: at(10_000), undoSendSeconds: null })).toBe(NOW + 10_000)
+ expect(undoToastDeadline({ ...base, sendAt: at(600_000), undoSendSeconds: null })).toBe(NOW + 30_000)
+ expect(undoToastDeadline({ ...base, sendAt: at(20_000), undoSendSeconds: 0 })).toBe(NOW + 20_000)
+ })
+})
diff --git a/frontend/src/shared/utils/pending-send.ts b/frontend/src/shared/utils/pending-send.ts
new file mode 100644
index 0000000..39f3de2
--- /dev/null
+++ b/frontend/src/shared/utils/pending-send.ts
@@ -0,0 +1,128 @@
+// 엔진이 붙잡아 둔 내 발송 — 보내기 취소를 기다리는 중 · 예약 (계약 2절 "보내기 취소 · 예약 발송" — 순수 함수만 둔다)
+// 서버 응답의 pendingSend 는 이 함수들로만 읽는다 — 필드가 없거나(9단계 앞 서버) 모양이 틀리면(응답을 바꾼 게이트웨이 등) 없는 것으로 본다.
+
+import type { MessageSummary, PendingSend } from '@/shared/types/mail'
+
+const PENDING_KINDS: readonly string[] = ['undo', 'scheduled']
+
+/** 계약의 모양인가 — kind 가 undo · scheduled 이고 sendAt 이 시각으로 읽힌다 */
+export function isPendingSend(value: unknown): value is PendingSend {
+ if (typeof value !== 'object' || value === null) return false
+ const candidate = value as { sendAt?: unknown; kind?: unknown }
+ return (
+ typeof candidate.kind === 'string' &&
+ PENDING_KINDS.includes(candidate.kind) &&
+ typeof candidate.sendAt === 'string' &&
+ !Number.isNaN(Date.parse(candidate.sendAt))
+ )
+}
+
+/** 응답의 값 → PendingSend 또는 null */
+export function pendingSendOf(value: unknown): PendingSend | null {
+ return isPendingSend(value) ? value : null
+}
+
+/** 그 메일의 붙잡아 둔 발송 — 없으면 null */
+export function messagePendingSend(message: Pick | null | undefined): PendingSend | null {
+ return pendingSendOf(message?.pendingSend)
+}
+
+/**
+ * 아직 보내지 않은 메일인가(어느 쪽이든 — 보내기 취소를 기다리는 중 · 예약). 이 메일은 취소해야만 보낸편지함을 떠날 수 있다 —
+ * 옮기거나 지워도 엔진은 그 시각에 보낸다(2026-10-29 리뷰). 그래서 목록에서 고를 수 없고, 읽기 화면에 옮기기 · 스팸 신고가 없으며
+ * [삭제]는 확인 뒤 취소하고 휴지통으로 옮긴다
+ */
+export function isHeldMessage(message: Pick | null | undefined): boolean {
+ return messagePendingSend(message) !== null
+}
+
+/** 예약한 메일인가 — 읽기 화면의 예약 안내 줄 · 답장 · 전달을 빼는 기준 */
+export function isScheduledMessage(message: Pick | null | undefined): boolean {
+ return messagePendingSend(message)?.kind === 'scheduled'
+}
+
+/** 두 붙잡은 발송이 같은가 (둘 다 없음 포함) */
+export function samePendingSend(a: PendingSend | null, b: PendingSend | null): boolean {
+ if (a === null || b === null) return a === b
+ return a.kind === b.kind && a.sendAt === b.sendAt
+}
+
+// ── 엔진이 풀 때 다시 보기 (usePendingSendRefresh) ─────────────────────────────
+
+/** 지켜보는 붙잡은 발송 하나 — 같은 메일 · 같은 시각은 하나로 본다(목록의 줄 · 열린 메일 · 가장 이른 예약이 같은 메일일 수 있다) */
+export interface PendingWatch {
+ key: string
+ /** 보낼 시각 (ms) */
+ at: number
+}
+
+/** 메일들의 붙잡은 발송 → 지켜볼 것 (없거나 모양이 틀린 것은 뺀다) */
+export function pendingWatchesOf(
+ messages: readonly (Pick | null | undefined)[],
+): PendingWatch[] {
+ const watches = new Map()
+ for (const message of messages) {
+ const pending = messagePendingSend(message)
+ if (!message || !pending) continue
+ const key = `${message.id}\n${pending.sendAt}`
+ if (!watches.has(key)) watches.set(key, { key, at: Date.parse(pending.sendAt) })
+ }
+ return [...watches.values()]
+}
+
+export interface PendingCheckTiming {
+ /** 보낼 시각 뒤 이만큼 기다렸다 본다 — 엔진이 풀고 목록에 반영할 틈 */
+ graceMs: number
+ /** 시각이 지났는데 아직 붙잡힌 것으로 보이면(서버가 늦음 · 이 PC 시계가 빠름) 이 간격으로 다시 본다 */
+ recheckMs: number
+}
+
+/** 시각(+ 여유)이 지났는데 아직 붙잡힌 것으로 보이는 발송 */
+export function overduePendingWatches(watches: readonly PendingWatch[], now: number, graceMs: number): PendingWatch[] {
+ return watches.filter((watch) => watch.at + graceMs <= now)
+}
+
+/**
+ * 다음에 다시 볼 시각 (ms) — 아직 오지 않은 것은 그 시각 + 여유, 이미 지난 것은 지금 + 다시 보기 간격 가운데 가장 이른 때.
+ * 지켜볼 것이 없으면 null
+ */
+export function nextPendingCheckAt(
+ watches: readonly PendingWatch[],
+ now: number,
+ timing: PendingCheckTiming,
+): number | null {
+ let next: number | null = null
+ for (const watch of watches) {
+ const due = watch.at + timing.graceMs
+ const at = due > now ? due : now + timing.recheckMs
+ if (next === null || at < next) next = at
+ }
+ return next
+}
+
+// ── 보낸 뒤 알림의 [보내기 취소] 마감 (쓰기 화면) ─────────────────────────────
+
+export interface UndoDeadlineInput {
+ /** 서버가 보낼 시각 (pendingSend.sendAt) */
+ sendAt: string
+ /** 지금 (ms — 응답을 받은 때) */
+ now: number
+ /** 알고 있는 보내기 취소 시간(초) — 설정을 받아 두지 않았으면 null */
+ undoSendSeconds: number | null
+ /** 설정한 초에서 뺄 몫 — 서버가 붙잡기 시작한 뒤 응답이 오기까지 지난 시간 */
+ marginMs: number
+ /** 설정을 모를 때의 가장 긴 시간 — 설정의 가장 긴 값(30초) */
+ maxMs: number
+}
+
+/**
+ * [보내기 취소] 알림이 닫힐 시각 (ms — 이 PC 시계).
+ * 설정한 초를 알면 지금 + 초 − 몫 — 이 PC 시계가 서버와 어긋나도(늦으면 sendAt 이 멀리 보인다) 알림이 설정보다 오래 남지 않는다.
+ * 모르면(또는 0 인데 서버가 붙잡았다 — 다른 PC 에서 바꿈) sendAt 까지, 다만 지금 + 가장 긴 시간을 넘지 않게.
+ * 이미 지났으면(지금 이하) 부르는 쪽이 [보내기 취소] 없이 알린다
+ */
+export function undoToastDeadline(input: UndoDeadlineInput): number {
+ const seconds = input.undoSendSeconds
+ if (seconds !== null && seconds > 0) return input.now + seconds * 1000 - input.marginMs
+ return Math.min(Date.parse(input.sendAt), input.now + input.maxMs)
+}
diff --git a/frontend/src/shared/utils/schedule-send.spec.ts b/frontend/src/shared/utils/schedule-send.spec.ts
new file mode 100644
index 0000000..a238da2
--- /dev/null
+++ b/frontend/src/shared/utils/schedule-send.spec.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ browserTimeZoneName,
+ customScheduleDayRange,
+ customScheduleOf,
+ customScheduleTime,
+ defaultCustomSchedule,
+ formatTimeSlot,
+ rejectedScheduleProblem,
+ schedulePresets,
+ scheduleProblem,
+ scheduleTimeSlots,
+ sendAtOf,
+} from './schedule-send'
+
+// 예약 발송의 시각 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 예약 창") — 모두 브라우저 시간대
+// 시각은 지역 시각으로 만든다(new Date(연, 월, 일, 시, 분)) — 테스트가 어느 시간대에서 돌아도 같은 결과다
+const at = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime()
+const MINUTE = 60_000
+
+describe('scheduleProblem — 지금부터 5분 뒤 이상 · 7일 안 (서버와 같은 한도)', () => {
+ const now = at(10, 29, 15, 0)
+
+ it('5분 뒤 · 7일 뒤 그 시각까지는 된다', () => {
+ expect(scheduleProblem(now + 5 * MINUTE, now)).toBeNull()
+ expect(scheduleProblem(now + 7 * 24 * 60 * MINUTE, now)).toBeNull()
+ })
+
+ it('5분 안 · 지난 시각 · 읽을 수 없는 시각은 tooSoon, 7일을 넘으면 tooLate', () => {
+ expect(scheduleProblem(now + 5 * MINUTE - 1, now)).toBe('tooSoon')
+ expect(scheduleProblem(now - MINUTE, now)).toBe('tooSoon')
+ expect(scheduleProblem(Number.NaN, now)).toBe('tooSoon')
+ expect(scheduleProblem(now + 7 * 24 * 60 * MINUTE + 1, now)).toBe('tooLate')
+ })
+
+ it('서버가 받지 않은 시각(VAL_001)의 까닭 — 이 화면의 검사로 고르고, 검사로는 맞으면(시계가 어긋남) 가까운 쪽 한도 (리뷰 #11)', () => {
+ // 보낸 사이 5분 안이 됐다 · 7일을 넘었다
+ expect(rejectedScheduleProblem(now + 4 * MINUTE, now)).toBe('tooSoon')
+ expect(rejectedScheduleProblem(now + 8 * 24 * 60 * MINUTE, now)).toBe('tooLate')
+ // 이 PC 로는 맞다 — 오늘 저녁은 아래 한도에, 6일 뒤는 위 한도에 가깝다
+ expect(rejectedScheduleProblem(now + 3 * 60 * MINUTE, now)).toBe('tooSoon')
+ expect(rejectedScheduleProblem(now + 6 * 24 * 60 * MINUTE, now)).toBe('tooLate')
+ })
+})
+
+describe('schedulePresets — 오늘 오후(남았을 때만) · 내일 오전 · 내일 오후 · 다음 주 월요일', () => {
+ it('목요일 오후 3시 — 오늘 오후 6:00 이 첫 줄, 다음 주 월요일은 11월 2일 오전 9:00 (보드 그대로)', () => {
+ expect(schedulePresets(at(10, 29, 15))).toEqual([
+ { id: 'todayEvening', at: at(10, 29, 18) },
+ { id: 'tomorrowMorning', at: at(10, 30, 9) },
+ { id: 'tomorrowAfternoon', at: at(10, 30, 13) },
+ { id: 'nextMonday', at: at(11, 2, 9) },
+ ])
+ })
+
+ it('오늘 오후 6:00 이 5분 안이거나 지났으면 「오늘 오후」를 두지 않는다', () => {
+ expect(schedulePresets(at(10, 29, 17, 56)).map((preset) => preset.id)).toEqual([
+ 'tomorrowMorning',
+ 'tomorrowAfternoon',
+ 'nextMonday',
+ ])
+ expect(schedulePresets(at(10, 29, 17, 55)).map((preset) => preset.id)[0]).toBe('todayEvening')
+ expect(schedulePresets(at(10, 29, 21)).map((preset) => preset.id)[0]).toBe('tomorrowMorning')
+ })
+
+ it('월요일 아침 — 다음 주 월요일 오전 9:00 은 7일을 넘어 두지 않는다 (9시가 지나면 7일 안이라 둔다)', () => {
+ // 2026-11-02 은 월요일
+ expect(schedulePresets(at(11, 2, 8)).map((preset) => preset.id)).not.toContain('nextMonday')
+ expect(schedulePresets(at(11, 2, 10))).toContainEqual({ id: 'nextMonday', at: at(11, 9, 9) })
+ })
+
+ it('일요일 — 다음 주 월요일 오전 9:00 이 내일 오전과 같으면 한 줄만 둔다', () => {
+ // 2026-11-01 은 일요일
+ const presets = schedulePresets(at(11, 1, 10))
+ expect(presets.map((preset) => preset.id)).toEqual(['todayEvening', 'tomorrowMorning', 'tomorrowAfternoon'])
+ expect(presets[1]?.at).toBe(at(11, 2, 9))
+ })
+
+ it('달이 바뀌어도 날짜를 맞춘다 (10월 31일 토요일 → 내일 11월 1일, 다음 주 월요일 11월 2일)', () => {
+ expect(schedulePresets(at(10, 31, 20))).toEqual([
+ { id: 'tomorrowMorning', at: at(11, 1, 9) },
+ { id: 'tomorrowAfternoon', at: at(11, 1, 13) },
+ { id: 'nextMonday', at: at(11, 2, 9) },
+ ])
+ })
+})
+
+describe('직접 고르기 — 날짜 + 30분 단위 시각', () => {
+ it('시각은 0:00 부터 23:30 까지 30분마다 48칸 — 글은 "오전 9:00" · "오후 1:30"', () => {
+ const slots = scheduleTimeSlots()
+ expect(slots).toHaveLength(48)
+ expect(slots[0]).toBe(0)
+ expect(slots.at(-1)).toBe(23 * 60 + 30)
+ expect(formatTimeSlot(9 * 60)).toBe('오전 9:00')
+ expect(formatTimeSlot(13 * 60 + 30)).toBe('오후 1:30')
+ })
+
+ it('처음 값은 내일 오전 9:00, 날짜 칸은 오늘부터 7일 뒤까지', () => {
+ const now = at(10, 29, 15)
+ expect(defaultCustomSchedule(now)).toEqual({ day: '2026-10-30', minutes: 9 * 60 })
+ expect(customScheduleDayRange(now)).toEqual({ min: '2026-10-29', max: '2026-11-05' })
+ })
+
+ it('날짜 · 시각 → 보낼 시각, 비었거나 없는 날짜 · 범위 밖의 시각이면 null', () => {
+ expect(customScheduleTime({ day: '2026-10-30', minutes: 9 * 60 + 30 })).toBe(at(10, 30, 9, 30))
+ expect(customScheduleTime({ day: '', minutes: 9 * 60 })).toBeNull()
+ expect(customScheduleTime({ day: '2026-02-30', minutes: 0 })).toBeNull()
+ expect(customScheduleTime({ day: '2026-10-30', minutes: 24 * 60 })).toBeNull()
+ expect(customScheduleTime({ day: '2026-10-30', minutes: -30 })).toBeNull()
+ })
+
+ it('보낼 시각 → 직접 고르기의 칸 (서버가 받지 않은 시각으로 창을 다시 열 때) — 30분 단위가 아니면 null', () => {
+ expect(customScheduleOf(at(10, 29, 18))).toEqual({ day: '2026-10-29', minutes: 18 * 60 })
+ expect(customScheduleOf(at(11, 2, 9, 30))).toEqual({ day: '2026-11-02', minutes: 9 * 60 + 30 })
+ expect(customScheduleOf(at(10, 29, 18, 10))).toBeNull()
+ expect(customScheduleOf(at(10, 29, 18) + 1_000)).toBeNull()
+ expect(customScheduleOf(Number.NaN)).toBeNull()
+ })
+})
+
+describe('보낼 시각 · 시간대 이름', () => {
+ it('서버에는 UTC ISO 8601 로 보낸다', () => {
+ expect(sendAtOf(Date.UTC(2026, 9, 30, 0, 0))).toBe('2026-10-30T00:00:00.000Z')
+ })
+
+ it('시간대 이름은 브라우저가 알려 주는 대로 — 한국 시간대는 "…표준시"', () => {
+ expect(browserTimeZoneName(Date.UTC(2026, 9, 29), 'Asia/Seoul')).toMatch(/표준시$/)
+ expect(browserTimeZoneName(Date.UTC(2026, 9, 29), 'Not/AZone')).toBeNull()
+ })
+})
diff --git a/frontend/src/shared/utils/schedule-send.ts b/frontend/src/shared/utils/schedule-send.ts
new file mode 100644
index 0000000..808b735
--- /dev/null
+++ b/frontend/src/shared/utils/schedule-send.ts
@@ -0,0 +1,157 @@
+// 예약 발송의 시각 — 고르기(프리셋) · 직접 고르기 · 검사 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계" — 순수 함수만 둔다)
+// 시각은 모두 브라우저 시간대로 계산하고, 서버에는 UTC(ISO 8601)로 보낸다 (toISOString).
+// 예약할 수 있는 때는 지금부터 5분 뒤 이상 · 7일 안 — 서버와 같은 한도(밖이면 400 VAL_001).
+
+import {
+ DAYS_PER_WEEK,
+ MINUTES_PER_DAY,
+ MONDAY,
+ SCHEDULE_CUSTOM_DEFAULT_HOUR,
+ SCHEDULE_MAX_AHEAD_MS,
+ SCHEDULE_MIN_LEAD_MS,
+ SCHEDULE_PRESET_HOURS,
+ SCHEDULE_SLOT_MINUTES,
+ type SchedulePresetId,
+} from '@/shared/constants/mail-send-later'
+import { formatDate } from '@/shared/utils/format'
+
+const MINUTES_PER_HOUR = 60
+
+/** 고르기 한 줄 */
+export interface SchedulePreset {
+ id: SchedulePresetId
+ /** 보낼 시각 (ms) */
+ at: number
+}
+
+/** 예약할 수 없는 까닭 — 5분 안(지난 시각 포함) · 7일 넘음 */
+export type ScheduleProblem = 'tooSoon' | 'tooLate'
+
+/** 서버가 받지 않은 예약 — 그 시각(ms)과 까닭. 쓰기 화면이 예약 창을 다시 열며 넘긴다 */
+export interface ScheduleRejection {
+ at: number
+ problem: ScheduleProblem
+}
+
+/** 「직접 고르기」의 칸 — 날짜( 값 YYYY-MM-DD)와 그날 0시부터의 분(30분 단위) */
+export interface CustomSchedule {
+ day: string
+ minutes: number
+}
+
+const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
+
+/** base 가 든 날(브라우저 시간대)에서 dayOffset 날 뒤의 hour:minute (ms) */
+function localTimeOf(base: number, dayOffset: number, hour: number, minute = 0): number {
+ const date = new Date(base)
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + dayOffset, hour, minute).getTime()
+}
+
+/** 예약할 수 있는 시각인가 — 지금부터 5분 뒤 이상 · 7일 안 (서버와 같다) */
+export function scheduleProblem(at: number, now: number): ScheduleProblem | null {
+ if (!Number.isFinite(at) || at < now + SCHEDULE_MIN_LEAD_MS) return 'tooSoon'
+ if (at > now + SCHEDULE_MAX_AHEAD_MS) return 'tooLate'
+ return null
+}
+
+/**
+ * 서버가 받지 않은 예약 시각(400 VAL_001)의 까닭 — 이 화면의 검사로 고른다(보낸 사이 5분 안이 됐으면 tooSoon).
+ * 이 화면의 검사로는 맞는데 서버가 받지 않았으면(이 PC 시계와 서버 시계가 어긋남) 가까운 쪽 한도로 본다
+ */
+export function rejectedScheduleProblem(at: number, now: number): ScheduleProblem {
+ const problem = scheduleProblem(at, now)
+ if (problem) return problem
+ return at - now < (SCHEDULE_MIN_LEAD_MS + SCHEDULE_MAX_AHEAD_MS) / 2 ? 'tooSoon' : 'tooLate'
+}
+
+/** 다음 주 월요일까지의 날 수 — 오늘 다음의 첫 월요일 (오늘이 월요일이면 7일 뒤) */
+function daysUntilNextMonday(now: number): number {
+ const diff = (MONDAY - new Date(now).getDay() + DAYS_PER_WEEK) % DAYS_PER_WEEK
+ return diff === 0 ? DAYS_PER_WEEK : diff
+}
+
+/**
+ * 예약 창의 고르기 — 오늘 오후 6:00(남았을 때만) · 내일 오전 9:00 · 내일 오후 1:00 · 다음 주 월요일 오전 9:00.
+ * 예약할 수 없는 시각(5분 안 · 7일 넘음 — 월요일 아침의 "다음 주 월요일" 등)은 뺀다.
+ * 같은 시각이 겹치면(일요일의 "다음 주 월요일" = 내일 오전) 앞의 것만 둔다
+ */
+export function schedulePresets(now: number): SchedulePreset[] {
+ const candidates: SchedulePreset[] = [
+ { id: 'todayEvening', at: localTimeOf(now, 0, SCHEDULE_PRESET_HOURS.todayEvening) },
+ { id: 'tomorrowMorning', at: localTimeOf(now, 1, SCHEDULE_PRESET_HOURS.tomorrowMorning) },
+ { id: 'tomorrowAfternoon', at: localTimeOf(now, 1, SCHEDULE_PRESET_HOURS.tomorrowAfternoon) },
+ { id: 'nextMonday', at: localTimeOf(now, daysUntilNextMonday(now), SCHEDULE_PRESET_HOURS.nextMonday) },
+ ]
+ const seen = new Set()
+ return candidates.filter((preset) => {
+ if (scheduleProblem(preset.at, now) !== null || seen.has(preset.at)) return false
+ seen.add(preset.at)
+ return true
+ })
+}
+
+/** 「직접 고르기」의 시각 고르기 — 0:00 부터 23:30 까지 30분마다 (그날 0시부터의 분) */
+export function scheduleTimeSlots(): number[] {
+ const slots: number[] = []
+ for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += SCHEDULE_SLOT_MINUTES) slots.push(minutes)
+ return slots
+}
+
+/** 「직접 고르기」의 처음 값 — 내일 오전 9:00 (보드의 처음 값) */
+export function defaultCustomSchedule(now: number): CustomSchedule {
+ return {
+ day: formatDate(new Date(localTimeOf(now, 1, 0))),
+ minutes: SCHEDULE_CUSTOM_DEFAULT_HOUR * MINUTES_PER_HOUR,
+ }
+}
+
+/** 날짜 칸이 고를 수 있는 범위 — 오늘부터 7일 뒤까지 ( 의 min · max) */
+export function customScheduleDayRange(now: number): { min: string; max: string } {
+ return { min: formatDate(new Date(now)), max: formatDate(new Date(now + SCHEDULE_MAX_AHEAD_MS)) }
+}
+
+/**
+ * 보낼 시각(ms) → 「직접 고르기」의 칸 — 서버가 받지 않은 시각으로 예약 창을 다시 열 때 그 시각을 칸에 채운다.
+ * 30분 단위가 아니면(고르기 · 직접 고르기로는 생기지 않는다) null
+ */
+export function customScheduleOf(at: number): CustomSchedule | null {
+ if (!Number.isFinite(at)) return null
+ const date = new Date(at)
+ if (date.getSeconds() !== 0 || date.getMilliseconds() !== 0) return null
+ const minutes = date.getHours() * MINUTES_PER_HOUR + date.getMinutes()
+ if (minutes % SCHEDULE_SLOT_MINUTES !== 0) return null
+ return { day: formatDate(date), minutes }
+}
+
+/** 「직접 고르기」 → 보낼 시각(ms). 날짜가 비었거나 없는 날짜(2월 30일 등) · 시각이 범위 밖이면 null */
+export function customScheduleTime(schedule: CustomSchedule): number | null {
+ const match = DAY_PATTERN.exec(schedule.day)
+ if (!match) return null
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
+ const { minutes } = schedule
+ if (!Number.isInteger(minutes) || minutes < 0 || minutes >= MINUTES_PER_DAY) return null
+ const date = new Date(year, month - 1, day, Math.floor(minutes / MINUTES_PER_HOUR), minutes % MINUTES_PER_HOUR)
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
+ return date.getTime()
+}
+
+/** 시각 고르기의 글 — "오전 9:00" · "오후 1:30" (브라우저 시간대의 그 시각 그대로) */
+export function formatTimeSlot(minutes: number): string {
+ const date = new Date(2000, 0, 1, Math.floor(minutes / MINUTES_PER_HOUR), minutes % MINUTES_PER_HOUR)
+ return new Intl.DateTimeFormat('ko-KR', { hour: 'numeric', minute: '2-digit' }).format(date)
+}
+
+/** 브라우저가 알려 주는 시간대 이름 — "한국 표준시" 등. 모르면 null (안내 글이 이름 없이 쓴다) */
+export function browserTimeZoneName(now: number, timeZone?: string): string | null {
+ try {
+ const parts = new Intl.DateTimeFormat('ko-KR', { timeZone, timeZoneName: 'long' }).formatToParts(new Date(now))
+ return parts.find((part) => part.type === 'timeZoneName')?.value?.trim() || null
+ } catch {
+ return null
+ }
+}
+
+/** 보낼 시각(ms) → 서버의 sendAt (UTC ISO 8601) */
+export function sendAtOf(at: number): string {
+ return new Date(at).toISOString()
+}
diff --git a/frontend/src/stores/mail-block.store.spec.ts b/frontend/src/stores/mail-block.store.spec.ts
new file mode 100644
index 0000000..105d427
--- /dev/null
+++ b/frontend/src/stores/mail-block.store.spec.ts
@@ -0,0 +1,98 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+
+import type { BlockedSender } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
+import { useMailBlockStore } from './mail-block.store'
+
+// 보낸 사람 차단 스토어 (계약 2절 "보낸 사람 차단" — 9단계)
+
+const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
+const SPAM: BlockedSender = { address: 'kim@spam.example', blockedAt: '2026-10-29T00:00:00.000Z' }
+const DOMAIN: BlockedSender = { address: '@ads.example', blockedAt: '2026-10-28T00:00:00.000Z' }
+
+function deferred() {
+ let resolve: (result: MockResult) => void = () => undefined
+ const promise = new Promise((settle) => {
+ resolve = settle
+ })
+ return { promise, resolve }
+}
+
+describe('mail-block.store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated(USER)
+ })
+
+ afterEach(() => {
+ restoreApiAdapter()
+ useToastStore().clear()
+ })
+
+ it('다시 받는 중에 풀기가 끝나면 받던 목록을 버리고 "ready" 로 돌린다 — 화면이 빈 자리에 멈추지 않는다 (리뷰 #6)', async () => {
+ let reload: ReturnType | null = null
+ installMockApi((request) => {
+ if (request.method === 'DELETE') return ok({ address: SPAM.address })
+ if (reload) return reload.promise
+ return ok({ senders: [SPAM, DOMAIN], isActive: true })
+ })
+ const store = useMailBlockStore()
+ await store.load()
+
+ // 설정 › 차단한 주소에 들어와 다시 받는 중 — 그사이 앞서 띄운 알림의 [되돌리기]로 푼다
+ reload = deferred()
+ const loading = store.load()
+ expect(store.status).toBe('loading')
+ await store.remove(SPAM.address)
+
+ expect(store.status).toBe('ready')
+ expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
+ // 늦게 온 옛 목록(푼 주소가 아직 든)은 버린다
+ reload.resolve(ok({ senders: [SPAM, DOMAIN], isActive: true }))
+ await loading
+ expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
+ expect(store.status).toBe('ready')
+ })
+
+ it('받아 둔 목록이 없을 때 받는 중에 풀었으면 푼 뒤의 목록으로 다시 받는다', async () => {
+ let first: ReturnType | null = deferred()
+ const requests = installMockApi((request) => {
+ if (request.method === 'DELETE') return ok({ address: SPAM.address })
+ if (first) {
+ const pending = first
+ first = null
+ return pending.promise
+ }
+ return ok({ senders: [DOMAIN], isActive: true })
+ })
+ const store = useMailBlockStore()
+
+ const stale = first
+ const loading = store.load()
+ await store.remove(SPAM.address)
+ stale?.resolve(ok({ senders: [SPAM, DOMAIN], isActive: true }))
+ await loading
+ await flushPromises()
+
+ expect(requests.filter((request) => request.method === 'GET')).toHaveLength(2)
+ expect(store.senders.map((sender) => sender.address)).toEqual([DOMAIN.address])
+ expect(store.status).toBe('ready')
+ })
+
+ it('이미 풀려 있던 풀기(404)는 알리지 않고 목록에서만 뺀다 — 규칙을 저장하지 않았으니 isActive 는 그대로', async () => {
+ installMockApi((request) =>
+ request.method === 'DELETE' ? fail(404, 'RES_001') : ok({ senders: [SPAM], isActive: false }),
+ )
+ const store = useMailBlockStore()
+ await store.load()
+
+ await store.remove(SPAM.address)
+
+ expect(store.senders).toEqual([])
+ expect(store.blockList?.isActive).toBe(false)
+ expect(useToastStore().toasts).toEqual([])
+ })
+})
diff --git a/frontend/src/stores/mail-block.store.ts b/frontend/src/stores/mail-block.store.ts
new file mode 100644
index 0000000..3668057
--- /dev/null
+++ b/frontend/src/stores/mail-block.store.ts
@@ -0,0 +1,127 @@
+import { computed, ref, shallowRef, watch } from 'vue'
+import { defineStore } from 'pinia'
+
+import { toApiError, type ApiError } from '@/composables/useApi'
+import { useMailSettings, type MailSettingsRequestOptions } from '@/composables/useMailSettings'
+import { MAIL_BLOCK_MAX } from '@/shared/constants/mail-block'
+import type { BlockedSender, MailBlockList } from '@/shared/types/mail'
+import { blockOfSender } from '@/shared/utils/mail-block'
+import { useAuthStore } from '@/stores/auth.store'
+import type { LoadStatus } from '@/stores/mail.store'
+
+// 보낸 사람 차단 스토어 — 계약 2절 "보낸 사람 차단" (9단계 — 직원마다 500개까지, 엔진의 규칙 스크립트 맨 앞에 둔다)
+// - 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded) — 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람" 줄이 본다.
+// 읽기 화면 ⋯ 를 열 때 받아 둔 것이 없으면(못 받았으면) 다시 받아 본다. 못 받으면 알리지 않고 지난 값을 둔다(모르는 동안은 "차단" 항목)
+// - 설정 › 차단한 주소는 들어올 때마다 새로 받는다(load) — 다른 탭 · PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다
+// - 더하기 · 풀기는 응답을 받은 뒤 목록에 넣고 뺀다(최근에 차단한 것이 위). 차단은 필터의 version 을 묻지 않는다 (주소 하나를 더하고 뺀다 — 계약)
+// 서버가 저장하면 엔진의 규칙이 다시 켜지므로(필터 · 부재중 · 차단 가운데 무엇이든 — 계약) isActive 도 켠다. 이미 풀려 있던 풀기(404)는 저장하지 않았으니 그대로
+// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset · 바꾸기가 올린다),
+// 더하기 · 풀기는 세션 세대(authStore.generation)로 가린다 (필터 · 부재중 스토어와 같은 결)
+export const useMailBlockStore = defineStore('mail-block', () => {
+ const settingsApi = useMailSettings()
+ const authStore = useAuthStore()
+
+ const blockList = shallowRef(null)
+ const status = ref('idle')
+ const error = ref(null)
+
+ const senders = computed(() => blockList.value?.senders ?? [])
+ /** 500개를 다 막았다 — 읽기 화면 ⋯ 의 「보낸 사람 차단」을 끄고 까닭을 보인다 */
+ const isFull = computed(() => senders.value.length >= MAIL_BLOCK_MAX)
+
+ let seq = 0
+
+ /** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 지난 값은 둔다) */
+ async function load(): Promise {
+ seq += 1
+ const current = seq
+ status.value = 'loading'
+ error.value = null
+ try {
+ const result = await settingsApi.getBlocks({ silent: true })
+ if (current !== seq) return blockList.value
+ blockList.value = result
+ status.value = 'ready'
+ return result
+ } catch (caught) {
+ if (current !== seq) return blockList.value
+ status.value = 'error'
+ error.value = toApiError(caught)
+ return null
+ }
+ }
+
+ /** 받아 둔 것이 없거나 못 받았으면 받는다 — 메일 화면에 들어올 때 · 읽기 화면 ⋯ 를 열 때 (알리지 않는다) */
+ function ensureLoaded(): void {
+ if (status.value === 'idle' || status.value === 'error') void load()
+ }
+
+ /** 그 보낸 사람을 막는 차단 — 주소가 같은 것 먼저, 그다음 그 도메인. 없거나 아직 모르면 null */
+ function blockOf(email: string | null | undefined): BlockedSender | null {
+ return email ? blockOfSender(senders.value, email) : null
+ }
+
+ /** 응답의 차단을 목록 맨 위에 둔다 (같은 값이 있으면 그 자리를 뺀다 — 이미 있으면 서버가 200 으로 같은 것을 준다) */
+ function withSender(list: MailBlockList, sender: BlockedSender): MailBlockList {
+ return { ...list, senders: [sender, ...list.senders.filter((item) => item.address !== sender.address)] }
+ }
+
+ /**
+ * 차단 (POST /mail-blocks) — 받은 값을 목록 맨 위에 넣는다. 실패는 예외가 그대로 올라간다(칸 안 안내 · 알림은 부르는 쪽 · 인터셉터).
+ * 기다리는 사이 세션이 바뀌었으면 결과를 돌려주기만 하고 목록에는 쓰지 않는다
+ */
+ async function add(address: string, options?: MailSettingsRequestOptions): Promise {
+ const session = authStore.generation
+ const sender = await settingsApi.addBlock(address, options)
+ if (authStore.generation !== session) return sender
+ seq += 1
+ blockList.value = { ...withSender(blockList.value ?? { senders: [], isActive: true }, sender), isActive: true }
+ if (status.value !== 'ready') status.value = 'ready'
+ return sender
+ }
+
+ /**
+ * 차단 풀기 (DELETE /mail-blocks/{address}) — 목록에서 뺀다. 없으면(404 — 다른 곳에서 이미 풀었다) 푼 것과 같다 — 알리지 않고 목록에서 뺀다.
+ * 그 밖의 실패는 예외가 그대로 올라간다(알림은 인터셉터). 기다리는 사이 세션이 바뀌었으면 목록에는 쓰지 않는다.
+ * 받는 중인 목록(load)은 푼 것보다 옛것일 수 있어 버린다 — 상태도 'ready' 로 돌린다(더하기와 같다 — 버린 받기가 'loading' 에 멈춰 두지 않게).
+ * 받아 둔 목록이 아직 없으면 고칠 목록이 없다 — 받는 중이었으면 푼 뒤의 목록으로 다시 받는다
+ */
+ async function remove(address: string): Promise {
+ const session = authStore.generation
+ /** 서버가 규칙을 저장했다 (이미 풀려 있던 404 는 아니다) */
+ let saved = true
+ try {
+ await settingsApi.removeBlock(address, { silent: ['RES_001'] })
+ } catch (caught) {
+ if (toApiError(caught).code !== 'RES_001') throw caught
+ saved = false
+ }
+ if (authStore.generation !== session) return
+ if (!blockList.value) {
+ if (status.value === 'loading') void load()
+ return
+ }
+ seq += 1
+ blockList.value = {
+ senders: blockList.value.senders.filter((item) => item.address !== address),
+ isActive: saved || blockList.value.isActive,
+ }
+ status.value = 'ready'
+ error.value = null
+ }
+
+ function reset() {
+ seq += 1
+ blockList.value = null
+ status.value = 'idle'
+ error.value = null
+ }
+
+ // 로그아웃 · 다른 계정 로그인 — 이전 사용자의 차단 목록이 남지 않게
+ watch(
+ () => authStore.user?.email ?? null,
+ () => reset(),
+ )
+
+ return { blockList, senders, status, error, isFull, load, ensureLoaded, blockOf, add, remove, reset }
+})
diff --git a/frontend/src/stores/mail-settings.store.ts b/frontend/src/stores/mail-settings.store.ts
new file mode 100644
index 0000000..b0335b0
--- /dev/null
+++ b/frontend/src/stores/mail-settings.store.ts
@@ -0,0 +1,173 @@
+import { computed, ref, watch } from 'vue'
+import { defineStore } from 'pinia'
+
+import { toApiError, type ApiError } from '@/composables/useApi'
+import { useMailSettings } from '@/composables/useMailSettings'
+import type { MailSettings, UndoSendSeconds } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import type { LoadStatus } from '@/stores/mail.store'
+
+type SettingKey = keyof MailSettings
+
+// 보내기 · 읽기 설정 스토어 — 계약 2절 "보내기 · 읽기 설정" (9단계 — 직원마다 서버 DB 에 하나, 어느 PC 에서나 같다)
+// - 설정 › 메일 › 보내기 · 읽기는 들어올 때마다 새로 받는다(load) — 다른 PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
+// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다
+// - 고르는 대로 그 칸만 PATCH 한다(저장 단추 없음). 화면은 늘 마지막으로 고른 값을 보인다.
+// **칸마다 저장은 한 번에 하나**(6단계 필터의 saving 과 같은 결) — 보내는 동안 다시 고르면 마지막으로 고른 것만 기억해 두고,
+// 앞 저장이 끝난 뒤 그것이 서버의 답과 다르면 보낸다. 두 저장을 함께 보내면 서버가 나중에 처리한 쪽이 남는데, 응답 차례로는 그것을 알 수 없어
+// 화면("늘 묻기")과 DB("늘 보기")가 갈렸다(2026-10-29 리뷰 — 외부 이미지는 개인정보 영향).
+// 마지막으로 고른 것의 저장이 실패하면 그 칸을 서버가 마지막으로 확인해 준 값으로 되돌린다(오류 알림은 인터셉터)
+// - 기다리는 저장이 모두 끝났고 마지막으로 끝난 저장이 성공이면 saved — 화면이 "저장했습니다"를 보인다. 다시 고르면 내린다
+// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) · 다시 받은 뒤(load) 늦게 온 응답은 쓰지 않는다 (authStore.generation · 순번)
+export const useMailSettingsStore = defineStore('mail-settings', () => {
+ const settingsApi = useMailSettings()
+ const authStore = useAuthStore()
+
+ /** 보이는 값 — 고르면 저장이 끝나기 전에 먼저 바뀐다 (마지막으로 고른 값) */
+ const settings = ref(null)
+ const status = ref('idle')
+ const error = ref(null)
+ /** 기다리는 저장이 모두 끝났고 마지막이 성공했다 — "저장했습니다" */
+ const saved = ref(false)
+ /** 저장하는 중인 칸 수 */
+ const pending = ref(0)
+
+ /**
+ * 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
+ * 쓰기 화면이 [보내기 취소] 알림의 마감을 이 초로 센다 (이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게 — 계약 2절)
+ */
+ const knownUndoSendSeconds = computed(() =>
+ status.value === 'ready' && settings.value ? settings.value.undoSendSeconds : null,
+ )
+
+ /** 서버가 마지막으로 확인해 준 값 — 마지막으로 고른 것의 저장이 실패하면 그 칸을 이 값으로 되돌린다 */
+ let confirmed: MailSettings | null = null
+ /** 칸마다 마지막으로 고른 값 — 보내는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
+ const wanted = new Map()
+ /** 칸마다 도는 저장 — 한 칸에 하나. 끝나면 지운다 */
+ const chains = new Map>()
+ /** 마지막으로 끝난 저장 요청이 성공했다 — 모두 끝났을 때 "저장했습니다"를 보일지 */
+ let lastSucceeded = false
+ let loadSeq = 0
+
+ /** 기다리던 저장을 모두 잊는다 — 받은 값을 새로 쓰기 전(load) · 세션이 바뀔 때(reset). 늦게 끝나는 저장은 순번으로 버린다 */
+ function forgetSaves() {
+ wanted.clear()
+ chains.clear()
+ pending.value = 0
+ lastSucceeded = false
+ }
+
+ /** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
+ async function load(): Promise {
+ loadSeq += 1
+ const current = loadSeq
+ status.value = 'loading'
+ error.value = null
+ saved.value = false
+ // 저장하던 중에 떠났다 돌아왔으면 그 저장은 이 화면의 것이 아니다 — 세던 수가 남으면 "저장했습니다"가 다시 뜨지 않는다 (리뷰 #7)
+ forgetSaves()
+ try {
+ const result = await settingsApi.getSettings({ silent: true })
+ if (current !== loadSeq) return settings.value
+ settings.value = { ...result }
+ confirmed = { ...result }
+ status.value = 'ready'
+ return result
+ } catch (caught) {
+ if (current !== loadSeq) return settings.value
+ status.value = 'error'
+ error.value = toApiError(caught)
+ return null
+ }
+ }
+
+ /** 받아 둔 것이 없거나 못 받았으면 받는다 — 메일 화면에 들어올 때 (알리지 않는다) */
+ function ensureLoaded(): void {
+ if (status.value === 'idle' || status.value === 'error') void load()
+ }
+
+ function setKey(key: K, value: MailSettings[K]) {
+ if (settings.value) settings.value = { ...settings.value, [key]: value }
+ }
+
+ /** 그 칸의 저장을 보낸다 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
+ async function flush(key: K): Promise {
+ const session = authStore.generation
+ const sessionLoad = loadSeq
+ const stillCurrent = () => authStore.generation === session && loadSeq === sessionLoad
+ pending.value += 1
+ let succeeded = false
+ try {
+ let value = wanted.get(key) as MailSettings[K]
+ for (;;) {
+ let next: MailSettings[K] | undefined
+ try {
+ const result = await settingsApi.updateSettings({ [key]: value } as Partial)
+ if (!stillCurrent()) return false
+ succeeded = true
+ confirmed = { ...(confirmed ?? result), [key]: result[key] }
+ const latest = wanted.get(key) as MailSettings[K]
+ // 그사이 다른 값을 골랐고 그것이 서버의 답과 다르면 이어 보낸다 — 아니면 서버의 답으로 맞추고 끝낸다
+ if (latest !== value && latest !== result[key]) next = latest
+ else setKey(key, result[key])
+ } catch {
+ if (!stillCurrent()) return false
+ succeeded = false
+ const latest = wanted.get(key) as MailSettings[K]
+ // 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값으로 되돌린다
+ if (latest !== value && latest !== confirmed?.[key]) next = latest
+ else if (confirmed) setKey(key, confirmed[key])
+ }
+ if (next === undefined) break
+ value = next
+ }
+ return succeeded
+ } finally {
+ if (stillCurrent()) {
+ wanted.delete(key)
+ chains.delete(key)
+ lastSucceeded = succeeded
+ pending.value = Math.max(0, pending.value - 1)
+ if (pending.value === 0) saved.value = lastSucceeded
+ }
+ }
+ }
+
+ /**
+ * 한 칸을 고른다 — 화면을 먼저 바꾸고 PATCH { key: value }. 그 칸의 저장이 이미 돌고 있으면 보내지 않고 고른 값만 기억한다
+ * (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받은 설정이 없으면 보내지 않는다(false).
+ * 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
+ */
+ function update(key: K, value: MailSettings[K]): Promise {
+ if (!settings.value || status.value !== 'ready') return Promise.resolve(false)
+ const running = chains.get(key)
+ // 지금 값을 다시 눌렀다 — 보낼 것이 없다
+ if (!running && settings.value[key] === value) return Promise.resolve(true)
+ setKey(key, value)
+ wanted.set(key, value)
+ saved.value = false
+ if (running) return running
+ const chain = flush(key)
+ chains.set(key, chain)
+ return chain
+ }
+
+ function reset() {
+ loadSeq += 1
+ settings.value = null
+ confirmed = null
+ forgetSaves()
+ status.value = 'idle'
+ error.value = null
+ saved.value = false
+ }
+
+ // 로그아웃 · 다른 계정 로그인 — 이전 사용자의 설정이 남지 않게
+ watch(
+ () => authStore.user?.email ?? null,
+ () => reset(),
+ )
+
+ return { settings, status, error, saved, pending, knownUndoSendSeconds, load, ensureLoaded, update, reset }
+})
diff --git a/frontend/src/stores/mail.store.stage9.spec.ts b/frontend/src/stores/mail.store.stage9.spec.ts
new file mode 100644
index 0000000..0a9cf17
--- /dev/null
+++ b/frontend/src/stores/mail.store.stage9.spec.ts
@@ -0,0 +1,456 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+
+import type { MessageDetail, MessageSummary, PendingSend } from '@/shared/types/mail'
+import { useAuthStore } from '@/stores/auth.store'
+import { useToastStore } from '@/stores/toast.store'
+import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
+import { useMailStore } from './mail.store'
+
+// 메일 스토어의 9단계 — 외부 이미지 보기 · 예약됨 목록 · 예약한 메일 수 · 붙잡은 발송 취소
+// (계약 2절 "외부 이미지 보기" · "보내기 취소 · 예약 발송")
+
+const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
+const SCHEDULED: PendingSend = { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' }
+
+function summary(id: string, partial: Partial = {}): MessageSummary {
+ return {
+ id,
+ threadId: `t-${id}`,
+ mailboxIds: ['sent'],
+ from: { name: '김아라', email: USER.email },
+ to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
+ subject: `제목 ${id}`,
+ preview: '',
+ receivedAt: '2026-10-29T01:00:00Z',
+ isRead: true,
+ isFlagged: false,
+ hasAttachment: false,
+ size: 100,
+ pendingSend: null,
+ ...partial,
+ }
+}
+
+function detail(id: string, partial: Partial = {}): MessageDetail {
+ return {
+ ...summary(id, { mailboxIds: ['inbox'], from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' } }),
+ cc: [],
+ bcc: [],
+ replyTo: [],
+ sentAt: null,
+ messageId: null,
+ references: [],
+ htmlBody: '안녕하세요 ',
+ textBody: '안녕하세요',
+ hasBlockedImages: true,
+ imagesShown: false,
+ attachments: [],
+ ...partial,
+ }
+}
+
+function page(items: MessageSummary[], total = items.length, nextCursor: string | null = null) {
+ return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } })
+}
+
+/** 돌려줄 응답을 나중에 정하는 요청 */
+function deferred() {
+ let resolve: (result: MockResult) => void = () => undefined
+ const promise = new Promise((settle) => {
+ resolve = settle
+ })
+ return { promise, resolve }
+}
+
+describe('mail.store — 9단계', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ useAuthStore().markAuthenticated(USER)
+ })
+
+ afterEach(() => {
+ restoreApiAdapter()
+ useToastStore().clear()
+ })
+
+ describe('외부 이미지 보기 — showImages', () => {
+ const SHOWN = detail('m1', {
+ htmlBody: '안녕하세요 ',
+ hasBlockedImages: false,
+ imagesShown: true,
+ isRead: false,
+ })
+
+ it('열린 메일을 images=show 로 다시 받아 본문만 바꾼다 — 그사이 바뀐 읽음 · 중요 표시는 지금 값 그대로', async () => {
+ const requests = installMockApi((request) => (request.params.images === 'show' ? ok(SHOWN) : ok({})))
+ const store = useMailStore()
+ store.selectedId = 'm1'
+ store.selected = detail('m1', { isFlagged: true })
+ store.selectedStatus = 'ready'
+
+ await expect(store.showImages()).resolves.toBe(true)
+
+ expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
+ ['GET', '/messages/m1', { images: 'show' }],
+ ])
+ expect(store.selected).toMatchObject({
+ htmlBody: SHOWN.htmlBody,
+ hasBlockedImages: false,
+ imagesShown: true,
+ isFlagged: true,
+ isRead: true,
+ })
+ expect(store.imagesLoadingId).toBeNull()
+ })
+
+ it('받는 사이 다른 메일을 열었으면 늦은 응답을 버린다', async () => {
+ const late = deferred()
+ installMockApi((request) => {
+ if (request.params.images === 'show') return late.promise
+ return ok(detail('m2', { hasBlockedImages: false }))
+ })
+ const store = useMailStore()
+ store.selectedId = 'm1'
+ store.selected = detail('m1')
+ store.selectedStatus = 'ready'
+
+ const showing = store.showImages()
+ expect(store.imagesLoadingId).toBe('m1')
+ await store.openMessage('m2')
+ late.resolve(ok(SHOWN))
+
+ await expect(showing).resolves.toBe(false)
+ expect(store.selected?.id).toBe('m2')
+ expect(store.selected?.imagesShown).toBe(false)
+ })
+
+ it('이미 보이는 메일 · 받는 중에 또 누르면 다시 받지 않고, 실패하면 그대로 둔다(알림은 인터셉터)', async () => {
+ const requests = installMockApi(() => fail(503, 'MAIL_001'))
+ const store = useMailStore()
+ store.selectedId = 'm1'
+ store.selected = detail('m1')
+ store.selectedStatus = 'ready'
+
+ const first = store.showImages()
+ await expect(store.showImages()).resolves.toBe(false)
+ await expect(first).resolves.toBe(false)
+ expect(requests).toHaveLength(1)
+ expect(store.selected?.hasBlockedImages).toBe(true)
+ expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
+
+ store.selected = { ...SHOWN }
+ await expect(store.showImages()).resolves.toBe(false)
+ expect(requests).toHaveLength(1)
+ })
+ })
+
+ describe('예약됨 목록 — GET /scheduled-messages (보낼 시각 차례)', () => {
+ it('예약됨을 열면 그 목록을 받고, 전체 수(total)로 패널의 「예약됨」 수를 맞춘다 — 더 보기도 같은 길', async () => {
+ const requests = installMockApi((request) => {
+ if (request.url !== '/scheduled-messages') return ok({})
+ if (request.params.cursor === 'c2') return page([summary('s3', { pendingSend: SCHEDULED })], 3)
+ return page([summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })], 3, 'c2')
+ })
+ const store = useMailStore()
+
+ await store.openScheduled()
+ expect(store.listSource).toEqual({ kind: 'scheduled' })
+ expect(store.currentMailboxId).toBeNull()
+ expect(store.messages.map((message) => message.id)).toEqual(['s1', 's2'])
+ expect(store.scheduledCount).toBe(3)
+
+ await store.loadMore()
+ expect(store.messages.map((message) => message.id)).toEqual(['s1', 's2', 's3'])
+ expect(requests.map((request) => [request.url, request.params])).toEqual([
+ ['/scheduled-messages', { limit: 20 }],
+ ['/scheduled-messages', { cursor: 'c2', limit: 20 }],
+ ])
+ })
+
+ it('새 메일 확인으로 맞출 때 보낸 메일은 빠지고 — "새 메일 N통" 줄은 띄우지 않는다', async () => {
+ let items = [summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
+ installMockApi((request) => (request.url === '/scheduled-messages' ? page(items) : ok({})))
+ const store = useMailStore()
+ await store.openScheduled()
+
+ items = [summary('s0', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
+ await store.syncVisibleList()
+
+ expect(store.messages.map((message) => message.id)).toEqual(['s0', 's2'])
+ expect(store.listSync?.addedAbove).toBe(0)
+ expect(store.scheduledCount).toBe(2)
+ })
+
+ it('예약한 메일 수는 limit=1 의 total — 못 받으면 알리지 않고 지난 값을 둔다', async () => {
+ let reply: MockResult = page([summary('s1', { pendingSend: SCHEDULED })], 4)
+ const requests = installMockApi(() => reply)
+ const store = useMailStore()
+
+ await store.loadScheduledCount()
+ expect(store.scheduledCount).toBe(4)
+ expect(requests[0]?.params).toEqual({ limit: 1 })
+
+ reply = fail(503, 'MAIL_001')
+ await store.loadScheduledCount()
+ expect(store.scheduledCount).toBe(4)
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('늦게 온 개수 응답은 그 뒤에 받은 예약됨 목록의 total 을 덮지 않는다', async () => {
+ const late = deferred()
+ installMockApi((request) => (request.params.limit === 1 ? late.promise : page([summary('s1')], 1)))
+ const store = useMailStore()
+
+ const counting = store.loadScheduledCount()
+ await store.openScheduled()
+ late.resolve(page([], 9))
+ await counting
+
+ expect(store.scheduledCount).toBe(1)
+ })
+ })
+
+ describe('붙잡은 발송 취소 — DELETE /scheduled-messages/{id}', () => {
+ it('취소하면 예약됨 목록에서 빼고 개수 · 예약한 메일 수를 다시 맞춘다 — draftId 를 돌려준다', async () => {
+ let scheduled = [summary('s1', { pendingSend: SCHEDULED }), summary('s2', { pendingSend: SCHEDULED })]
+ const requests = installMockApi((request) => {
+ if (request.method === 'DELETE') {
+ scheduled = scheduled.filter((item) => item.id !== 's1')
+ return ok({ draftId: 's1' })
+ }
+ if (request.url === '/scheduled-messages') return page(scheduled)
+ if (request.url === '/mailboxes') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
+ return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
+ })
+ const store = useMailStore()
+ await store.openScheduled()
+ store.selectedId = 's1'
+ store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
+ store.selectedStatus = 'ready'
+
+ await expect(store.cancelPendingSend('s1')).resolves.toEqual({ draftId: 's1' })
+ await flushPromises()
+
+ expect(store.messages.map((message) => message.id)).toEqual(['s2'])
+ expect(store.selected?.pendingSend).toBeNull()
+ expect(store.scheduledCount).toBe(1)
+ expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(
+ expect.arrayContaining(['DELETE /scheduled-messages/s1', 'GET /mailboxes', 'GET /mail-quota']),
+ )
+ })
+
+ it('이미 보냈으면(409 MAIL_014) 빼지 않고 예외가 올라간다 — 알림은 인터셉터, 보이는 목록 · 예약됨 수는 다시 본다', async () => {
+ installMockApi((request) =>
+ request.method === 'DELETE' ? fail(409, 'MAIL_014') : page([summary('s1', { pendingSend: SCHEDULED })]),
+ )
+ const store = useMailStore()
+ await store.openScheduled()
+
+ await expect(store.cancelPendingSend('s1')).rejects.toMatchObject({ status: 409 })
+ expect(store.messages.map((message) => message.id)).toEqual(['s1'])
+ expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
+ })
+ })
+
+ describe('아직 보내지 않은 메일(붙잡은 발송)은 고를 수 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (리뷰 #1)', () => {
+ const UNDO: PendingSend = { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' }
+
+ function sentList() {
+ const store = useMailStore()
+ store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
+ store.messages = [
+ summary('a'),
+ summary('held-1', { pendingSend: UNDO }),
+ summary('b'),
+ summary('held-2', { pendingSend: SCHEDULED }),
+ summary('c'),
+ ]
+ store.listStatus = 'ready'
+ return store
+ }
+
+ it('하나 고르기 · 범위 · 모두 고르기가 붙잡은 발송을 건너뛴다 — 머리 상자도 고를 수 있는 메일 기준', () => {
+ const store = sentList()
+
+ store.toggleCheck('held-1')
+ expect([...store.checkedIds]).toEqual([])
+
+ store.toggleCheck('a')
+ store.checkRangeTo('c')
+ expect([...store.checkedIds].sort()).toEqual(['a', 'b', 'c'])
+ expect(store.checkState).toBe('all')
+
+ store.clearChecked()
+ store.toggleCheckAll()
+ expect([...store.checkedIds].sort()).toEqual(['a', 'b', 'c'])
+ store.toggleCheckAll()
+ expect(store.checkedCount).toBe(0)
+ })
+
+ it('여러 통 처리에 붙잡은 발송이 들어가지 않는다 — 고른 뒤 붙잡은 발송이 되면 고른 것에서도 빠진다', async () => {
+ const requests = installMockApi((request) =>
+ request.method === 'PATCH'
+ ? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
+ : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }),
+ )
+ const store = sentList()
+ store.toggleCheck('a')
+ store.toggleCheck('b')
+ // 다른 창에서 같은 메일이 붙잡힌 발송으로 보이게 됐다(목록을 다시 맞춤)
+ store.messages = store.messages.map((message) => (message.id === 'b' ? { ...message, pendingSend: UNDO } : message))
+ expect([...store.checkedIds]).toEqual(['a'])
+
+ await store.moveChecked('trash')
+
+ expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
+ { ids: ['a'], mailboxId: 'trash' },
+ ])
+ })
+ })
+
+ describe('엔진이 붙잡은 발송을 풀었는지 다시 보기 — refreshPendingSends (리뷰 #2)', () => {
+ it('보이는 목록(붙잡은 메일이 보이면 검색 결과도) · 열린 메일 · 예약됨 수를 알리지 않고 다시 본다 — 풀린 메일의 표시가 내려간다', async () => {
+ const held = summary('s1', { pendingSend: SCHEDULED })
+ const requests = installMockApi((request) => {
+ if (request.url === '/messages') return page([{ ...held, pendingSend: null }])
+ if (request.url === '/messages/s1') return ok({ ...detail('s1'), pendingSend: null })
+ if (request.url === '/scheduled-messages') return page([], 0)
+ return ok({})
+ })
+ const store = useMailStore()
+ store.listSource = { kind: 'search', q: '배포', scope: 'all' }
+ store.messages = [held]
+ store.listStatus = 'ready'
+ store.selectedId = 's1'
+ store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
+ store.selectedStatus = 'ready'
+
+ await store.refreshPendingSends()
+
+ expect(requests.map((request) => [request.method, request.url])).toEqual(
+ expect.arrayContaining([
+ ['GET', '/messages'],
+ ['GET', '/messages/s1'],
+ ['GET', '/scheduled-messages'],
+ ]),
+ )
+ expect(store.messages[0]?.pendingSend).toBeNull()
+ expect(store.selected?.pendingSend).toBeNull()
+ expect(store.scheduledCount).toBe(0)
+ expect(useToastStore().toasts).toEqual([])
+ })
+
+ it('목록을 다시 맞추면 열린 메일의 붙잡은 발송도 맞춘다 (refreshSelectedFrom)', async () => {
+ installMockApi(() => page([summary('s1', { pendingSend: null })]))
+ const store = useMailStore()
+ store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
+ store.messages = [summary('s1', { pendingSend: SCHEDULED })]
+ store.listStatus = 'ready'
+ store.selectedId = 's1'
+ store.selected = { ...detail('s1', { mailboxIds: ['sent'] }), pendingSend: SCHEDULED }
+
+ await store.syncVisibleList()
+
+ expect(store.selected?.pendingSend).toBeNull()
+ })
+
+ it('열린 메일을 다시 보는 사이 다른 메일을 열었으면 늦은 응답을 버린다', async () => {
+ const late = deferred()
+ installMockApi((request) => {
+ if (request.url === '/messages/s1') return late.promise
+ if (request.url === '/messages/m2') return ok(detail('m2'))
+ return page([], 0)
+ })
+ const store = useMailStore()
+ store.selectedId = 's1'
+ store.selected = { ...detail('s1'), pendingSend: SCHEDULED }
+ store.selectedStatus = 'ready'
+
+ const refreshing = store.refreshPendingSends()
+ await store.openMessage('m2')
+ late.resolve(ok({ ...detail('s1'), pendingSend: null }))
+ await refreshing
+
+ expect(store.selected?.id).toBe('m2')
+ })
+
+ it('예약됨 수를 물으면 가장 이른 예약(첫 줄)을 지켜볼 수 있게 둔다', async () => {
+ installMockApi(() => page([summary('s1', { pendingSend: SCHEDULED })], 3))
+ const store = useMailStore()
+
+ await store.loadScheduledCount()
+
+ expect(store.nextScheduled?.id).toBe('s1')
+ useAuthStore().clearAuth()
+ await flushPromises()
+ expect(store.nextScheduled).toBeNull()
+ })
+ })
+
+ describe('붙잡은 발송 취소 — 같은 메일은 한 번에 하나 · 늦은 응답은 그 세션 · 그 목록에서만 (리뷰 #9 · #12)', () => {
+ it('같은 메일을 거듭 취소하면 요청은 하나 — 둘 다 같은 결과를 받는다', async () => {
+ const cancel = deferred()
+ const requests = installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([], 0)))
+ const store = useMailStore()
+
+ const first = store.cancelPendingSend('s1')
+ const second = store.cancelPendingSend('s1')
+ expect(store.isCancellingPendingSend('s1')).toBe(true)
+ cancel.resolve(ok({ draftId: 's1' }))
+
+ await expect(first).resolves.toEqual({ draftId: 's1' })
+ await expect(second).resolves.toEqual({ draftId: 's1' })
+ expect(requests.filter((request) => request.method === 'DELETE')).toHaveLength(1)
+ expect(store.isCancellingPendingSend('s1')).toBe(false)
+ })
+
+ it('기다리는 사이 다른 계정으로 바뀌었으면 늦은 응답으로 새 계정의 목록을 고치지 않는다', async () => {
+ const cancel = deferred()
+ installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([], 0)))
+ const store = useMailStore()
+
+ const cancelling = store.cancelPendingSend('s1')
+ useAuthStore().clearAuth()
+ useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })
+ await flushPromises()
+ // 새 계정의 목록 — 메일 id 가 앞 계정과 겹칠 수 있다
+ store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
+ store.messages = [summary('s1', { pendingSend: SCHEDULED })]
+ store.listStatus = 'ready'
+ cancel.resolve(ok({ draftId: 's1' }))
+ await cancelling
+
+ expect(store.messages.map((message) => message.id)).toEqual(['s1'])
+ expect(store.messages[0]?.pendingSend).toEqual(SCHEDULED)
+ })
+
+ it('기다리는 사이 다른 목록으로 옮겨 갔으면 그 목록에서 빼지 않는다', async () => {
+ const cancel = deferred()
+ installMockApi((request) => (request.method === 'DELETE' ? cancel.promise : page([summary('s1')])))
+ const store = useMailStore()
+ store.listSource = { kind: 'scheduled' }
+ store.messages = [summary('s1', { pendingSend: SCHEDULED })]
+ store.listStatus = 'ready'
+
+ const cancelling = store.cancelPendingSend('s1')
+ store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
+ store.messages = [summary('s1', { mailboxIds: ['inbox'] })]
+ cancel.resolve(ok({ draftId: 's1' }))
+ await cancelling
+
+ expect(store.messages.map((message) => message.id)).toEqual(['s1'])
+ })
+ })
+
+ it('로그아웃하면 예약한 메일 수도 비운다', async () => {
+ installMockApi(() => page([], 2))
+ const store = useMailStore()
+ await store.loadScheduledCount()
+ expect(store.scheduledCount).toBe(2)
+
+ useAuthStore().clearAuth()
+ await flushPromises()
+ expect(store.scheduledCount).toBe(0)
+ })
+})
diff --git a/frontend/src/stores/mail.store.ts b/frontend/src/stores/mail.store.ts
index 09ea760..e73d9e4 100644
--- a/frontend/src/stores/mail.store.ts
+++ b/frontend/src/stores/mail.store.ts
@@ -8,17 +8,19 @@ import { createListState } from './mail/list'
import { createMailboxAdmin } from './mail/mailbox-admin'
import { createMailboxesState } from './mail/mailboxes'
import { createMessageActions, createOpenedMessage } from './mail/message'
+import { createScheduledState } from './mail/scheduled'
import { createSelection } from './mail/selection'
export type { BulkResult } from '@/shared/utils/mail-bulk'
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
-// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
+// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
-// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
+// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
+// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 엔진이 풀었는지 다시 보기
//
// 오류 처리 원칙:
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
@@ -39,6 +41,13 @@ export const useMailStore = defineStore('mail', () => {
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
+ const scheduled = createScheduledState({
+ mailApi,
+ mailboxes: mailboxState,
+ list,
+ opened,
+ sessionGeneration: () => authStore.generation,
+ })
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
const lastBrowseLocation = ref(null)
@@ -73,6 +82,7 @@ export const useMailStore = defineStore('mail', () => {
function reset() {
admin.reset()
+ scheduled.reset()
mailboxState.reset()
list.reset()
selection.clearChecked()
@@ -122,6 +132,7 @@ export const useMailStore = defineStore('mail', () => {
hasMore: list.hasMore,
neighborAfterRemoval: list.neighborAfterRemoval,
openMailbox: list.openMailbox,
+ openScheduled: list.openScheduled,
openSearch: list.openSearch,
rememberSearchOrigin: list.rememberSearchOrigin,
refresh: list.refresh,
@@ -141,11 +152,20 @@ export const useMailStore = defineStore('mail', () => {
selected: opened.selected,
selectedStatus: opened.selectedStatus,
selectedError: opened.selectedError,
+ imagesLoadingId: opened.imagesLoadingId,
openMessage: message.openMessage,
closeMessage: opened.closeMessage,
markRead: message.markRead,
+ showImages: message.showImages,
toggleFlag: message.toggleFlag,
moveMessage: message.moveMessage,
+ // 예약됨 · 붙잡은 발송 (9단계)
+ scheduledCount: scheduled.scheduledCount,
+ nextScheduled: scheduled.nextScheduled,
+ loadScheduledCount: scheduled.loadScheduledCount,
+ cancelPendingSend: scheduled.cancelPendingSend,
+ isCancellingPendingSend: scheduled.isCancellingPendingSend,
+ refreshPendingSends: scheduled.refreshPendingSends,
// 여러 통 처리 · 한 통 삭제
patchChecked: bulk.patchChecked,
moveMessages: bulk.moveMessages,
diff --git a/frontend/src/stores/mail/list.ts b/frontend/src/stores/mail/list.ts
index dd88eb8..b2bb79a 100644
--- a/frontend/src/stores/mail/list.ts
+++ b/frontend/src/stores/mail/list.ts
@@ -9,17 +9,20 @@
import { computed, ref, watch } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
+import type { MailRequestOptions } from '@/composables/useMail'
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
-import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
+import { DEFAULT_PAGE_SIZE, type PageInfo, type PaginatedResult } from '@/shared/types/pagination'
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
import {
listQueryOf,
sameListSource,
scopeMailboxIdOf,
+ SCHEDULED_LIST_SOURCE,
type MailListSource,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
+import { messagePendingSend, samePendingSend } from '@/shared/utils/pending-send'
import type { MailboxesState } from './mailboxes'
import type { OpenedMessage } from './message'
import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
@@ -162,18 +165,29 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
})
}
- /** 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함)를 맞춘다 */
+ /**
+ * 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함 · 붙잡은 발송)를 맞춘다 — 엔진이 붙잡은 발송을 풀었으면(9단계)
+ * 읽기 화면의 예약 안내 줄 · 보내지 않은 메일의 삭제 확인도 함께 내려간다
+ */
function refreshSelectedFrom(list: readonly MessageSummary[]) {
const current = opened.selected.value
if (!current) return
const fresh = list.find((message) => message.id === current.id)
if (!fresh) return
+ const pendingSend = messagePendingSend(fresh)
const changed =
fresh.isRead !== current.isRead ||
fresh.isFlagged !== current.isFlagged ||
- fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n')
+ fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n') ||
+ !samePendingSend(pendingSend, messagePendingSend(current))
if (changed) {
- opened.selected.value = { ...current, isRead: fresh.isRead, isFlagged: fresh.isFlagged, mailboxIds: fresh.mailboxIds }
+ opened.selected.value = {
+ ...current,
+ isRead: fresh.isRead,
+ isFlagged: fresh.isFlagged,
+ mailboxIds: fresh.mailboxIds,
+ pendingSend,
+ }
}
}
@@ -186,6 +200,30 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
// ── 목록 열기 · 맞추기 ───────────────────────────────
+ /**
+ * 출처의 한 쪽 — 메일함 · 검색은 GET /messages, 예약됨은 GET /scheduled-messages (보낼 시각 차례 — 9단계).
+ * cursor 는 해석하지 않고 그대로 넘긴다
+ */
+ function fetchPage(
+ source: MailListSource,
+ page: { cursor?: string; limit: number },
+ options?: MailRequestOptions,
+ ): Promise> {
+ if (source.kind === 'scheduled') return mailApi.listScheduledMessages(page, options)
+ return mailApi.listMessages({ ...listQueryOf(source), ...page }, options)
+ }
+
+ /** 예약됨 목록의 전체 수를 받았다 — 메일함 패널의 「예약됨」 개수가 이 값을 쓴다 (scheduled.ts) */
+ let onScheduledTotal: ((total: number | null) => void) | null = null
+
+ function watchScheduledTotal(callback: (total: number | null) => void) {
+ onScheduledTotal = callback
+ }
+
+ function reportTotal(source: MailListSource, info: PageInfo) {
+ if (source.kind === 'scheduled') onScheduledTotal?.(info.total)
+ }
+
/**
* 목록 열기 — 첫 페이지를 불러온다. 다른 목록이면 고른 것이 풀린다 (메일함을 바꾸거나 검색하면 선택을 푼다).
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
@@ -206,8 +244,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
try {
- const page = await mailApi.listMessages(
- { ...listQueryOf(next), limit: DEFAULT_PAGE_SIZE },
+ const page = await fetchPage(
+ next,
+ { limit: DEFAULT_PAGE_SIZE },
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
{ silent: keepVisible ? undefined : true },
)
@@ -216,6 +255,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
pageInfo.value = page.pageInfo
listStatus.value = 'ready'
listError.value = null
+ reportTotal(next, page.pageInfo)
} catch (error) {
if (seq !== listSeq || keepVisible) return
listStatus.value = 'error'
@@ -230,6 +270,11 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
await openList({ kind: 'mailbox', mailboxId })
}
+ /** 예약됨 열기 (9단계) — 같은 목록으로 돌아오면 보이던 목록을 둔 채 새로 고친다 (보낸 메일은 저절로 빠진다) */
+ async function openScheduled(): Promise {
+ await openList(SCHEDULED_LIST_SOURCE)
+ }
+
/**
* 검색 결과 열기 (계약 2절 "검색") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL).
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
@@ -263,12 +308,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const seq = listSeq
isLoadingMore.value = true
try {
- const page = await mailApi.listMessages({ ...listQueryOf(source), cursor, limit: DEFAULT_PAGE_SIZE })
+ const page = await fetchPage(source, { cursor, limit: DEFAULT_PAGE_SIZE })
if (seq !== listSeq) return
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
const known = new Set(messages.value.map((message) => message.id))
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
pageInfo.value = page.pageInfo
+ reportTotal(source, page.pageInfo)
} catch {
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
} finally {
@@ -280,7 +326,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 (계약 2절 "새 메일 확인") —
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
- * 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다
+ * 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다.
+ * 예약됨(9단계)은 늘 맞춘다 — 보낸 메일이 저절로 빠진다. 새로 생긴 줄은 새 메일이 아니라 "새 메일 N통" 줄을 띄우지 않는다
*/
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise {
const source = listSource.value
@@ -291,7 +338,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const generation = localGeneration
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
try {
- const page = await mailApi.listMessages({ ...listQueryOf(source), limit }, { silent: true })
+ const page = await fetchPage(source, { limit }, { silent: true })
if (seq !== listSeq || listStatus.value !== 'ready') return
if (generation !== localGeneration) {
if (attempt === 0) await syncVisibleList(options, attempt + 1)
@@ -306,8 +353,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
messages.value = merged.messages
pageInfo.value = merged.pageInfo
refreshSelectedFrom(merged.messages)
+ reportTotal(source, page.pageInfo)
listSyncSeq += 1
- listSync.value = { seq: listSyncSeq, addedAbove: merged.addedAbove }
+ listSync.value = { seq: listSyncSeq, addedAbove: source.kind === 'scheduled' ? 0 : merged.addedAbove }
} catch {
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
}
@@ -351,11 +399,13 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
neighborAfterRemoval,
openList,
openMailbox,
+ openScheduled,
openSearch,
rememberSearchOrigin,
refresh,
loadMore,
syncVisibleList,
+ watchScheduledTotal,
reset,
}
}
diff --git a/frontend/src/stores/mail/message.ts b/frontend/src/stores/mail/message.ts
index 771d092..afd1a2e 100644
--- a/frontend/src/stores/mail/message.ts
+++ b/frontend/src/stores/mail/message.ts
@@ -14,6 +14,8 @@ export function createOpenedMessage() {
const selected = ref(null)
const selectedStatus = ref('idle')
const selectedError = ref(null)
+ /** 외부 이미지를 보이는 본문을 받는 중인 메일 — 읽기 화면의 [이미지 보기]가 기다린다 (9단계) */
+ const imagesLoadingId = ref(null)
/** 메일을 빠르게 바꿀 때 늦게 온 본문 응답이 덮어쓰지 않게 */
let detailSeq = 0
@@ -22,6 +24,11 @@ export function createOpenedMessage() {
return detailSeq
}
+ /** 지금 순번 — 올리지 않고 읽는다 (이미지 보기처럼 같은 메일을 다시 받을 때 그사이 다른 메일을 열었는지 본다) */
+ function currentDetailSeq(): number {
+ return detailSeq
+ }
+
function isCurrentDetail(seq: number): boolean {
return seq === detailSeq
}
@@ -32,9 +39,20 @@ export function createOpenedMessage() {
selected.value = null
selectedStatus.value = 'idle'
selectedError.value = null
+ imagesLoadingId.value = null
}
- return { selectedId, selected, selectedStatus, selectedError, nextDetailSeq, isCurrentDetail, closeMessage }
+ return {
+ selectedId,
+ selected,
+ selectedStatus,
+ selectedError,
+ imagesLoadingId,
+ nextDetailSeq,
+ currentDetailSeq,
+ isCurrentDetail,
+ closeMessage,
+ }
}
export type OpenedMessage = ReturnType
@@ -78,6 +96,7 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
opened.selected.value = null
opened.selectedStatus.value = 'loading'
opened.selectedError.value = null
+ opened.imagesLoadingId.value = null
try {
const detail = await mailApi.getMessage(id, { silent: true })
if (!opened.isCurrentDetail(seq)) return
@@ -91,6 +110,39 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
}
}
+ /**
+ * [이미지 보기] — 열린 메일을 `images=show` 로 다시 받아 본문만 바꾼다 (9단계 "외부 이미지 보기" — 그 메일만, 다시 열면 설정대로).
+ * 그사이 다른 메일을 열었거나 닫았으면(순번이 바뀜) 늦은 응답을 버린다. 읽음 · 중요 · 메일함 표시는 지금 값 그대로 둔다.
+ * 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다. 바꿨으면 true
+ */
+ async function showImages(): Promise {
+ const current = opened.selected.value
+ if (!current || current.imagesShown || opened.imagesLoadingId.value === current.id) return false
+ const id = current.id
+ const seq = opened.currentDetailSeq()
+ opened.imagesLoadingId.value = id
+ try {
+ const detail = await mailApi.getMessage(id, { showImages: true })
+ const latest = opened.selected.value
+ if (!opened.isCurrentDetail(seq) || latest?.id !== id) return false
+ opened.selected.value = {
+ ...latest,
+ htmlBody: detail.htmlBody,
+ textBody: detail.textBody,
+ hasBlockedImages: detail.hasBlockedImages,
+ imagesShown: detail.imagesShown === true,
+ isHtmlBodyOmitted: detail.isHtmlBodyOmitted,
+ isBodyTruncated: detail.isBodyTruncated,
+ attachments: detail.attachments,
+ }
+ return true
+ } catch {
+ return false
+ } finally {
+ if (opened.imagesLoadingId.value === id) opened.imagesLoadingId.value = null
+ }
+ }
+
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
async function toggleFlag(id: string): Promise {
const current =
@@ -131,5 +183,5 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
return true
}
- return { markRead, openMessage, toggleFlag, moveMessage }
+ return { markRead, openMessage, showImages, toggleFlag, moveMessage }
}
diff --git a/frontend/src/stores/mail/scheduled.ts b/frontend/src/stores/mail/scheduled.ts
new file mode 100644
index 0000000..10edba0
--- /dev/null
+++ b/frontend/src/stores/mail/scheduled.ts
@@ -0,0 +1,156 @@
+// 메일 스토어 모듈 — 예약됨 개수 · 붙잡은 발송 취소 · 엔진이 풀었는지 다시 보기 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, mail.store.ts 가 조립한다)
+// - 메일함 패널의 「예약됨」(있을 때만)은 GET /scheduled-messages?limit=1 의 total 이다. 다시 묻는 때(계약): 메일 화면에 들어올 때 ·
+// 보내거나 취소한 뒤 · 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 (MailLayout · 쓰기 화면이 부른다). 예약됨 목록을 받으면 그 total 로도 맞춘다.
+// 못 받으면 알리지 않고 지난 값을 둔다 (보조 정보 — 사용량과 같은 결). 같은 응답의 첫 줄(가장 이른 예약)은 nextScheduled — 풀릴 때를 지켜본다
+// - 붙잡은 발송 취소(보내기 취소 · 예약 취소 · [고치기] · 보내지 않은 메일 [삭제])는 한 길이다 — 취소한 메일은 임시보관함으로 옮겨진다(같은 id).
+// 같은 메일의 취소는 한 번에 하나다 — 이어 누른 두 번째가 첫 번째의 결과를 기다린다(두 번째 요청이 "이미 보냈다"(MAIL_014)로 거짓 알림을 띄우지 않게)
+// - 엔진이 붙잡은 발송을 풀어도(보냄) 새 메일 확인 · 실시간 알림의 state 는 바뀌지 않는다 — 그래서 보낼 시각이 지나면 보이는 목록 · 열린 메일 ·
+// 예약됨 수를 다시 본다(refreshPendingSends — 때는 usePendingSendRefresh 가 정한다). 취소가 MAIL_014(이미 보냄)이면 바로 다시 본다
+import { ref } from 'vue'
+
+import { toApiError } from '@/composables/useApi'
+import type { MailRequestOptions } from '@/composables/useMail'
+import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
+import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
+import { sameListSource } from '@/shared/utils/mail-list-source'
+import { messagePendingSend, pendingSendOf, samePendingSend } from '@/shared/utils/pending-send'
+import type { ListState } from './list'
+import type { MailboxesState } from './mailboxes'
+import type { OpenedMessage } from './message'
+import type { MailApi } from './types'
+
+interface ScheduledDeps {
+ mailApi: MailApi
+ mailboxes: MailboxesState
+ list: ListState
+ opened: OpenedMessage
+ /** 지금 세션의 세대 (authStore.generation) — 기다리는 사이 세션이 바뀌었으면 늦은 응답으로 목록을 고치지 않는다 */
+ sessionGeneration: () => number
+}
+
+export function createScheduledState({ mailApi, mailboxes, list, opened, sessionGeneration }: ScheduledDeps) {
+ /** 예약한 메일 수 — 0 이면 패널에 「예약됨」을 그리지 않는다 (예약됨을 보는 동안은 그린다 — MailboxTree) */
+ const scheduledCount = ref(0)
+ /** 가장 이른 예약 — 개수를 물은 응답의 첫 줄. 그 시각이 지나면 예약됨 수를 다시 묻는다 (usePendingSendRefresh) */
+ const nextScheduled = ref(null)
+ let countSeq = 0
+ /** 메일마다 진행 중인 취소 — 같은 메일의 두 번째 취소는 이것을 기다린다 */
+ const cancels = new Map>()
+
+ /** 예약한 메일 수를 다시 묻는다 — 실패는 알리지 않고 지난 값을 둔다 */
+ async function loadScheduledCount(): Promise {
+ countSeq += 1
+ const seq = countSeq
+ try {
+ const page = await mailApi.listScheduledMessages({ limit: SCHEDULED_COUNT_PAGE_SIZE }, { silent: true })
+ if (seq !== countSeq) return
+ scheduledCount.value = page.pageInfo.total ?? page.items.length
+ nextScheduled.value = page.items[0] ?? null
+ } catch {
+ // 보조 정보 — 다음에 물을 때 맞춘다
+ }
+ }
+
+ // 예약됨 목록을 받았다 — 그 total 이 더 새롭다 (앞서 보낸 개수 질문의 응답은 버린다. 가장 이른 예약은 그 목록의 첫 줄로 지켜본다)
+ list.watchScheduledTotal((total) => {
+ if (total === null) return
+ countSeq += 1
+ scheduledCount.value = total
+ })
+
+ /**
+ * 열린 메일의 붙잡은 발송을 다시 본다 — 알리지 않는다(silent). 붙잡은 발송이 없는 메일이면 묻지 않는다.
+ * 그사이 다른 메일을 열었으면(상세 순번) 늦은 응답을 버린다. 본문 · 읽음 표시는 그대로 두고 pendingSend 만 맞춘다
+ */
+ async function refreshOpenedPendingSend(): Promise {
+ const current = opened.selected.value
+ if (!current || messagePendingSend(current) === null) return
+ const id = current.id
+ const seq = opened.currentDetailSeq()
+ try {
+ const fresh = await mailApi.getMessage(id, { silent: true })
+ const latest = opened.selected.value
+ if (!opened.isCurrentDetail(seq) || latest?.id !== id) return
+ const pending = pendingSendOf(fresh.pendingSend)
+ if (!samePendingSend(pending, messagePendingSend(latest))) opened.selected.value = { ...latest, pendingSend: pending }
+ } catch {
+ // 다음에 다시 볼 때 맞춘다
+ }
+ }
+
+ /**
+ * 엔진이 붙잡은 발송을 풀었는지 다시 본다 — 보이는 목록(붙잡은 메일이 보이면 검색 결과도) · 열린 메일 · 예약됨 수.
+ * 모두 알리지 않는다. 다 끝나면 돌아온다
+ */
+ async function refreshPendingSends(): Promise {
+ const includeSearch = list.messages.value.some((message) => messagePendingSend(message) !== null)
+ await Promise.all([list.syncVisibleList({ includeSearch }), refreshOpenedPendingSend(), loadScheduledCount()])
+ }
+
+ /** 같은 메일의 취소가 진행 중인가 — 화면은 두 번째 누름에 아무것도 하지 않는다 */
+ function isCancellingPendingSend(id: string): boolean {
+ return cancels.has(id)
+ }
+
+ async function sendCancel(id: string, options?: MailRequestOptions): Promise {
+ const session = sessionGeneration()
+ const source = list.listSource.value
+ let result: ScheduledMessageCancel
+ try {
+ result = await mailApi.cancelScheduledMessage(id, options)
+ } catch (error) {
+ // 이미 보냈다(엔진이 풀었다) — 화면에 남은 "보내는 중" · 예약 표시를 바로 다시 맞춘다
+ if (sessionGeneration() === session && toApiError(error).code === 'MAIL_014') void refreshPendingSends()
+ throw error
+ }
+ // 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다 (스토어도 이미 비웠다)
+ if (sessionGeneration() !== session) return result
+ // 다른 목록으로 옮겨 갔으면 그 목록에는 이 메일이 없다 — 빼지 않고 서버 기준으로 맞추기만 한다
+ if (sameListSource(list.listSource.value, source) && (source?.kind === 'scheduled' || source?.kind === 'mailbox')) {
+ list.removeFromList(new Set([id]))
+ }
+ const current = opened.selected.value
+ if (current?.id === id && sameListSource(list.listSource.value, source)) {
+ opened.selected.value = { ...current, pendingSend: null }
+ }
+ void list.syncVisibleList({ includeSearch: true })
+ mailboxes.syncCounts()
+ void loadScheduledCount()
+ return result
+ }
+
+ /**
+ * 붙잡은 발송을 취소한다 (DELETE /scheduled-messages/{id}) — 그 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id → draftId).
+ * 예약됨 · 메일함 목록에서는 뺀다(보낸편지함을 떠났다 — 임시보관함이면 다시 맞추며 들어온다), 열린 메일이면 붙잡은 발송 표시를 내린다.
+ * 개수 · 예약됨 수를 다시 맞춘다. 같은 메일의 취소가 진행 중이면 새로 보내지 않고 그 결과를 함께 기다린다.
+ * 실패(이미 보냄 409 MAIL_014 · 없음 404 등)는 예외가 그대로 올라간다 — 알림은 인터셉터가 띄운다(options.silent 를 주지 않으면)
+ */
+ function cancelPendingSend(id: string, options?: MailRequestOptions): Promise {
+ const running = cancels.get(id)
+ if (running) return running
+ const cancel = sendCancel(id, options).finally(() => {
+ if (cancels.get(id) === cancel) cancels.delete(id)
+ })
+ cancels.set(id, cancel)
+ return cancel
+ }
+
+ function reset() {
+ countSeq += 1
+ scheduledCount.value = 0
+ nextScheduled.value = null
+ cancels.clear()
+ }
+
+ return {
+ scheduledCount,
+ nextScheduled,
+ loadScheduledCount,
+ cancelPendingSend,
+ isCancellingPendingSend,
+ refreshPendingSends,
+ reset,
+ }
+}
+
+export type ScheduledState = ReturnType
diff --git a/frontend/src/stores/mail/selection.ts b/frontend/src/stores/mail/selection.ts
index bf48dc3..bf954b7 100644
--- a/frontend/src/stores/mail/selection.ts
+++ b/frontend/src/stores/mail/selection.ts
@@ -1,6 +1,8 @@
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
+// 아직 보내지 않은 메일(붙잡은 발송 — 보내는 중 · 예약, 9단계)은 고를 수 없다: 옮기거나 지워도 엔진은 그 시각에 보낸다 — 취소해야만 보낸편지함을 떠난다.
+// 하나 고르기 · 범위 · 모두 고르기가 모두 건너뛰고, 고른 메일(checkedMessages — 여러 통 처리가 쓰는 것)에도 들지 않는다 (pending-send.ts 의 isHeldMessage)
import { computed, shallowRef, watch } from 'vue'
import type { MessageSummary } from '@/shared/types/mail'
@@ -11,6 +13,7 @@ import {
toggleChecked,
type CheckAllState,
} from '@/shared/utils/mail-selection'
+import { isHeldMessage } from '@/shared/utils/pending-send'
import type { ListState } from './list'
export function createSelection(list: Pick) {
@@ -19,21 +22,22 @@ export function createSelection(list: Pick)
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
let checkAnchorId: string | null = null
+ /** 고를 수 있는 메일 id — 목록 순서 그대로, 붙잡은 발송은 뺀다 */
+ const selectableIds = computed(() =>
+ list.messages.value.filter((message) => !isHeldMessage(message)).map((message) => message.id),
+ )
const checkedCount = computed(() => checkedIds.value.size)
- /** 고른 메일 — 목록 순서 그대로 */
+ /** 고른 메일 — 목록 순서 그대로 (붙잡은 발송은 고른 것에 남아 있어도 넣지 않는다 — 여러 통 처리가 이것만 보낸다) */
const checkedMessages = computed(() =>
- checkedIds.value.size === 0 ? [] : list.messages.value.filter((message) => checkedIds.value.has(message.id)),
- )
- /** 표 머리 고르기 상자의 상태 */
- const checkState = computed(() =>
- checkAllState(
- checkedIds.value,
- list.messages.value.map((message) => message.id),
- ),
+ checkedIds.value.size === 0
+ ? []
+ : list.messages.value.filter((message) => checkedIds.value.has(message.id) && !isHeldMessage(message)),
)
+ /** 표 머리 고르기 상자의 상태 — 고를 수 있는 메일 기준 */
+ const checkState = computed(() => checkAllState(checkedIds.value, selectableIds.value))
- function visibleIds(): string[] {
- return list.messages.value.map((message) => message.id)
+ function isSelectable(id: string): boolean {
+ return selectableIds.value.includes(id)
}
function clearChecked() {
@@ -41,32 +45,31 @@ export function createSelection(list: Pick)
checkAnchorId = null
}
- /** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭 */
+ /** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭. 고를 수 없는 메일이면 아무것도 하지 않는다 */
function toggleCheck(id: string) {
+ if (!isSelectable(id)) return
checkedIds.value = toggleChecked(checkedIds.value, id)
checkAnchorId = id
}
- /** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 */
+ /** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 (고를 수 없는 메일은 건너뛴다) */
function checkRangeTo(id: string) {
- checkedIds.value = checkRange(checkedIds.value, visibleIds(), checkAnchorId, id)
+ if (!isSelectable(id)) return
+ checkedIds.value = checkRange(checkedIds.value, selectableIds.value, checkAnchorId, id)
checkAnchorId = id
}
- /** 표 머리 상자 — 불러온 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
+ /** 표 머리 상자 — 고를 수 있는 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
function toggleCheckAll() {
if (checkState.value === 'all') clearChecked()
- else checkedIds.value = new Set(visibleIds())
+ else checkedIds.value = new Set(selectableIds.value)
}
- // 목록에서 빠진 메일은 고른 것에서도 뺀다
+ // 목록에서 빠진 메일 · 고를 수 없게 된 메일은 고른 것에서도 뺀다
watch(
- list.messages,
- (messages) => {
- const next = pruneChecked(
- checkedIds.value,
- messages.map((message) => message.id),
- )
+ selectableIds,
+ (ids) => {
+ const next = pruneChecked(checkedIds.value, ids)
if (next !== checkedIds.value) checkedIds.value = next
},
{ flush: 'sync' },
diff --git a/frontend/src/stores/toast.store.spec.ts b/frontend/src/stores/toast.store.spec.ts
index 791cfac..4238339 100644
--- a/frontend/src/stores/toast.store.spec.ts
+++ b/frontend/src/stores/toast.store.spec.ts
@@ -232,4 +232,68 @@ describe('toast.store', () => {
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리했습니다 — 다시 시도해 주세요.' })
})
})
+
+ describe('마감 시각이 있는 알림 — 9단계 [보내기 취소] (디자인 원본 MailSendLater)', () => {
+ it('그 시각에 닫힌다 — 마우스를 올리거나 초점이 들어와도 멈추지 않는다', () => {
+ const store = useToastStore()
+ const expiresAt = Date.now() + 10_000
+ const id = store.show({ kind: 'success', message: '메일을 보냈습니다.', action: undo, expiresAt })
+
+ store.hold(id, 'pointer')
+ store.hold(id, 'focus')
+ vi.advanceTimersByTime(9_999)
+ expect(messages()).toEqual(['메일을 보냈습니다.'])
+ vi.advanceTimersByTime(1)
+ expect(messages()).toEqual([])
+ })
+
+ it('놓아도 처음부터 다시 세지 않는다 — 단추가 있어도 10초가 아니라 그 시각', () => {
+ const store = useToastStore()
+ const id = store.show({ kind: 'error', message: '보냈지만 1명은 받지 못합니다', action: undo, expiresAt: Date.now() + 5_000 })
+
+ vi.advanceTimersByTime(3_000)
+ store.hold(id, 'pointer')
+ store.release(id, 'pointer')
+ vi.advanceTimersByTime(2_000)
+ expect(messages()).toEqual([])
+ })
+
+ it('알림이 넘쳐도 다른 알림부터 닫는다 — 그 시각이 지나면 되돌릴 수 없다', () => {
+ const store = useToastStore()
+ store.show({ kind: 'success', message: '메일을 보냈습니다.', action: undo, expiresAt: Date.now() + 10_000 })
+ store.show({ kind: 'success', message: '하나' })
+ store.show({ kind: 'success', message: '둘' })
+ store.show({ kind: 'success', message: '셋' })
+
+ expect(messages()).toEqual(['메일을 보냈습니다.', '둘', '셋'])
+ })
+
+ it('진행 알림에는 마감이 없다', () => {
+ const store = useToastStore()
+ store.show({ kind: 'progress', message: '비우는 중', expiresAt: Date.now() + 1_000 })
+
+ vi.advanceTimersByTime(60_000)
+ expect(store.toasts.map((toast) => toast.expiresAt)).toEqual([null])
+ })
+
+ it('ToastHost — 둘째 줄에 남은 시간 막대( |