fix(frontend): 14단계 화면을 실사용 시험 · 사용자 관점 점검대로 고친다

- 분할 보기에서 대화 줄을 고르면 도구 줄이 두 줄로 넘치던 것, 목록 머리의
  안 읽은 수가 1440 에서도 늘 잘리던 것
- 묶은 줄에 한 일의 알림은 "{메일함}에 있는 메일 N통을 …"(줄의 통 수는
  대화 전체라서), 되돌리기 알림은 되돌린 메일함을 말한다
- 같은 대화 칸: 펼친 줄의 첨부 수 안내, 우리 원문 인용 머리부터 뺀 미리
  보기, 보내기를 기다리는 메일은 줄 끝에
- 검색 준비 중 · 결과 없음 · 하루짜리 기간 · 내 이름 조건의 글, 예약 시각의
  띄어쓰기, 이름 맞추기(대화로 묶기 · 내용에 든 말 · 표 머리 참여자)
- 다른 탭 · PC 에서 바꾼 설정을 탭으로 돌아올 때 다시 받는다(60초 지났을 때)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 01:15:43 +09:00
parent 0090a161ec
commit 9ba4bc1a41
39 changed files with 1024 additions and 93 deletions
@@ -2,7 +2,8 @@
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
// 스토어가 대화를 다시 받아 이 줄을 뺀다. 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
@@ -15,7 +16,9 @@ import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
import { useMailPrint } from '@/composables/useMailPrint'
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount } from '@/shared/utils/format'
import { formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
@@ -47,6 +50,10 @@ const failure = computed(() => (props.detail?.status === 'error' ? props.detail.
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
const missing = computed(() => failure.value?.code === 'RES_001')
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
const attachmentCount = computed(() =>
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
)
const bodyTitle = computed(() => MAIL_CONVERSATION_TEXT.bodyTitle(props.row.sender.name, props.row.time))
/** 본문 안에서 누른 키 — Ctrl+P(맥 ⌘P)면 브라우저 인쇄 대신 이 메일의 인쇄 화면 */
@@ -97,6 +104,18 @@ function onFrameKeydown(event: KeyboardEvent) {
:title="bodyTitle"
@frame-keydown="onFrameKeydown"
/>
<p
v-if="attachmentCount > 0"
class="conversation-panel__attachments"
data-conversation-attachments
>
<AppIcon
name="paperclip"
:size="14"
class="conversation-panel__attachments-icon"
/>
<span>{{ MAIL_CONVERSATION_TEXT.attachmentsNote(formatCount(attachmentCount)) }}</span>
</p>
</template>
<p
v-else-if="failure"
@@ -211,6 +230,24 @@ function onFrameKeydown(event: KeyboardEvent) {
min-width: 0;
}
/* 첨부 안내 — 머리 줄(받는 사람 · 제목)과 같은 12.5px 회색, 아이콘은 첫 줄에 */
.conversation-panel__attachments {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
word-break: keep-all;
overflow-wrap: anywhere;
}
.conversation-panel__attachments-icon {
flex: 0 0 auto;
margin-top: var(--space-2);
color: var(--color-text-muted);
}
.conversation-panel__loading {
display: flex;
flex-direction: column;
@@ -3,6 +3,8 @@
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(groupByConversation)을 PATCH 한다 — 화면을 먼저 바꾸고(메일함 화면이 바로 묶거나 푼다)
// 끝내 실패하면 서버가 확인한 값으로 되돌린다(mail-settings.store — 칸마다 저장은 한 번에 하나, 알림은 인터셉터).
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(36×36 — 이름은 title · 화면 낭독, MailboxEmptyButton 과 같은 결), 목록 보기는 아이콘 + 이름.
// 이름까지 보이면 99px 이라 1440 분할 보기에서도 "받은편지함" 뒤 개수 글 자리가 11px 만 남는다(2026-11-06 쟀다) — 그래서 아이콘만 두고
// 설정 › 보내기 · 읽기의 설명이 "말풍선 모양 [대화로 묶기] 단추"라고 모양을 말한다(MAIL_SETTINGS_TEXT.groupHint)
// 켠 모양은 보기 전환의 고른 칸과 같다(청록 옅은 바탕 · 진한 청록 글자 — 테두리는 그대로, 보드의 #9FD3D7 테두리는 새 색이라 두지 않았다)
import { computed } from 'vue'
@@ -269,6 +269,7 @@ watch(
<MailSearchEmpty
v-else-if="mailStore.listStatus === 'ready' && search"
:query="search.q"
:has-conditions="searchConditions !== null"
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
:word-limit="indexing && !searchTooLong"
:too-many-words="searchTooLong"
+41 -10
View File
@@ -2,6 +2,9 @@
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](14단계) · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 개수(14단계 — 디자인 원본 ListThreads14): 묶지 않았으면 "메일 9통, 안 읽은 메일 2통"(메일함의 통 수), 묶었으면 "대화 6개, 안 읽은 메일 2통"
// (대화 수는 목록의 total — 모르면 그 부분을 뺀다). 안 읽은 메일이 없으면 뒤를 뺀다.
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통"(묶었으면 "대화 6개")을 보이고 전체 글은 title · 화면 낭독에 둔다.
// 칸 사이도 조금 좁힌다(이름↔개수 6 · 머리↔단추 4 · 단추 사이 4) — 1440 의 목록 칸(384)에서 "받은편지함" 뒤 "안 읽은 메일 99통"(86px)까지 다 보이게
// (2026-11-06 실사용 시험 — 원래 자리 74px 로 "메일 6통, 안 …"처럼 안 읽은 수가 늘 잘렸다)
// [대화로 묶기]는 메일함 목록(묶었든 아니든)에서만 — 검색 결과 · 예약됨은 묶지 않는다. 설정을 받는 동안은 숨기고(그사이 고른 것은 받기가 덮는다),
// 받았거나 받지 못했으면 보인다 — 받지 못했으면 짐작 위에서 고르고 그 칸만 저장한다 (mail-settings.store — 리뷰 L4)
// 예약됨(9단계)은 "예약됨" · "2통 · 보낼 시각 차례" (디자인 원본 MailSendLater)
@@ -16,7 +19,7 @@ import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { formatCount } from '@/shared/utils/format'
import { listHeadCountText } from '@/shared/utils/mail-conversation-list'
import { listHeadCountText, listHeadShortCountText } from '@/shared/utils/mail-conversation-list'
import { isScheduledSource, mailboxListIdOf } from '@/shared/utils/mail-list-source'
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
@@ -28,7 +31,7 @@ interface Props {
variant?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
variant: 'split',
})
@@ -37,18 +40,25 @@ const settingsStore = useMailSettingsStore()
const scheduled = computed(() => isScheduledSource(mailStore.listSource))
const title = computed(() => (scheduled.value ? SEND_LATER_TEXT.mailbox : (mailStore.currentMailbox?.name ?? '메일함')))
/** 메일함 목록의 개수 칸 — 묶었으면 대화 수(목록의 total), 아니면 메일함의 통 수. 예약됨 · 메일함을 모르면 null */
const countInput = computed(() => {
const box = scheduled.value ? null : mailStore.currentMailbox
if (!box) return null
return mailStore.isConversationList
? { grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount }
: { grouped: false, total: box.totalCount, unread: box.unreadCount }
})
const countText = computed(() => {
if (scheduled.value) {
if (mailStore.listStatus !== 'ready') return ''
return SEND_LATER_TEXT.listCount(formatCount(mailStore.pageInfo?.total ?? mailStore.messages.length))
}
const box = mailStore.currentMailbox
if (!box) return ''
if (mailStore.isConversationList) {
return listHeadCountText({ grouped: true, total: mailStore.pageInfo?.total ?? null, unread: box.unreadCount })
}
return listHeadCountText({ grouped: false, total: box.totalCount, unread: box.unreadCount })
return countInput.value ? listHeadCountText(countInput.value) : ''
})
/** 보이는 개수 — 분할 보기(좁은 머리)의 메일함 목록은 줄인 글(안 읽은 메일이 있으면 그 수만), 그 밖은 전체 글 */
const shownCountText = computed(() =>
props.variant === 'split' && countInput.value ? listHeadShortCountText(countInput.value) : countText.value,
)
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
const groupable = computed(
() =>
@@ -72,11 +82,19 @@ const emptiable = computed(() => {
>
{{ title }}
</h1>
<!-- 분할 보기 칸에서는 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다 (글은 그대로 있어 화면 낭독은 다 읽는다) -->
<!--
분할 보기는 줄인 글을 보이고 전체 글은 title · 화면 낭독에 둔다. 그래도 좁으면 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다
-->
<span
class="list-header__count"
:title="countText || undefined"
>{{ countText }}</span>
>
<template v-if="shownCountText !== countText">
<span aria-hidden="true">{{ shownCountText }}</span>
<span class="visually-hidden">{{ countText }}</span>
</template>
<template v-else>{{ countText }}</template>
</span>
</div>
<div class="list-header__end">
<MailConversationToggle
@@ -118,6 +136,19 @@ const emptiable = computed(() => {
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
}
/* 분할 보기의 목록 칸(384) — 개수 글이 들 자리를 내려고 칸 사이를 조금 좁힌다 (머리말) */
.list-header--split {
gap: var(--space-4);
}
.list-header--split .list-header__heading {
gap: var(--space-6);
}
.list-header--split .list-header__end {
gap: var(--space-4);
}
/*
* 이름 · 개수 — 오른쪽 단추들이 남긴 자리를 쓴다. 좁으면 개수가 먼저 줄고(사라질 때까지) 그다음에 이름이 준다
* (14단계 — [대화로 묶기]가 더해져 분할 보기 칸에서 이름이 1px 모자라 "색인 …"으로 잘렸다)
@@ -248,6 +248,13 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
expect(panel.text()).toContain(
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
)
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
'이름이나 메일 주소',
'이름이나 메일 주소',
'제목에 든 말',
'내용에 든 말',
])
expect(document.activeElement).toBe(field(wrapper, 'from').element)
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 조건이 걸린 검색 결과의 조건 줄 (디자인 원본 Search14 "조건으로 찾음") — 찾을 곳 줄 아래 한 줄(최소 40)
// "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기](36 — 안내 줄 단추의 밀도 예외 --size-notice-action).
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(mail-search.ts)
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"),
// 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (mail-search.ts)
// [조건 지우기]는 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
import { computed } from 'vue'
@@ -9,6 +10,7 @@ import { useClock } from '@/composables/useClock'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { SEARCH_CONDITION_LINE_TEXT } from '@/shared/constants/mail-search'
import { searchConditionsSummary, type SearchConditions } from '@/shared/utils/mail-search'
import { useAuthStore } from '@/stores/auth.store'
interface Props {
conditions: SearchConditions
@@ -17,9 +19,10 @@ interface Props {
const props = defineProps<Props>()
const clock = useClock()
const authStore = useAuthStore()
const { clearConditions } = useMailSearchNavigation()
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now())))
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now()), authStore.user))
</script>
<template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · "다른 말로 찾거나 조건을 줄여 보세요."
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
// 검색어와 조건의 낱말이 한도를 넘는 주소(손으로 고친 주소 — 14단계)면 찾지 않았다 — "찾을 말이 너무 많습니다"와 줄이는 방법 (tooManyWords)
@@ -13,6 +14,8 @@ import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
interface Props {
/** 검색어 — 조건만으로 찾았으면 빈 글 */
query: string
/** 걸린 조건이 있다 — 없으면 조건을 줄이라고 하지 않는다 */
hasConditions?: boolean
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
offerAll: boolean
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
@@ -26,6 +29,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
hasConditions: false,
wordLimit: false,
tooManyWords: false,
})
@@ -36,7 +40,8 @@ const title = computed(() =>
)
const hint = computed(() => {
if (props.tooManyWords) return SEARCH_NOTICE_TEXT.tooManyWordsHint(MAIL_SEARCH_MAX_WORDS)
return props.query ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyConditionsHint
if (!props.query) return MAIL_SEARCH_TEXT.emptyConditionsHint
return props.hasConditions ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyWordsHint
})
</script>
@@ -164,7 +164,7 @@ describe('여러 통 처리 도구 줄', () => {
expect(store.checkedCount).toBe(1)
})
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 그 메일함 이름으로 알린다', async () => {
const { wrapper, store } = await mountView()
store.toggleCheck('m1')
store.toggleCheck('m3')
@@ -186,6 +186,31 @@ describe('여러 통 처리 도구 줄', () => {
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
// 한 메일함으로 되돌렸다 — 어디로 되돌렸는지 말한다 (2026-11-06 실사용 시험)
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
})
it('여러 메일함으로 되돌렸으면 메일함 이름 없이 "메일 N통을 되돌렸습니다."', async () => {
const { wrapper, store } = await mountView({
messages: [summary('m1'), summary('m2', { mailboxIds: ['sent'] }), summary('m3')],
})
store.toggleCheck('m1')
store.toggleCheck('m2')
await flushPromises()
await wrapper.get('[data-tool="move"]').trigger('click')
await flushPromises()
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')?.trigger('click')
await flushPromises()
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches().slice(1)).toEqual([
{ ids: ['m1'], mailboxId: 'inbox' },
{ ids: ['m2'], mailboxId: 'sent' },
])
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
})
@@ -398,7 +423,7 @@ describe('여러 통 처리 도구 줄', () => {
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
expect(toastTexts()).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
})
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
@@ -8,7 +8,9 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다.
// 분할 보기는 보이는 글만 "N개 선택됨"으로 줄이고 화면 낭독은 "대화 N개 선택됨"을 읽는다 — 목록 칸(384)에서 단추 일곱(280)과 한 줄에 들게
// (2026-11-06 실사용 시험: "대화 1개 선택됨" 88px 이면 도구가 두 줄 · 97px 로 넘쳤다. "N개 선택됨"은 세 자리까지 79px — 1440 의 자리 83px)
import { computed, nextTick, ref, watch } from 'vue'
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
@@ -76,6 +78,12 @@ const countText = computed(() => {
const count = formatCount(mailStore.checkedCount)
return mailStore.isConversationList ? MAIL_CONVERSATION_LIST_TEXT.selected(count) : MAIL_BULK_TEXT.selected(count)
})
/** 분할 보기에서 보이는 고른 수 — 대화 목록만 줄인다("1개 선택됨"). 줄이지 않으면 null (화면 낭독은 늘 countText) */
const shortCountText = computed(() =>
props.variant === 'split' && mailStore.isConversationList
? MAIL_CONVERSATION_LIST_TEXT.selectedShort(formatCount(mailStore.checkedCount))
: null,
)
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
const leadingTools = computed<LeadingTool[]>(() => [
@@ -167,7 +175,13 @@ watch(disabledTools, (disabled) => {
:id="titleId"
class="bulk-bar__count"
>
{{ countText }}
<template v-if="shortCountText">
<span aria-hidden="true">{{ shortCountText }}</span>
<span class="visually-hidden">{{ countText }}</span>
</template>
<template v-else>
{{ countText }}
</template>
</h1>
<div
ref="toolsRef"
@@ -291,6 +291,42 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
})
describe('줄의 차례 · 미리 보기 (2026-11-06 실사용 시험)', () => {
it('보내기를 기다리는 메일은 받은 줄의 맨 끝 — 서버가 저장한 시각 차례로 주어도 그 뒤에 온 답장보다 앞에 서지 않는다', async () => {
const sendAt = new Date(2026, 10, 6, 23, 38).toISOString()
const scheduled = summary('s1', {
mailboxIds: ['sent'],
from: USER,
receivedAt: at(2026, 9, 29, 10, 0),
pendingSend: { kind: 'scheduled', sendAt },
})
serve({ conversation: () => page([ORIGINAL, scheduled, REPLY, CURRENT]) })
const { wrapper } = await mountReader()
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
'm1',
'm2',
'm3',
's1',
])
})
it('미리 보기에서 우리 원문 인용("> 원본 메일")부터 뒤는 뺀다 — 남는 글이 없으면 제목', async () => {
serve({
conversation: () =>
page([
{ ...ORIGINAL, preview: '도윤님, 금요일까지 정하겠습니다. > 원본 메일 > 보낸 사람: 이도윤' },
{ ...REPLY, preview: '> 원본 메일 > 보낸 사람: 김아라 > 날짜: 2026. 10. 27.' },
CURRENT,
]),
})
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'm1').get('.conversation-row__preview').text()).toBe('도윤님, 금요일까지 정하겠습니다.')
expect(rowOf(wrapper, 'm2').get('.conversation-row__preview').text()).toBe(REPLY.subject)
})
})
describe('펼치기 · 접기', () => {
it('다른 줄을 누르면 그 자리에서 펼친다 — 받는 사람 · 제목 · 본문(강조 쿼리 없이) · [답장] · [이 메일만 열기], 안 읽은 메일이면 읽음으로', async () => {
serve()
@@ -363,6 +399,40 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
expect(rowOf(wrapper, 's1').get('[data-conversation-open]').attributes('href')).toBe('/mail/sent/s1')
})
it('첨부가 있으면 본문 아래 · [답장] 위에 "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." — 본문 그림은 세지 않는다', async () => {
serve({
detail: (request) => {
const id = request.url.split('/').pop() ?? ''
const base = THREAD.find((item) => item.id === id) ?? summary(id)
if (id !== 'm2') return ok(detail(base))
return ok(
detail(base, {
attachments: [
{ id: 'a1', name: '견적_수정안.csv', type: 'text/csv', size: 44 },
{ id: 'a2', name: '단가표.xlsx', type: 'application/vnd.ms-excel', size: 9_000 },
{ id: 'i1', name: 'logo.png', type: 'image/png', size: 1_200, isInline: true },
],
}),
)
},
})
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('button').trigger('click')
await rowOf(wrapper, 'm1').get('button').trigger('click')
await flushPromises()
const panel = rowOf(wrapper, 'm2').get('.conversation-panel')
const note = panel.get('[data-conversation-attachments]')
expect(note.text()).toBe('첨부 파일 2개는 [이 메일만 열기]를 누르면 볼 수 있습니다.')
// 본문 아래 · [답장] 위
const order = [...panel.element.children].map((child) => child.className.split(' ')[0])
expect(order.indexOf('conversation-panel__attachments')).toBe(order.indexOf('mail-body-frame') + 1)
expect(order.indexOf('conversation-panel__actions')).toBe(order.indexOf('conversation-panel__attachments') + 1)
// 첨부가 없는 메일에는 없다
expect(rowOf(wrapper, 'm1').find('[data-conversation-attachments]').exists()).toBe(false)
})
it('본문을 못 받으면 줄 안에 사전 문구 + [다시 시도] — 알리지 않는다', async () => {
let failing = true
serve({ detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))) })
@@ -380,7 +380,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 받은편지함으로 되돌렸습니다.')
})
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
@@ -288,11 +288,11 @@ describe('MessageReader — 9단계', () => {
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
it('머리 아래 "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
const notice = wrapper.get('[data-notice="scheduled"]')
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)} 에 보낼 메일입니다.`)
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
@@ -4,7 +4,7 @@
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
// 고르기 · 별 · 보낸 사람(참여자 + 통 수) · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
// 고르기 · 별 · 참여자(+ 통 수 — 칸 머리도 "참여자") · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
@@ -16,7 +16,6 @@ import { useConversationRows } from '@/composables/useConversationRows'
import { useMailListRows } from '@/composables/useMailListRows'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { whoColumnLabel } from '@/shared/utils/mail-list-row'
import { useMailStore } from '@/stores/mail.store'
const router = useRouter()
@@ -24,15 +23,15 @@ const mailStore = useMailStore()
const rows = useMailListRows()
const conversationRows = useConversationRows()
/** 대화로 묶은 메일함 — 크기 칸이 없고 보낸 사람 칸에 참여자를 보인다 */
/** 대화로 묶은 메일함 — 크기 칸이 없고 사람 칸에 참여자를 보인다 */
const threads = computed(() => mailStore.isConversationList)
const caption = computed(() => {
if (mailStore.search) return MAIL_SEARCH_TEXT.title
const name = mailStore.currentMailbox?.name ?? '메일함'
return threads.value ? MAIL_CONVERSATION_LIST_TEXT.tableCaption(name) : `${name} 메일 목록`
})
/** 보낸 사람 칸 머리 — 대화는 참여자(보낸 사람들)라 어느 메일함이든 "보낸 사람"(받은편지함의 칸 이름과 같다) */
const whoHeader = computed(() => (threads.value ? whoColumnLabel(null, false) : rows.whoHeader.value))
/** 사람 칸 머리 — 대화는 줄이 참여자라 어느 메일함이든 "참여자", 메일은 "보낸 사람"(보낸편지함 · 임시보관함은 "받는 사람") */
const whoHeader = computed(() => (threads.value ? MAIL_CONVERSATION_LIST_TEXT.participantsColumn : rows.whoHeader.value))
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {