chore: 14단계 검색 색인을 앱 로그인 브랜치에 합친다

앱 새 메일 알림(푸시)의 서버 일을 앱 세션과 14단계 위에서 하려고 합친다.
겹친 곳은 두 곳이다. auth.module.ts 는 import 둘을 함께 두고,
AuthService.login() 은 enrollAfterLogin(14단계)을 남긴 뒤 recordSignIn(앱 로그인)으로 기록한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 21:44:45 +09:00
227 changed files with 31229 additions and 742 deletions
@@ -0,0 +1,333 @@
<script setup lang="ts">
// 분할 보기의 대화 줄 (14단계 — 디자인 원본 ListThreads14) — 2줄: 참여자 · 통 수(회색 "4통" — 한 통이면 없다) · 마지막 시각 / 마지막 메일 제목(+ 첨부)
// - 하나라도 안 읽었으면 굵게(뱃지 없이 — 메일 줄과 같다), 중요 표시한 메일이 있으면 별이 찬다. 누르면 그 메일함의 마지막 메일을 연다(아래에 같은 대화 칸)
// - 고르기 칸 · Ctrl(⌘) / Shift + 클릭 · 별은 메일 줄(MessageListItem)과 같은 자리 · 크기다 — 하는 일은 그 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
interface Props {
row: ConversationSummary
/** 이름 자리 — 참여자 (useConversationRows) */
who: RowWho
/** 누르면 갈 곳 — 그 메일함의 마지막 메일(임시보관함은 쓰기 화면) */
to: RouteLocationRaw
/** 하나라도 안 읽었다 (임시보관함은 늘 false) */
unread?: boolean
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
draft?: boolean
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
held?: boolean
/** 지금 열려 있는 메일이 든 줄 */
selected?: boolean
/** 고른 줄 (여러 통 처리) */
checked?: boolean
/** 하나라도 골랐다 — 모든 줄에 고르기 상자를 보인다 */
checking?: boolean
}
interface Emits {
(e: 'toggle-flag', rowId: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', rowId: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
unread: false,
draft: false,
held: false,
selected: false,
checked: false,
checking: false,
})
const emit = defineEmits<Emits>()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
const time = computed(() => pendingLabel.value ?? formatMailListTime(props.row.latest.receivedAt))
/** Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다 (메일 줄과 같다). 고를 수 없는 줄은 고르지 않는다 */
function onMainClick(event: MouseEvent) {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
function onStar() {
if (!props.held) emit('toggle-flag', props.row.id)
}
</script>
<template>
<li
:class="[
'thread-row',
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
]"
:data-row-id="row.id"
@mousedown="onMouseDown"
>
<MailCheckbox
class="thread-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
/>
<RouterLink
class="thread-row__main"
:to="to"
:data-message-id="row.latest.id"
@click="onMainClick"
>
<span class="thread-row__top">
<span :class="['thread-row__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
</span>
<span
v-if="countText"
class="thread-row__count"
data-thread-count
>{{ countText }}</span>
<time
:class="['thread-row__time', { 'is-pending': pendingLabel }]"
:datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt"
>{{ time }}</time>
</span>
<span class="thread-row__bottom">
<span :class="['thread-row__subject', { 'is-placeholder': draft && !row.latest.subject }]">{{ subject }}</span>
<AppIcon
v-if="row.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
:label="MAIL_LIST_ROW_TEXT.attachment"
class="thread-row__clip"
/>
</span>
</RouterLink>
<button
type="button"
:class="['thread-row__star', { 'is-flagged': row.isFlagged }]"
:aria-label="MAIL_LIST_ROW_TEXT.flag"
:aria-pressed="row.isFlagged"
:aria-disabled="held ? 'true' : undefined"
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
data-thread-star
@click="onStar"
>
<AppIcon
name="star"
:size="15"
:filled="row.isFlagged"
/>
</button>
</li>
</template>
<style scoped>
/* 메일 줄(MessageListItem)과 같은 자리 · 크기 — 2줄 약 50px (디자인 원본 ListThreads14 · Main) */
.thread-row {
position: relative;
display: flex;
align-items: stretch;
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.thread-row:hover {
background: var(--color-surface-panel);
}
.thread-row.is-selected {
background: var(--color-brand-subtle);
}
.thread-row.is-checked {
background: var(--color-row-checked);
}
.thread-row.is-checked:hover {
background: var(--color-row-checked-hover);
}
/* 고르기 칸 — 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 (메일 줄과 같다) */
.thread-row__check {
position: absolute;
width: var(--space-1);
height: var(--space-1);
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외 --size-row-check)에 16px 상자 */
.thread-row:hover .thread-row__check,
.thread-row:focus-within .thread-row__check,
.thread-row.is-checking .thread-row__check {
position: static;
flex: 0 0 auto;
justify-content: flex-end;
width: var(--size-row-check);
height: auto;
overflow: visible;
clip-path: none;
}
.thread-row__check:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
.thread-row__main {
flex: 1 1 auto;
min-width: 0;
min-height: var(--size-list-row);
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
padding: var(--space-6) var(--space-2) var(--space-6) var(--space-16);
line-height: var(--line-height-heading);
color: inherit;
text-decoration: none;
}
.thread-row:hover .thread-row__main,
.thread-row:focus-within .thread-row__main,
.thread-row.is-checking .thread-row__main {
padding-left: var(--space-10);
}
.thread-row__main:hover {
color: inherit;
}
.thread-row__main:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* 첫 줄 — 참여자(넘치면 줄인다) · 통 수 · 시각(오른쪽 끝) (디자인 원본 ListThreads14) */
.thread-row__top {
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-row__who {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 450;
color: var(--color-text-strong);
}
/* 통 수 — 회색 글, 뱃지 · 알약 없음 */
.thread-row__count {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 500;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-row__time {
flex: 0 0 auto;
margin-left: auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-row__time.is-pending,
.thread-row.is-unread .thread-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
.thread-row__bottom {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-row__subject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.thread-row__clip {
flex: 0 0 auto;
color: var(--color-text-muted);
}
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 줄과 같다) */
.thread-row__who.is-placeholder,
.thread-row__subject.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 (메일 줄과 같다) */
.thread-row.is-unread .thread-row__who {
font-weight: 650;
color: var(--color-text);
}
.thread-row.is-unread .thread-row__subject,
.thread-row.is-unread .thread-row__time {
font-weight: 600;
color: var(--color-text);
}
.thread-row__star {
flex: 0 0 auto;
width: var(--size-touch);
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--color-icon-muted);
}
.thread-row__star.is-flagged {
color: var(--color-star);
}
.thread-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
.thread-row__star:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
</style>
@@ -0,0 +1,322 @@
<script setup lang="ts">
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
// 스토어가 대화를 다시 받아 이 줄을 뺀다. 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
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'
interface Props {
/** 줄을 펼친 단추가 가리키는 id (aria-controls) */
panelId: string
row: ConversationRow
/** 읽기 화면의 보기 방식 — 목록 보기는 [이 메일만 열기]를 바꿔 끼운다 */
layout?: 'split' | 'list'
detail: ConversationDetailState | null
}
interface Emits {
(e: 'retry'): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const emit = defineEmits<Emits>()
const print = useMailPrint()
const recipients = computed(() => formatAddressList(props.row.message.to))
const subject = computed(() => props.row.message.subject || MAIL_NO_SUBJECT)
const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.detail : null))
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
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)면 브라우저 인쇄 대신 이 메일의 인쇄 화면 */
function onFrameKeydown(event: KeyboardEvent) {
const current = ready.value
if (!current || !print.isPrintShortcut(event)) return
event.preventDefault()
print.openPrint(current)
}
</script>
<template>
<div
:id="panelId"
class="conversation-panel"
>
<p
v-if="recipients"
class="conversation-panel__meta"
>
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.recipients }}</span>
{{ recipients }}
</p>
<p class="conversation-panel__meta conversation-panel__meta--muted">
<span class="conversation-panel__label">{{ MAIL_CONVERSATION_TEXT.subject }}</span>
{{ subject }}
</p>
<template v-if="ready">
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="conversation-panel__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="conversation-panel__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<MailBodyFrame
class="conversation-panel__body"
:html-body="ready.htmlBody"
:text-body="ready.textBody"
:images-shown="ready.imagesShown === true"
: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"
class="conversation-panel__error"
data-conversation-detail-error
>
<AppIcon
name="alert-circle"
:size="16"
class="conversation-panel__error-icon"
/>
<span>{{ failure.message }}</span>
<button
v-if="!missing"
type="button"
class="conversation-panel__retry"
@click="emit('retry')"
>
{{ MAIL_CONVERSATION_TEXT.retry }}
</button>
</p>
<div
v-else
class="conversation-panel__loading"
aria-busy="true"
:aria-label="MAIL_CONVERSATION_TEXT.bodyLoading"
data-conversation-detail-loading
>
<span class="conversation-panel__bar" />
<span class="conversation-panel__bar conversation-panel__bar--short" />
</div>
<div
v-if="!missing"
class="conversation-panel__actions"
>
<RouterLink
v-if="row.replyLocation"
class="conversation-panel__action"
:to="row.replyLocation"
data-conversation-reply
>
{{ MAIL_CONVERSATION_TEXT.reply }}
</RouterLink>
<RouterLink
class="conversation-panel__action"
:to="row.location"
:replace="layout === 'list'"
data-conversation-open
>
{{ MAIL_CONVERSATION_TEXT.openOnly }}
</RouterLink>
</div>
<p
class="visually-hidden"
role="status"
data-conversation-detail-status
>
{{ failure?.message ?? '' }}
</p>
</div>
</template>
<style scoped>
/*
* 줄 머리 아래 — 왼쪽 78px 은 이름 선에 맞춘다(줄 안쪽 14 + 화살표 16 + 사이 10 + 머리글자 28 + 사이 10, 디자인 원본 Thread14)
*/
.conversation-panel {
display: flex;
flex-direction: column;
gap: var(--space-10);
padding: var(--space-2) var(--space-16) var(--space-14)
calc(var(--space-14) + var(--space-16) + var(--space-10) + var(--space-28) + var(--space-10));
}
.conversation-panel__meta {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.conversation-panel__meta--muted {
color: var(--color-text-muted);
}
.conversation-panel__label {
margin-right: var(--space-6);
}
/* 본문 안내 — 읽기 화면의 본문 안내 줄과 같은 모양 (노랑 · 위아래 10 · 좌우 12, 아이콘은 첫 줄에 — MessageReaderNotices) */
.conversation-panel__notice {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
font-size: var(--font-size-label);
}
.conversation-panel__notice-icon {
flex: 0 0 auto;
margin-top: var(--space-2);
}
/* 펼친 본문은 이 칸 폭을 넘지 않는다 (본문 틀의 글줄 최대 폭은 그대로) */
.conversation-panel__body {
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;
gap: var(--space-10);
padding: var(--space-6) 0;
}
.conversation-panel__bar {
width: 80%;
height: var(--space-10);
border-radius: var(--radius-xs);
background: var(--color-border-subtle);
}
.conversation-panel__bar--short {
width: 45%;
}
.conversation-panel__error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.conversation-panel__error-icon {
color: var(--color-danger);
}
/* [다시 시도] 44 — 글자 단추 */
.conversation-panel__retry {
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation-panel__retry:hover {
background: var(--color-brand-subtle);
}
/* [답장] · [이 메일만 열기] — 글자 시작을 위 글과 맞춘다(단추 안쪽 10 만큼 당긴다) */
.conversation-panel__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-left: calc(var(--space-10) * -1);
}
/* 높이 36 — 디자인 원본 Thread14 의 확정값, 안내 줄 단추의 밀도 예외(--size-notice-action)를 다시 쓴다 */
.conversation-panel__action {
display: inline-flex;
align-items: center;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border-radius: var(--radius-md);
color: var(--color-brand);
font-weight: 600;
text-decoration: none;
}
.conversation-panel__action:hover {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,234 @@
<script setup lang="ts">
// "같은 대화" 칸의 줄 하나 (14단계 — 디자인 원본 Thread14) — 줄 48: ▸ · 머리글자 · 이름(내가 보냈으면 "나" + 회색 메일함 이름) · 미리 보기 한 줄 · 시각
// - 지금 보는 메일: 옅은 청록 바탕 · "지금 보고 있는 메일" · 누를 수 없다(aria-current). 화살표 자리만큼 들여 머리글자 선을 맞춘다
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
interface Props {
row: ConversationRow
/** 읽기 화면의 보기 방식 — 펼친 줄의 [이 메일만 열기]를 기록에 쌓을지 */
layout?: 'split' | 'list'
expanded?: boolean
/** 펼친 줄의 본문 — 아직 받지 않았으면 null */
detail?: ConversationDetailState | null
}
interface Emits {
(e: 'toggle', id: string): void
(e: 'retry-detail', id: string): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
expanded: false,
detail: null,
})
const emit = defineEmits<Emits>()
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
</script>
<template>
<li
:class="['conversation-row', { 'is-unread': row.unread }]"
:data-conversation-row="row.message.id"
>
<div
v-if="row.current"
class="conversation-row__line conversation-row__line--current"
aria-current="true"
>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</div>
<template v-else>
<button
type="button"
class="conversation-row__line"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-conversation-toggle
@click="emit('toggle', row.message.id)"
>
<AppIcon
:name="expanded ? 'chevron-down' : 'chevron-right'"
:stroke-width="2"
class="conversation-row__chevron"
/>
<span
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
>{{ row.sender.mailboxName }}</span>
<span class="conversation-row__preview">{{ row.preview }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
:datetime="row.timeValue"
>{{ row.time }}</time>
</button>
<ConversationMessagePanel
v-if="expanded"
:panel-id="panelId"
:row="row"
:layout="layout"
:detail="detail"
@retry="emit('retry-detail', row.message.id)"
/>
</template>
</li>
</template>
<style scoped>
.conversation-row:not(:first-child) {
border-top: var(--border-width) solid var(--color-border-subtle);
}
/* 줄 48 (디자인 원본 Thread14) — 안쪽 위아래 6 · 좌우 14 */
.conversation-row__line {
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
min-height: var(--space-48);
padding: var(--space-6) var(--space-14);
border: 0;
background: var(--color-surface);
text-align: left;
}
button.conversation-row__line:hover {
background: var(--color-surface-panel);
}
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
button.conversation-row__line:focus-visible {
outline-offset: calc(var(--focus-ring-width) * -1);
}
/* 지금 보는 메일 — 옅은 청록 바탕, 화살표(16) + 사이(10) 만큼 들인다 */
.conversation-row__line--current {
padding-left: calc(var(--space-14) + var(--space-16) + var(--space-10));
background: var(--color-brand-tint);
}
.conversation-row__chevron {
color: var(--color-text-muted);
}
/* 머리글자 28 — 내가 보낸 메일은 회색 (디자인 원본 Thread14) */
.conversation-row__avatar {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-28);
height: var(--space-28);
border-radius: var(--radius-round);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-size: var(--font-size-small);
font-weight: 600;
}
.conversation-row__avatar--mine {
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
/* 이름은 미리 보기가 다 줄어든 뒤에야 줄인다 (미리 보기의 처음 폭이 0 이다) */
.conversation-row__who {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.conversation-row__mailbox {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.conversation-row__preview,
.conversation-row__current {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-label);
}
.conversation-row__preview {
color: var(--color-text-muted);
}
.conversation-row__current {
font-weight: 600;
color: var(--color-brand-strong);
}
.conversation-row__time {
flex: 0 0 auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
.conversation-row.is-unread .conversation-row__who {
font-weight: 650;
color: var(--color-text);
}
.conversation-row.is-unread .conversation-row__preview,
.conversation-row.is-unread .conversation-row__time {
font-weight: 600;
color: var(--color-text);
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
.conversation-row__time.is-pending,
.conversation-row.is-unread .conversation-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,287 @@
<script setup lang="ts">
// 목록 보기 표의 대화 줄(38) — 고르기 · 별 · 참여자(+ 통 수) · 제목(링크 + 첨부 클립) · 날짜 (14단계 — 보드 ListThreads14 에 표가 없어 정했다)
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { formatMailTableDate } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
interface Props {
row: ConversationSummary
who: RowWho
to: RouteLocationRaw
unread?: boolean
/** 임시보관함의 줄 — 제목이 없으면 흐린 글자 */
draft?: boolean
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
held?: boolean
checked?: boolean
}
interface Emits {
(e: 'open', location: RouteLocationRaw): void
(e: 'toggle-flag', rowId: string): void
/** 고르기 · 풀기 — range 면 Shift(범위) */
(e: 'check', rowId: string, range: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
unread: false,
draft: false,
held: false,
checked: false,
})
const emit = defineEmits<Emits>()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
/** Shift + 누름이 글자를 고르지 않게 한다 */
function onMouseDown(event: MouseEvent) {
if (event.shiftKey) event.preventDefault()
}
function onStar() {
if (!props.held) emit('toggle-flag', props.row.id)
}
</script>
<template>
<tr
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
:data-row-id="row.id"
@click="onRowClick"
@mousedown="onMouseDown"
>
<td class="mail-table__check">
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
<MailCheckbox
class="thread-table-row__check"
:checked="checked"
:label="MAIL_BULK_TEXT.selectOne(subject)"
:unavailable="held"
:note="SEND_LATER_TEXT.heldNotSelectable"
@toggle="(event: MouseEvent) => emit('check', row.id, event.shiftKey)"
/>
</td>
<td class="mail-table__star">
<button
type="button"
:class="['thread-table-row__star', { 'is-flagged': row.isFlagged }]"
:aria-label="MAIL_LIST_ROW_TEXT.flag"
:aria-pressed="row.isFlagged"
:aria-disabled="held ? 'true' : undefined"
:title="held ? MAIL_CONVERSATION_LIST_TEXT.heldFlagNote : undefined"
data-thread-star
@click="onStar"
>
<AppIcon
name="star"
:size="14"
:filled="row.isFlagged"
/>
</button>
</td>
<td class="thread-table-row__who-cell">
<div class="thread-table-row__who-line">
<span :class="['thread-table-row__who', { 'is-placeholder': who.placeholder }]">
<span
v-if="unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ who.text }}
</span>
<span
v-if="countText"
class="thread-table-row__count"
data-thread-count
>{{ countText }}</span>
</div>
</td>
<td class="mail-table__subject">
<div class="thread-table-row__subject-line">
<RouterLink
:class="['thread-table-row__link', { 'is-placeholder': draft && !row.latest.subject }]"
:to="to"
:data-message-id="row.latest.id"
>
{{ subject }}
</RouterLink>
<AppIcon
v-if="row.hasAttachment"
name="paperclip"
:size="13"
:stroke-width="2"
:label="MAIL_LIST_ROW_TEXT.attachment"
class="thread-table-row__clip"
/>
</div>
</td>
<td
:class="['mail-table__date', { 'is-pending': pendingLabel }]"
:title="pendingLabel ?? undefined"
>
<time :datetime="row.latest.pendingSend?.sendAt ?? row.latest.receivedAt">{{
pendingLabel ?? formatMailTableDate(row.latest.receivedAt)
}}</time>
</td>
</tr>
</template>
<style scoped>
/* 메일 표의 행(MessageTableRow)과 같은 높이 · 바탕 (디자인 원본 MainList · BulkSelect) */
.thread-table-row {
height: var(--size-table-row);
cursor: pointer;
}
.thread-table-row td {
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.thread-table-row:hover {
background: var(--color-row-hover);
}
.thread-table-row:focus-within {
background: var(--color-brand-subtle);
}
.thread-table-row.is-checked {
background: var(--color-row-checked);
}
.thread-table-row.is-checked:hover {
background: var(--color-row-checked-hover);
}
.thread-table-row__check {
width: var(--table-col-check);
height: calc(var(--size-table-row) - var(--border-width));
justify-content: flex-start;
}
.thread-table-row__star {
width: var(--size-compact-control);
height: calc(var(--size-table-row) - var(--border-width));
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--color-icon-muted);
}
.thread-table-row__star.is-flagged {
color: var(--color-star);
}
.thread-table-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
/* 참여자 칸 — 이름이 먼저 줄고 통 수는 남는다 */
.thread-table-row__who-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-table-row__who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 450;
color: var(--color-text-strong);
}
.thread-table-row__count {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-weight: 500;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.thread-table-row__subject-line {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-6);
}
.thread-table-row__link {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.thread-table-row__link:hover {
color: var(--color-text);
text-decoration: underline;
}
.thread-table-row__clip {
flex: 0 0 auto;
color: var(--color-text-muted);
}
/* 비어 있는 값 — "(받는 사람 없음)" · 임시 저장본의 "(제목 없음)" (메일 표와 같다) */
.thread-table-row__who.is-placeholder,
.thread-table-row__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.thread-table-row.is-unread .thread-table-row__who,
.thread-table-row.is-unread .thread-table-row__link {
font-weight: 650;
color: var(--color-text);
}
.thread-table-row.is-unread .mail-table__date {
font-weight: 600;
color: var(--color-text);
}
.thread-table-row .mail-table__date.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
// 검색 강조가 든 글 (디자인 원본 Search14 · README 「14단계 › 검색 · 조건 검색 · 강조」) — 서버가 준 맞은 곳을 <mark>(노랑 · 글자색 그대로)로 그린다.
// v-html 없이 조각(search-highlight.ts)으로 그린다. 맞은 곳이 없으면 글 그대로다(엔진 방식 검색 · 메일함 목록).
// 뿌리 요소가 없다 — 부르는 쪽의 글 자리(span · h2 · a) 안에 넣는다.
import { computed } from 'vue'
import type { SearchRange } from '@/shared/types/mail'
import { highlightSegments } from '@/shared/utils/search-highlight'
interface Props {
text: string
/** 맞은 곳 [시작, 끝) — UTF-16, 서버가 NFC 로 맞춘 글 기준 */
ranges?: readonly SearchRange[] | null
}
const props = withDefaults(defineProps<Props>(), {
ranges: null,
})
const segments = computed(() => highlightSegments(props.text, props.ranges))
</script>
<template>
<template
v-for="(segment, index) in segments"
:key="index"
>
<mark
v-if="segment.mark"
class="search-mark"
data-search-mark
>{{ segment.text }}</mark>
<template v-else>
{{ segment.text }}
</template>
</template>
</template>
<style scoped>
/*
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
*/
.search-mark {
padding: 0 var(--space-1);
border-radius: calc(var(--radius-xs) / 2);
background: var(--color-search-mark);
color: inherit;
}
</style>
@@ -158,3 +158,114 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
})
})
describe('ListReadNav — 14단계 검색의 빈 쪽', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
it('[다음]은 빈 쪽에 멈추지 않는다 — 다음 메일이 나올 때까지 커서로 이어 묻고 그 메일로 간다', async () => {
const requests = installMockApi(({ params }) => {
if (params.cursor === 'c2') return ok({ items: [], pageInfo: { hasNext: true, nextCursor: 'c3', total: null } })
return ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } })
})
const store = useMailStore()
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
store.messages = [summary('m1'), summary('m2'), summary('m3')]
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: null }
store.listStatus = 'ready'
const query = `q=${encodeURIComponent('회의')}&in=all`
const router = createTestRouter()
await router.push(`/mail/search?${query}`)
await router.push(`/mail/search/m3?${query}`)
await router.isReady()
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
// 전체 수를 모르면 "n / 전체" 를 보이지 않는다
expect(wrapper.find('.nav-position').exists()).toBe(false)
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
'c2',
'c3',
])
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
})
it('뒤에서 결과를 이어 받는 중에 누른 [다음]은 그 일을 기다렸다가 넘어간다 — 기다리는 동안 꺼지고 aria-busy, 새로 묻지 않는다', async () => {
let resolveSecond: (value: ReturnType<typeof ok>) => void = () => undefined
const second = new Promise<ReturnType<typeof ok>>((done) => {
resolveSecond = done
})
const requests = installMockApi(({ url, params }) => {
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
if (!params.cursor) {
return ok({ items: [summary('m1'), summary('m2'), summary('m3')], pageInfo: { hasNext: true, nextCursor: 'c2', total: null } })
}
return second
})
const store = useMailStore()
const opening = store.openSearch('회의', 'all')
await flushPromises()
expect(store.isLoadingMore).toBe(true)
const query = `q=${encodeURIComponent('회의')}&in=all`
const router = createTestRouter()
await router.push(`/mail/search?${query}`)
await router.push(`/mail/search/m3?${query}`)
await router.isReady()
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
const next = wrapper.get('button[aria-label="다음 메일"]')
// 이어 받는 중이면 끝에 있어도 [다음]을 누를 수 있다
expect(next.attributes('disabled')).toBeUndefined()
await next.trigger('click')
await flushPromises()
expect(next.attributes('disabled')).toBeDefined()
expect(next.attributes('aria-busy')).toBe('true')
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
resolveSecond(ok({ items: [summary('m4')], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
await opening
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m4?${query}`)
expect(requests.filter((request) => request.url === '/messages').map((request) => request.params.cursor)).toEqual([
undefined,
'c2',
])
})
it('목록을 보이는 채로 새로 고치는 동안 받아야 할 [다음]은 꺼지고 aria-busy — 끝나면 다시 누를 수 있다', async () => {
const store = useMailStore()
store.currentMailboxId = 'inbox'
store.messages = [summary('m1'), summary('m2'), summary('m3')]
store.pageInfo = { hasNext: true, nextCursor: 'c2', total: 5 }
store.listStatus = 'ready'
store.isRefreshing = true
const router = createTestRouter()
await router.push('/mail/inbox')
await router.push('/mail/inbox/m3')
await router.isReady()
const wrapper = mount(ListReadNav, { props: { messageId: 'm3' }, global: { plugins: [pinia, router] } })
const next = wrapper.get('button[aria-label="다음 메일"]')
expect(next.attributes('disabled')).toBeDefined()
expect(next.attributes('aria-busy')).toBe('true')
store.isRefreshing = false
await flushPromises()
expect(next.attributes('disabled')).toBeUndefined()
expect(next.attributes('aria-busy')).toBe('false')
})
})
+39 -21
View File
@@ -2,6 +2,8 @@
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
// 대화로 묶은 메일함(14단계)은 줄(대화) 사이를 옮긴다 — 이전/다음은 앞뒤 줄의 마지막 메일, 위치는 "n번째 대화 / 대화 수".
// 연 메일이 줄의 마지막 메일이 아니어도([이 메일만 열기]로 연 더 오래된 메일) 그 메일이 든 줄을 기준으로 한다
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -32,45 +34,60 @@ const listHref = computed(() => {
const source = mailStore.listSource
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
})
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
const total = computed(
() =>
mailStore.pageInfo?.total ??
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
mailStore.messages.length,
)
/** 불러온 목록에서의 위치(줄) — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
const index = computed(() => mailStore.rowIndexOfMessage(props.messageId))
/**
* 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수.
* 검색 결과의 total 이 null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 모른다 — 위치 "n / 전체"를 보이지 않는다.
* 대화 목록은 대화 수 — 모르면 다 받았을 때만 불러온 줄 수 (메일함의 통 수는 대화 수가 아니다)
*/
const total = computed<number | null>(() => {
const known = mailStore.pageInfo?.total ?? null
if (known !== null) return known
if (mailStore.search) return null
if (mailStore.isConversationList) return mailStore.hasMore ? null : mailStore.rowCount
return mailStore.currentMailbox?.totalCount ?? mailStore.messages.length
})
const position = computed(() =>
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
index.value === -1 || total.value === null ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
)
const hasPrev = computed(() => index.value > 0)
/** 불러온 범위의 끝이어도 서버에 더 있으면(hasNext) 다음으로 갈 수 있다 */
/** 불러온 범위의 끝이어도 서버에 더 있거나(hasNext) 뒤에서 이어 받는 중이면 다음으로 갈 수 있다 */
const hasNext = computed(
() =>
index.value !== -1 && (index.value < mailStore.messages.length - 1 || mailStore.hasMore),
index.value !== -1 && (index.value < mailStore.rowCount - 1 || mailStore.hasMore || mailStore.isLoadingMore),
)
/** 다음 메일을 받아야 하는데 목록을 보이는 채로 새로 고치는 중 — 끝날 때까지 [다음]을 막는다 (옛 커서로 붙이지 않게) */
const nextWaiting = computed(
() => index.value !== -1 && mailStore.rowMessageIdAt(index.value + 1) === null && mailStore.isRefreshing,
)
async function goPrev() {
const previous = mailStore.messages[index.value - 1]
const previous = mailStore.rowMessageIdAt(index.value - 1)
const source = mailStore.listSource
if (previous && source) await showSiblingIn(source, previous.id)
if (previous && source) await showSiblingIn(source, previous)
}
/**
* 다음 메일 — 불러온 범위의 끝이면 더 받는다. 검색 결과를 뒤에서 이어 받는 중(14단계)이면 그 일이 끝나기를 기다리고,
* 그 안에 다음 메일이 없었으면 이번 누름의 차례로 한 번 더 받는다. 기다리는 동안 단추는 꺼지고 aria-busy
*/
async function goNext() {
if (loadingNext.value || index.value === -1) return
if (loadingNext.value || nextWaiting.value || index.value === -1) return
const current = index.value
if (!mailStore.messages[current + 1] && mailStore.hasMore) {
if (mailStore.rowMessageIdAt(current + 1) === null && (mailStore.hasMore || mailStore.isLoadingMore)) {
loadingNext.value = true
try {
const waited = mailStore.isLoadingMore
await mailStore.loadMore()
if (waited && mailStore.rowMessageIdAt(current + 1) === null && mailStore.hasMore) await mailStore.loadMore()
} finally {
loadingNext.value = false
}
}
const next = mailStore.messages[current + 1]
const next = mailStore.rowMessageIdAt(current + 1)
const source = mailStore.listSource
if (next && source) await showSiblingIn(source, next.id)
if (next && source) await showSiblingIn(source, next)
}
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
@@ -80,7 +97,8 @@ function onBackToList(event: MouseEvent) {
event.preventDefault()
const source = mailStore.listSource
if (!source) return
viewStore.setReturnFocus(props.messageId)
// 대화 목록은 줄의 링크가 마지막 메일을 가리킨다 — 그 줄로 초점을 돌려준다
viewStore.setReturnFocus(mailStore.rowMessageIdAt(index.value) ?? props.messageId)
void backToSourceList(source)
}
</script>
@@ -115,8 +133,8 @@ function onBackToList(event: MouseEvent) {
type="button"
class="nav-button"
aria-label="다음 메일"
:disabled="!hasNext || loadingNext"
:aria-busy="loadingNext"
:disabled="!hasNext || loadingNext || nextWaiting"
:aria-busy="loadingNext || nextWaiting"
@click="goNext"
>
<AppIcon
@@ -14,8 +14,10 @@
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
// 검색 결과에서 연 메일(14단계)은 맞은 말이 노랑으로 보인다 — 서식 본문은 서버가 넣은 <mark>, 글 본문은 textHighlights 의 위치로 (mail-html.ts)
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { SearchRange } from '@/shared/types/mail'
import { buildMailDocument } from '@/shared/utils/mail-html'
interface Props {
@@ -29,6 +31,8 @@ interface Props {
title?: string
/** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */
foldLargeFiles?: boolean
/** 글 본문의 검색에서 맞은 곳 — 서버의 searchHighlight.textBody (14단계 — 글 본문을 보일 때만 쓴다) */
textHighlights?: readonly SearchRange[] | null
}
interface Emits {
@@ -47,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
imagesShown: false,
title: '메일 본문',
foldLargeFiles: false,
textHighlights: null,
})
const emit = defineEmits<Emits>()
@@ -63,6 +68,7 @@ const srcdoc = computed(() =>
textBody: props.textBody,
imagesShown: props.imagesShown,
foldLargeFiles: props.foldLargeFiles,
textHighlights: props.textHighlights,
}),
)
@@ -0,0 +1,82 @@
<script setup lang="ts">
// 목록 머리 오른쪽의 [대화로 묶기] (14단계 — 디자인 원본 ListThreads14): 눌림 단추(aria-pressed) · 높이 36(--size-notice-action — 안내 줄 단추의
// 밀도 예외를 새 토큰 없이 다시 쓴다). 누르면 설정(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'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
interface Props {
variant: 'split' | 'list'
}
const props = defineProps<Props>()
const settingsStore = useMailSettingsStore()
const pressed = computed(() => settingsStore.groupByConversation)
const iconOnly = computed(() => props.variant === 'split')
function toggle() {
void settingsStore.update('groupByConversation', !pressed.value)
}
</script>
<template>
<button
type="button"
:class="['group-toggle', { 'is-on': pressed, 'group-toggle--icon': iconOnly }]"
:aria-pressed="pressed"
:title="iconOnly ? MAIL_CONVERSATION_LIST_TEXT.toggle : undefined"
data-group-toggle
@click="toggle"
>
<AppIcon
name="message-square"
:size="14"
:stroke-width="1.9"
/>
<span :class="{ 'visually-hidden': iconOnly }">{{ MAIL_CONVERSATION_LIST_TEXT.toggle }}</span>
</button>
</template>
<style scoped>
/* 높이 36(밀도 예외 --size-notice-action) · 테두리 · 12.5px 굵게 (디자인 원본 ListThreads14) */
.group-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-size: var(--font-size-label);
font-weight: 600;
white-space: nowrap;
}
.group-toggle:hover:not(.is-on) {
background: var(--color-surface-muted);
}
.group-toggle.is-on {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
/* 분할 보기 — 아이콘만, 정사각형 36 */
.group-toggle--icon {
width: var(--size-notice-action);
padding: 0;
}
</style>
+70 -8
View File
@@ -4,8 +4,11 @@
// 그 아래 휴지통 · 스팸메일함의 30일 줄(13단계 — 메일이 있을 때만, 없으면 빈 상태 글에 붙인다 · 검색 결과 · 다른 메일함에는 없다),
// 바탕화면 알림 권하기(메일함 목록에서만 · 한 번 — MailNotifyOffer, 5단계), "새 메일 N통 · 맨 위로"(useListArrivalNotice)
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음 · 예약한 메일 없음(9단계 — 예약됨에는 알림 권하기 줄이 없다)
// - 검색 결과(14단계 — 디자인 원본 Search14): 찾을 곳 줄 아래 조건 줄(조건이 걸렸을 때) · 안내 줄(검색 준비 중 · 한 글자 안내).
// 결과 없음은 더 찾을 것이 없을 때만 — 이어 찾을 것이 남았는데 아직 하나도 못 찾았으면 [더 찾기](MailSearchMore)
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
// - 대화로 묶은 메일함(14단계)도 같은 틀이다 — 줄 수 · 빈 상태는 메일 목록과 같은 규칙(줄이 대화일 뿐)
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -14,8 +17,11 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
import MailNotifyOffer from '@/components/mail/MailNotifyOffer.vue'
import MailSearchConditionLine from '@/components/mail/MailSearchConditionLine.vue'
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
import MailSearchMore from '@/components/mail/MailSearchMore.vue'
import MailSearchNotices from '@/components/mail/MailSearchNotices.vue'
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
@@ -26,7 +32,8 @@ import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { MAIL_SEARCH_TEXT, MAILBOX_RETENTION_NOTICES, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { isScheduledSource } from '@/shared/utils/mail-list-source'
import { isScheduledSource, isSearchTooLong } from '@/shared/utils/mail-list-source'
import { hasSearchConditions, skipsBodySearch } from '@/shared/utils/mail-search'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
@@ -69,15 +76,39 @@ const retentionNotice = computed(() => {
const role = mailbox.value?.role ?? null
const text = role ? (MAILBOX_RETENTION_NOTICES[role] ?? null) : null
if (text === null) return null
return mailStore.listStatus === 'ready' && mailStore.messages.length === 0 ? null : text
return mailStore.listStatus === 'ready' && mailStore.rowCount === 0 ? null : text
})
/** 검색 조건 — 조건 줄은 걸렸을 때만 (14단계) */
const searchConditions = computed(() =>
search.value && hasSearchConditions(search.value.conditions) ? (search.value.conditions ?? null) : null,
)
/** 검색어와 조건의 낱말이 한도를 넘는 주소 — 찾지 않았다, 결과 없음 자리에 그 까닭을 보인다 (14단계) */
const searchTooLong = computed(() => isSearchTooLong(search.value))
/** 지난 메일의 색인을 만드는 중 — 결과 머리 아래 "검색 준비 중" 줄 · 결과 없음의 한계 안내 (14단계) */
const indexing = computed(() => mailStore.searchIndexStatus === 'building')
/** 한 글자 낱말뿐이라 본문을 찾지 않았다 — 색인 방식(준비가 끝남)일 때만 알린다 (14단계) */
const singleChar = computed(
() =>
Boolean(search.value) &&
!searchTooLong.value &&
mailStore.searchIndexStatus === 'ready' &&
skipsBodySearch(search.value?.q ?? '', search.value?.conditions),
)
/** 더 받는 중 · 보이는 채로 새로 고치는 중 — [메일 더 보기] · [더 찾기]를 막는다 (새로 고치는 동안 옛 커서로 붙이지 않게) */
const loadingMore = computed(() => mailStore.isLoadingMore || mailStore.isRefreshing)
/** 결과가 아직 없지만 이어 찾을 것이 남았다 — 결과 없음 대신 [더 찾기] */
const searchPending = computed(
() => Boolean(search.value) && mailStore.listStatus === 'ready' && mailStore.rowCount === 0 && mailStore.hasMore,
)
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
function retry() {
const current = search.value
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
const source = mailStore.listSource
if (current) void mailStore.openSearch(current.q, current.scope, { force: true, conditions: current.conditions })
else if (scheduled.value) void mailStore.openScheduled()
else if (source?.kind === 'conversations') void mailStore.openConversations(source.mailboxId)
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
}
@@ -107,6 +138,18 @@ function onKeydown(event: KeyboardEvent) {
mailStore.clearChecked()
}
// 조건 줄의 [조건 지우기] · 결과를 기다리던 [더 찾기]는 누르면 그 자리가 사라진다 — 초점을 잃었으면 목록 칸으로 (14단계)
watch(
[() => searchConditions.value !== null, searchPending],
([hasConditions, pending], [hadConditions, wasPending]) => {
if ((hadConditions && !hasConditions) || (wasPending && !pending)) {
const active = document.activeElement
if (active === null || active === document.body) focusSection()
}
},
{ flush: 'post' },
)
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
watch(
() => mailStore.checkedCount,
@@ -159,7 +202,17 @@ watch(
/>
<span>{{ retentionNotice }}</span>
</p>
<MailSearchScope v-if="search" />
<template v-if="search">
<MailSearchScope />
<MailSearchConditionLine
v-if="searchConditions"
:conditions="searchConditions"
/>
<MailSearchNotices
:indexing="indexing"
:single-char="singleChar"
/>
</template>
<MailNotifyOffer
v-else-if="!scheduled"
:variant="variant"
@@ -190,7 +243,7 @@ watch(
@leave="leave"
/>
<template v-else-if="mailStore.messages.length > 0">
<template v-else-if="mailStore.rowCount > 0">
<slot />
<div
v-if="mailStore.hasMore"
@@ -199,18 +252,27 @@ watch(
<button
type="button"
class="mail-list-frame__more-button"
:disabled="mailStore.isLoadingMore"
:disabled="loadingMore"
@click="mailStore.loadMore()"
>
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
{{ loadingMore ? '불러오는 중…' : '메일 더 보기' }}
</button>
</div>
</template>
<MailSearchMore
v-else-if="searchPending"
:busy="loadingMore"
@more="mailStore.loadMore()"
/>
<MailSearchEmpty
v-else-if="mailStore.listStatus === 'ready' && search"
:query="search.q"
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
:has-conditions="searchConditions !== null"
:offer-all="search.scope !== SEARCH_SCOPE_ALL && !searchTooLong"
:word-limit="indexing && !searchTooLong"
:too-many-words="searchTooLong"
@search-all="setScope(SEARCH_SCOPE_ALL)"
/>
+87 -15
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
// 목록 머리 — 메일함 이름 · 개수 · [대화로 묶기](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)
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
@@ -7,12 +14,15 @@
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailConversationToggle from '@/components/mail/MailConversationToggle.vue'
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 { isScheduledSource } from '@/shared/utils/mail-list-source'
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'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -21,24 +31,40 @@ interface Props {
variant?: 'split' | 'list'
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
variant: 'split',
})
const mailStore = useMailStore()
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 ''
const total = `전체 ${formatCount(box.totalCount)}`
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
return countInput.value ? listHeadCountText(countInput.value) : ''
})
/** 보이는 개수 — 분할 보기(좁은 머리)의 메일함 목록은 줄인 글(안 읽은 메일이 있으면 그 수만), 그 밖은 전체 글 */
const shownCountText = computed(() =>
props.variant === 'split' && countInput.value ? listHeadShortCountText(countInput.value) : countText.value,
)
/** [대화로 묶기] — 메일함 목록에서만, 설정을 받았거나 받지 못했을 때 (14단계 — 받는 중 · 받기 전에는 숨긴다) */
const groupable = computed(
() =>
mailboxListIdOf(mailStore.listSource) !== null &&
(settingsStore.status === 'ready' || settingsStore.status === 'error'),
)
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
const emptiable = computed(() => {
@@ -49,14 +75,32 @@ const emptiable = computed(() => {
<template>
<div :class="['list-header', `list-header--${variant}`]">
<h1
:id="titleId"
class="list-header__title"
>
{{ title }}
</h1>
<span class="list-header__count">{{ countText }}</span>
<div class="list-header__heading">
<h1
:id="titleId"
class="list-header__title"
>
{{ title }}
</h1>
<!--
분할 보기는 줄인 글을 보이고 전체 글은 title · 화면 낭독에 둔다. 그래도 좁으면 줄어 잘릴 수 있다 — 마우스를 올리면 다 보인다
-->
<span
class="list-header__count"
:title="countText || undefined"
>
<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
v-if="groupable"
:variant="variant"
/>
<MailboxEmptyButton
v-if="emptiable"
:mailbox="emptiable"
@@ -92,7 +136,35 @@ 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 모자라 "색인 …"으로 잘렸다)
*/
.list-header__heading {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-8);
overflow: hidden;
}
.list-header__title {
flex: 0 0 auto;
max-width: 100%;
min-width: 0;
margin: 0;
overflow: hidden;
@@ -105,7 +177,7 @@ const emptiable = computed(() => {
/* 좁으면 이름보다 먼저 줄어든다 */
.list-header__count {
flex: 0 100 auto;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
font-size: var(--font-size-small);
@@ -1,17 +1,22 @@
<script setup lang="ts">
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
// 대화로 묶은 휴지통에서 줄을 골랐으면(from) 제목은 "휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서 (14단계, 2026-11-07)
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통 — from 없이 지금 글) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
import { computed } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { purgeTitleText } from '@/shared/utils/mail-bulk'
interface Props {
open: boolean
count: number
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
/** 대화로 묶은 목록에서 고른 줄이면 그 메일함 이름 ("휴지통") — 없으면 "메일 N통을 …" */
from?: string | null
}
interface Emits {
@@ -19,15 +24,19 @@ interface Emits {
(e: 'cancel'): void
}
defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
from: null,
})
const emit = defineEmits<Emits>()
const title = computed(() => purgeTitleText(props.count, { from: props.from }))
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
:title="title"
:message="MAIL_BULK_TEXT.purgeMessage"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailSearchBox from './MailSearchBox.vue'
@@ -51,8 +53,10 @@ describe('MailSearchBox (메일 검색)', () => {
expect(input.attributes('aria-label')).toBe('메일 검색')
expect(input.attributes('placeholder')).toBe('메일 검색')
expect(wrapper.get('form').attributes('role')).toBe('search')
expect(wrapper.find('button').exists()).toBe(false)
expect(wrapper.find('[data-search-clear]').exists()).toBe(false)
expect(wrapper.get('form').classes()).not.toContain('is-active')
// [조건]은 늘 있다 (14단계)
expect(wrapper.get('[data-search-conditions-toggle]').text()).toBe('조건')
})
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
@@ -123,7 +127,7 @@ describe('MailSearchBox (메일 검색)', () => {
expect((input.element as HTMLInputElement).value).toBe('회의')
expect(wrapper.get('form').classes()).toContain('is-active')
const clear = wrapper.get('button')
const clear = wrapper.get('[data-search-clear]')
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
@@ -165,3 +169,405 @@ describe('MailSearchBox (메일 검색)', () => {
expect(push).not.toHaveBeenCalled()
})
})
describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디자인 원본 Search14)', () => {
let pinia: Pinia
/** 2026년 11월 6일 오전 9시(브라우저 시간대) — 최근 1달은 10월 6일부터 */
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
useMailStore().mailboxes = MAILBOXES
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
})
/** 문서에 붙여 그린 칸 — 테스트마다 떼어 낸다 (열린 패널의 문서 pointerdown 처리가 다음 테스트에 남지 않게) */
let mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted) wrapper.unmount()
mounted = []
vi.restoreAllMocks()
vi.useRealTimers()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailSearchBox, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
return { wrapper, router, input: wrapper.get<HTMLInputElement>('input[type="search"]') }
}
async function openPanel(wrapper: VueWrapper) {
await wrapper.get('[data-search-conditions-toggle]').trigger('click')
await flushPromises()
return wrapper.get('[data-search-conditions]')
}
const field = (wrapper: VueWrapper, name: string) => wrapper.get(`[data-search-condition="${name}"]`)
it('[조건]은 칸 안(✕ 앞)에 있고 누르면 칸 아래에 패널이 열린다 — aria-expanded · 청록 바탕, 첫 칸(보낸 사람)으로 초점', async () => {
const { wrapper } = await mountAt('/mail/inbox')
const toggle = wrapper.get('[data-search-conditions-toggle]')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(toggle.classes()).not.toContain('is-on')
const panel = await openPanel(wrapper)
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(toggle.attributes('aria-controls')).toBe(panel.attributes('id'))
expect(toggle.classes()).toContain('is-on')
expect(panel.attributes('role')).toBe('group')
expect(panel.get('h2').text()).toBe('조건으로 찾기')
expect(panel.findAll('label').map((label) => label.text())).toEqual([
'보낸 사람',
'받는 사람',
'제목',
'내용',
'기간',
'첨부 파일이 있는 메일만',
])
expect(panel.findAll('option').map((option) => option.text())).toEqual([
'전체 기간',
'최근 1주',
'최근 1달',
'최근 3달',
'최근 1년',
'날짜 고르기',
])
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)
})
it('검색 칸이 비어도 조건만으로 찾는다 — 최근 1달은 그 날짜로 바꿔 주소에 싣는다, 패널은 닫히고 검색 칸으로', async () => {
const { wrapper, router, input } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'from').setValue(' 이도윤 ')
await field(wrapper, 'period').setValue('month')
await field(wrapper, 'attachment').setValue(true)
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
`/mail/search?in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(document.activeElement).toBe(input.element)
})
it('패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같다 — 검색어와 조건을 함께', async () => {
const { wrapper, router, input } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'subject').setValue('견적')
await input.setValue('계약')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
`/mail/search?q=${encodeURIComponent('계약')}&in=inbox&subject=${encodeURIComponent('견적')}`,
)
})
it('검색어와 조건을 합친 낱말이 8개를 넘으면 찾지 않고 VAL_001 사전 문구 — 패널 · 친 글자는 그대로', async () => {
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 알림과 떨어뜨린다
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(Date.now() + 60_000)
const { wrapper, router, input } = await mountAt('/mail/inbox')
await input.setValue('하나 둘 셋 넷 다섯')
await openPanel(wrapper)
await field(wrapper, 'body').setValue('여섯 일곱 여덟 아홉')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
expect((field(wrapper, 'body').element as HTMLInputElement).value).toBe('여섯 일곱 여덟 아홉')
})
it('날짜 고르기의 시작이 끝보다 늦으면 찾지 않고 칸 아래에 알린다 — 끝 날짜 칸으로 초점', async () => {
const { wrapper, router } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
await field(wrapper, 'since').setValue('2026-11-05')
await field(wrapper, 'until').setValue('2026-10-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
const error = wrapper.get('[role="alert"]')
expect(error.text()).toBe('끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.')
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('true')
expect(field(wrapper, 'until').attributes('aria-describedby')).toBe(error.attributes('id'))
expect(document.activeElement).toBe(field(wrapper, 'until').element)
// 고쳐서 찾으면 그 날짜로
await field(wrapper, 'until').setValue('2026-11-05')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-05', until: '2026-11-05' })
})
it('날짜는 1970년 1월 1일 ~ 9999년 12월 30일만 고른다 — 그 밖의 날짜는 찾지 않고 칸 아래에 알린다 (그 칸으로 초점)', async () => {
const { wrapper, router } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
expect(field(wrapper, 'since').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
expect(field(wrapper, 'until').attributes()).toMatchObject({ min: '1970-01-01', max: '9999-12-30' })
await field(wrapper, 'since').setValue('1969-12-31')
await field(wrapper, 'until').setValue('2026-11-05')
// 고른 끝 날짜가 시작 날짜 칸의 끝이 된다
expect(field(wrapper, 'since').attributes('max')).toBe('2026-11-05')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
const error = wrapper.get('[role="alert"]')
expect(error.text()).toBe('날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.')
expect(field(wrapper, 'since').attributes('aria-invalid')).toBe('true')
expect(field(wrapper, 'since').attributes('aria-describedby')).toBe(error.attributes('id'))
expect(field(wrapper, 'until').attributes('aria-invalid')).toBe('false')
expect(document.activeElement).toBe(field(wrapper, 'since').element)
// 날짜를 고치면 알림을 지우고, 고른 시작 날짜가 끝 날짜 칸의 처음이 된다
await field(wrapper, 'since').setValue('2026-11-01')
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(field(wrapper, 'until').attributes('min')).toBe('2026-11-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toMatchObject({ since: '2026-11-01', until: '2026-11-05' })
})
it('미리 정한 기간에서 날짜 고르기로 바꾸면 그 기간의 날짜로 칸을 채워 둔다', async () => {
const { wrapper } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('week')
await field(wrapper, 'period').setValue('custom')
expect((field(wrapper, 'since').element as HTMLInputElement).value).toBe('2026-10-30')
expect((field(wrapper, 'until').element as HTMLInputElement).value).toBe('2026-11-06')
})
it('Esc 는 패널만 닫고 [조건]으로 초점을 돌린다 — 바깥을 누르면 닫는다', async () => {
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await openPanel(wrapper)
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-search-conditions-toggle]').element)
// 검색은 그대로
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
await openPanel(wrapper)
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
})
it('패널이 열린 채 검색 칸에서 Esc 를 누르면 패널만 닫고 초점은 검색 칸에 남는다 — 검색은 그대로', async () => {
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await openPanel(wrapper)
input.element.focus()
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
await input.trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(document.activeElement).toBe(input.element)
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
})
it('키보드로 검색 칸 밖으로 나가면(focusout) 패널을 닫는다 — 검색 칸 안으로 옮기는 초점은 그대로', async () => {
const { wrapper, input } = await mountAt('/mail/inbox')
const outside = document.createElement('button')
document.body.append(outside)
await openPanel(wrapper)
const from = field(wrapper, 'from').element
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: input.element }))
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }))
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
// 나간 곳의 초점을 [조건]으로 끌어오지 않는다
expect(document.activeElement).not.toBe(wrapper.get('[data-search-conditions-toggle]').element)
})
it('다른 화면으로 가면(뒤로 가기 등) 열어 둔 패널을 닫는다', async () => {
const { wrapper, router } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await router.push('/mail/trash')
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(wrapper.get('[data-search-conditions-toggle]').attributes('aria-expanded')).toBe('false')
})
it('조건이 걸린 검색 — [조건]이 청록이고, 열면 걸린 조건으로 칸을 채운다(오늘 기준 최근 1달)', async () => {
const { wrapper } = await mountAt(
`/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`,
)
const toggle = wrapper.get('[data-search-conditions-toggle]')
expect(toggle.classes()).toContain('is-on')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(toggle.text()).toContain('조건이 걸려 있음')
await openPanel(wrapper)
expect((field(wrapper, 'from').element as HTMLInputElement).value).toBe('이도윤')
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('month')
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
})
it('패널을 닫은 채 누른 Enter 는 걸린 조건을 그대로 두고 검색어만 바꾼다 (기록을 쌓지 않는다)', async () => {
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all&from=${encodeURIComponent('이도윤')}`)
const push = vi.spyOn(router, 'push')
await input.setValue('견적')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
`/mail/search?q=${encodeURIComponent('견적')}&in=all&from=${encodeURIComponent('이도윤')}`,
)
expect(push).not.toHaveBeenCalled()
})
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
const textValues = (wrapper: VueWrapper) =>
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
const { wrapper, router, input } = await mountAt(path)
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
clear.element.focus()
await clear.trigger('click')
await flushPromises()
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
expect(document.activeElement).toBe(clear.element)
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
expect(router.currentRoute.value.fullPath).toBe(path)
expect(push).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(input.element.value).toBe('회의')
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
// 다시 눌러도 그대로다 (비운 칸)
await clear.trigger('click')
await flushPromises()
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect(document.activeElement).toBe(clear.element)
})
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
const { wrapper } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
await field(wrapper, 'since').setValue('2026-11-05')
await field(wrapper, 'until').setValue('2026-10-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
const { wrapper, router } = await mountAt(path)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe(path)
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
})
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
})
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await input.setValue('')
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
})
})
+74 -6
View File
@@ -1,28 +1,65 @@
<script setup lang="ts">
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · Search14 · BulkSelect 머리 · README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
import { ref, watch } from 'vue'
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailSearchConditions from '@/components/mail/MailSearchConditions.vue'
import MailSearchConditionsToggle from '@/components/mail/MailSearchConditionsToggle.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { useSearchConditionsPopup } from '@/composables/useSearchConditionsPopup'
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-search'
const PANEL_ID = 'mail-search-conditions'
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const text = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
const formRef = ref<HTMLFormElement | null>(null)
const toggleRef = ref<InstanceType<typeof MailSearchConditionsToggle> | null>(null)
const panelRef = ref<InstanceType<typeof MailSearchConditions> | null>(null)
const {
isOpen: conditionsOpen,
close: closeConditions,
toggle: toggleConditions,
onFocusOut,
} = useSearchConditionsPopup({
container: formRef,
toggle: toggleRef,
onOpened: () => panelRef.value?.focusFirst(),
})
/** 지금 걸린 조건 — 검색 화면의 주소에서 */
const appliedConditions = computed<SearchConditions | null>(() => routeSearch.value?.conditions ?? null)
const conditionsApplied = computed(() => hasSearchConditions(appliedConditions.value))
function onKeydown(event: KeyboardEvent) {
if (isImeComposing(event)) return
if (event.key === 'Enter') {
event.preventDefault()
void startSearch(text.value)
// 패널이 열린 동안은 [찾기]와 같다 — 패널의 조건과 함께
if (conditionsOpen.value) void panelRef.value?.apply()
else void startSearch(text.value)
return
}
if (event.key !== 'Escape') return
if (conditionsOpen.value) {
// 패널만 닫는다 — 치던 검색 칸에 초점을 둔다
event.preventDefault()
closeConditions({ restoreFocus: false })
return
}
if (isSearching.value) {
event.preventDefault()
void closeSearch()
@@ -32,12 +69,23 @@ function onKeydown(event: KeyboardEvent) {
}
}
/**
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
*/
async function onApply(conditions: SearchConditions) {
if (!(await startSearch(text.value, conditions))) return
closeConditions({ restoreFocus: false })
await nextTick()
inputRef.value?.focus()
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
watch(
() => routeSearch.value?.q ?? null,
() => (routeSearch.value ? routeSearch.value.q : null),
(query, previous) => {
if (query !== null) text.value = query
else if (previous) text.value = ''
else if (previous !== null && previous !== undefined) text.value = ''
},
{ immediate: true },
)
@@ -45,15 +93,18 @@ watch(
<template>
<form
ref="formRef"
role="search"
:class="['mail-search', { 'is-active': isSearching }]"
@submit.prevent
@focusout="onFocusOut"
>
<AppIcon
name="search"
class="mail-search__icon"
/>
<input
ref="inputRef"
v-model="text"
type="search"
class="mail-search__input"
@@ -64,12 +115,20 @@ watch(
enterkeyhint="search"
@keydown="onKeydown"
>
<MailSearchConditionsToggle
ref="toggleRef"
:expanded="conditionsOpen"
:applied="conditionsApplied"
:panel-id="PANEL_ID"
@toggle="toggleConditions"
/>
<button
v-if="isSearching"
type="button"
class="mail-search__clear"
:aria-label="MAIL_SEARCH_TEXT.clear"
:title="MAIL_SEARCH_TEXT.clearTitle"
data-search-clear
@click="closeSearch"
>
<AppIcon
@@ -78,11 +137,20 @@ watch(
:stroke-width="1.9"
/>
</button>
<MailSearchConditions
v-if="conditionsOpen"
ref="panelRef"
:panel-id="PANEL_ID"
:applied="appliedConditions"
@apply="onApply"
@close="closeConditions()"
/>
</form>
</template>
<style scoped>
.mail-search {
position: relative;
flex: 1 1 17.5rem;
max-width: 32.5rem;
display: flex;
@@ -90,7 +158,7 @@ watch(
gap: var(--space-8);
height: var(--size-search-box);
margin: 0;
padding: 0 var(--space-12);
padding: 0 var(--space-4) 0 var(--space-12);
border: var(--border-width) solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-surface);
@@ -0,0 +1,169 @@
<script setup lang="ts">
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
import { nextTick, onMounted, ref } from 'vue'
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
import { useClock } from '@/composables/useClock'
import { useSearchConditionsDraft } from '@/composables/useSearchConditionsDraft'
import { MAIL_SEARCH_MAX_LENGTH } from '@/shared/constants/mail'
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { SearchConditions, SearchConditionTextField } from '@/shared/utils/mail-search'
interface Props {
/** 칸 id 의 앞 — 패널의 id */
idPrefix: string
/** 지금 걸린 조건 — 처음 그릴 때 칸을 채운다 (없으면 빈 칸) */
applied: SearchConditions | null
}
interface Emits {
(e: 'submit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
{ field: 'to', label: SEARCH_CONDITIONS_TEXT.to, placeholder: SEARCH_CONDITIONS_TEXT.toPlaceholder },
{ field: 'subject', label: SEARCH_CONDITIONS_TEXT.subject, placeholder: SEARCH_CONDITIONS_TEXT.subjectPlaceholder },
{ field: 'body', label: SEARCH_CONDITIONS_TEXT.body, placeholder: SEARCH_CONDITIONS_TEXT.bodyPlaceholder },
]
const fieldId = (name: string) => `${props.idPrefix}-${name}`
const rootRef = ref<HTMLElement | null>(null)
const periodRef = ref<InstanceType<typeof MailSearchPeriodField> | null>(null)
function onTextKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || isImeComposing(event)) return
event.preventDefault()
emit('submit')
}
/** 넘길 조건 — 기간이 잘못됐으면 null 이고, 칸 아래에 알린 뒤 그 날짜 칸으로 */
async function takeConditions(): Promise<SearchConditions | null> {
const conditions = take()
if (conditions) return conditions
await nextTick()
periodRef.value?.focusInvalid()
return null
}
defineExpose({
take: takeConditions,
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
clear,
/** 첫 칸(보낸 사람)으로 */
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
})
onMounted(() => fill(props.applied))
</script>
<template>
<div
ref="rootRef"
class="condition-fields"
>
<template
v-for="item in TEXT_FIELDS"
:key="item.field"
>
<label
:for="fieldId(item.field)"
class="condition-fields__label"
>{{ item.label }}</label>
<input
:id="fieldId(item.field)"
v-model="draft[item.field]"
class="form-input condition-fields__control"
type="text"
:maxlength="MAIL_SEARCH_MAX_LENGTH"
:placeholder="item.placeholder"
autocomplete="off"
:data-search-condition="item.field"
@keydown="onTextKeydown"
>
</template>
<label
:for="fieldId('period')"
class="condition-fields__label condition-fields__label--top"
>{{ SEARCH_CONDITIONS_TEXT.period }}</label>
<MailSearchPeriodField
ref="periodRef"
:select-id="fieldId('period')"
:period="draft.period"
:since="draft.since"
:until="draft.until"
:problem="periodProblem"
@period="changePeriod"
@day="changeDay"
/>
<span class="condition-fields__label">{{ SEARCH_CONDITIONS_TEXT.attachment }}</span>
<label class="condition-fields__check">
<input
v-model="draft.hasAttachment"
type="checkbox"
data-search-condition="attachment"
>
<span>{{ SEARCH_CONDITIONS_TEXT.attachmentOnly }}</span>
</label>
</div>
</template>
<style scoped>
/* 이름 76 · 칸 (디자인 원본 Search14) */
.condition-fields {
display: grid;
grid-template-columns: 4.75rem minmax(0, 1fr);
align-items: center;
gap: var(--space-10) var(--space-12);
}
.condition-fields__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 기간 — 칸 아래에 날짜 · 안내가 더해져도 이름은 첫 줄(기간 고르기)에 맞춘다 */
.condition-fields__label--top {
align-self: start;
display: flex;
align-items: center;
min-height: var(--size-filter-control);
}
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
.condition-fields__control {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.condition-fields__check {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-filter-control);
cursor: pointer;
}
.condition-fields__check input {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
// 조건이 걸린 검색 결과의 조건 줄 (디자인 원본 Search14 "조건으로 찾음") — 찾을 곳 줄 아래 한 줄(최소 40)
// "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기](36 — 안내 줄 단추의 밀도 예외 --size-notice-action).
// 조건은 쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다. 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"),
// 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (mail-search.ts)
// [조건 지우기]는 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
import { computed } from 'vue'
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
}
const props = defineProps<Props>()
const clock = useClock()
const authStore = useAuthStore()
const { clearConditions } = useMailSearchNavigation()
const summary = computed(() => searchConditionsSummary(props.conditions, new Date(clock.now()), authStore.user))
</script>
<template>
<div
class="search-condition-line"
data-search-condition-line
>
<span class="search-condition-line__text">{{ summary }}</span>
<button
type="button"
class="search-condition-line__clear"
data-search-condition-line-clear
@click="clearConditions"
>
{{ SEARCH_CONDITION_LINE_TEXT.clear }}
</button>
</div>
</template>
<style scoped>
.search-condition-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-8);
min-height: var(--size-row-min);
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-16);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.search-condition-line__text {
min-width: 0;
font-size: var(--font-size-label);
color: var(--color-text-strong);
overflow-wrap: anywhere;
}
/* 36 — 안내 줄 단추의 밀도 예외 (tokens.css) */
.search-condition-line__clear {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.search-condition-line__clear:hover {
background: var(--color-brand-subtle);
}
</style>
@@ -0,0 +1,168 @@
<script setup lang="ts">
// 「조건으로 찾기」 패널 (디자인 원본 Search14 "조건 열림" · README 「14단계 › 검색 · 조건 검색 · 강조」) — 검색 칸 바로 아래, 검색 칸 폭.
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
import { computed, ref } from 'vue'
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { SearchConditions } from '@/shared/utils/mail-search'
interface Props {
/** 패널의 id — [조건] 단추가 aria-controls 로 가리킨다 */
panelId: string
/** 지금 걸린 조건 — 열 때 칸을 채운다 (없으면 빈 칸) */
applied: SearchConditions | null
}
interface Emits {
(e: 'apply', conditions: SearchConditions): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const titleId = computed(() => `${props.panelId}-title`)
const fieldsRef = ref<InstanceType<typeof MailSearchConditionFields> | null>(null)
/** [찾기] — 기간이 잘못됐으면 넘기지 않는다 (칸이 알린다) */
async function apply() {
const conditions = await fieldsRef.value?.take()
if (conditions) emit('apply', conditions)
}
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
function clearFields() {
fieldsRef.value?.clear()
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || isImeComposing(event)) return
event.preventDefault()
event.stopPropagation()
emit('close')
}
defineExpose({
apply,
/** 연 뒤 첫 칸(보낸 사람)으로 */
focusFirst: () => fieldsRef.value?.focusFirst(),
})
</script>
<template>
<div
:id="panelId"
class="search-conditions"
role="group"
:aria-labelledby="titleId"
data-search-conditions
@keydown="onKeydown"
>
<h2
:id="titleId"
class="search-conditions__title"
>
{{ SEARCH_CONDITIONS_TEXT.title }}
</h2>
<MailSearchConditionFields
ref="fieldsRef"
:id-prefix="panelId"
:applied="applied"
@submit="apply"
/>
<p class="search-conditions__hint">
{{ SEARCH_CONDITIONS_TEXT.hint }}
</p>
<div class="search-conditions__actions">
<button
type="button"
class="search-conditions__button"
data-search-conditions-clear
@click="clearFields"
>
{{ SEARCH_CONDITIONS_TEXT.clear }}
</button>
<button
type="button"
class="search-conditions__button search-conditions__button--primary"
data-search-conditions-apply
@click="apply"
>
{{ SEARCH_CONDITIONS_TEXT.apply }}
</button>
</div>
</div>
</template>
<style scoped>
/* 검색 칸 바로 아래 · 검색 칸 폭 — 위 16 · 좌우 18 · 아래 14, 칸 사이 14 (디자인 원본 Search14) */
.search-conditions {
position: absolute;
top: calc(100% + var(--space-6));
left: calc(var(--border-width) * -2);
right: calc(var(--border-width) * -2);
z-index: 20;
display: flex;
flex-direction: column;
gap: var(--space-14);
padding: var(--space-16) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-dialog);
color: var(--color-text);
}
.search-conditions__title {
margin: 0;
font-size: var(--font-size-emphasis);
font-weight: 700;
}
.search-conditions__hint {
margin: 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.search-conditions__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-8);
}
/* 단추 높이 40 — 칸과 같은 밀도 예외 */
.search-conditions__button {
min-height: var(--size-filter-control);
padding: 0 var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.search-conditions__button:hover {
background: var(--color-surface-muted);
}
.search-conditions__button--primary {
padding: 0 var(--space-18);
border-color: var(--color-brand);
background: var(--color-brand);
color: var(--color-on-brand);
}
.search-conditions__button--primary:hover {
border-color: var(--color-brand-strong);
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
// 검색 칸 안 오른쪽(✕ 앞)의 [조건] (14단계 — 디자인 원본 Search14): 높이 32 · 밀도 예외(--size-compact-control — 새 토큰 없이 다시 쓴다).
// 「조건으로 찾기」 패널을 열고 닫는다(aria-expanded · 열린 동안만 aria-controls). 패널이 열렸거나 조건이 걸려 있으면 청록 바탕이고,
// 걸려 있으면 화면 낭독기에 ", 조건이 걸려 있음"을 더한다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
interface Props {
/** 패널이 열렸다 */
expanded: boolean
/** 조건이 걸려 있다 */
applied: boolean
/** 패널의 id */
panelId: string
}
interface Emits {
(e: 'toggle'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
defineExpose({
focus: () => buttonRef.value?.focus(),
})
</script>
<template>
<button
ref="buttonRef"
type="button"
:class="['conditions-toggle', { 'is-on': expanded || applied }]"
:title="SEARCH_CONDITIONS_TEXT.toggleTitle"
:aria-expanded="expanded"
:aria-controls="expanded ? panelId : undefined"
data-search-conditions-toggle
@click="emit('toggle')"
>
<AppIcon
name="sliders"
:size="14"
:stroke-width="1.9"
/>
<span>{{ SEARCH_CONDITIONS_TEXT.toggle }}</span>
<span
v-if="applied"
class="visually-hidden"
>, {{ SEARCH_CONDITIONS_TEXT.toggleApplied }}</span>
</button>
</template>
<style scoped>
/* 높이 32 · 밀도 예외(--size-compact-control), 패널이 열렸거나 조건이 걸리면 청록 바탕 (디자인 원본 Search14) */
.conditions-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: var(--space-4);
height: var(--size-compact-control);
padding: 0 var(--space-9);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-label);
font-weight: 600;
}
.conditions-toggle:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
.conditions-toggle.is-on {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
}
</style>
@@ -1,30 +1,65 @@
<script setup lang="ts">
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
// 검색어와 조건의 낱말이 한도를 넘는 주소(손으로 고친 주소 — 14단계)면 찾지 않았다 — "찾을 말이 너무 많습니다"와 줄이는 방법 (tooManyWords)
import { computed } from 'vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_SEARCH_MAX_WORDS, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
interface Props {
/** 검색어 — 조건만으로 찾았으면 빈 글 */
query: string
/** 걸린 조건이 있다 — 없으면 조건을 줄이라고 하지 않는다 */
hasConditions?: boolean
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
offerAll: boolean
/** 낱말 단위로 찾는 동안(지난 메일 색인을 준비하는 중)이다 — 그 한계를 알린다 */
wordLimit?: boolean
/** 검색어와 조건의 낱말이 한도를 넘어 찾지 않았다 */
tooManyWords?: boolean
}
interface Emits {
(e: 'search-all'): void
}
defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
hasConditions: false,
wordLimit: false,
tooManyWords: false,
})
const emit = defineEmits<Emits>()
const title = computed(() =>
props.tooManyWords ? SEARCH_NOTICE_TEXT.tooManyWordsTitle : MAIL_SEARCH_TEXT.emptyTitle(props.query),
)
const hint = computed(() => {
if (props.tooManyWords) return SEARCH_NOTICE_TEXT.tooManyWordsHint(MAIL_SEARCH_MAX_WORDS)
if (!props.query) return MAIL_SEARCH_TEXT.emptyConditionsHint
return props.hasConditions ? MAIL_SEARCH_TEXT.emptyHint : MAIL_SEARCH_TEXT.emptyWordsHint
})
</script>
<template>
<StateMessage
class="search-empty"
icon="search"
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
:hint="MAIL_SEARCH_TEXT.emptyHint"
:icon="tooManyWords ? 'alert-circle' : 'search'"
:title="title"
:hint="hint"
:data-search-too-many-words="tooManyWords ? '' : undefined"
>
<p
v-if="wordLimit"
class="search-empty__limit"
data-search-word-limit
>
{{ MAIL_SEARCH_TEXT.wordLimitHint }}
</p>
<button
v-if="offerAll"
type="button"
@@ -41,9 +76,18 @@ const emit = defineEmits<Emits>()
padding: var(--space-72) var(--space-28);
}
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
.search-empty :deep(.state-message__hint) {
/* 도움말은 좁게 두 줄로 (디자인 원본 300px) — 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
.search-empty :deep(.state-message__hint),
.search-empty__limit {
max-width: 18.75rem;
word-break: keep-all;
overflow-wrap: anywhere;
}
.search-empty__limit {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.search-empty__all {
@@ -2,6 +2,7 @@
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
// 통 수는 서버의 total 이다 — null 이면(1,000통 넘음 · 다 세지 못함 — 14단계) 수를 보이지 않는다. 조건만으로 찾았으면 검색어 없이 수만
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -26,12 +27,12 @@ const mailStore = useMailStore()
const { originMailbox, closeSearch } = useMailSearchNavigation()
const countText = computed(() => {
const query = mailStore.search?.q
if (!query) return ''
const search = mailStore.search
if (!search) return ''
const query = search.q
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
const total = mailStore.listStatus === 'ready' ? (mailStore.pageInfo?.total ?? null) : null
return total === null ? MAIL_SEARCH_TEXT.quoted(query) : MAIL_SEARCH_TEXT.count(query, formatCount(total))
})
const closeTitle = computed(() => {
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 이어 찾을 것이 남았는데 아직 하나도 찾지 못한 검색 (14단계 — 계약 2절 "검색 — 14단계" › hasNext 인데 빈 쪽)
// 서버는 한 요청에 확인하는 메일 수에 상한이 있다 — 목록 스토어가 정한 횟수(MAIL_SEARCH_FILL_MAX_REQUESTS)까지 이어 묻고도 못 찾았으면
// 결과 없음 대신 이 안내와 [더 찾기]를 보인다. 누르면 같은 횟수만큼 더 이어 묻는다(목록 스토어의 loadMore). 찾는 동안 단추는 aria-busy
import StateMessage from '@/components/feedback/StateMessage.vue'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
interface Props {
busy: boolean
}
interface Emits {
(e: 'more'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
function onMore() {
if (!props.busy) emit('more')
}
</script>
<template>
<StateMessage
class="search-more"
icon="search"
:title="SEARCH_NOTICE_TEXT.partialTitle"
:hint="SEARCH_NOTICE_TEXT.partialHint"
>
<button
type="button"
class="search-more__button"
:aria-busy="busy"
:aria-disabled="busy ? 'true' : undefined"
data-search-more
@click="onMore"
>
{{ busy ? SEARCH_NOTICE_TEXT.moreBusy : SEARCH_NOTICE_TEXT.more }}
</button>
</StateMessage>
</template>
<style scoped>
.search-more {
padding: var(--space-72) var(--space-28);
}
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다(keep-all · 긴 영문은 anywhere) */
.search-more :deep(.state-message__hint) {
max-width: 18.75rem;
word-break: keep-all;
overflow-wrap: anywhere;
}
.search-more__button {
margin-top: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.search-more__button:hover:not([aria-disabled='true']) {
background: var(--color-brand-subtle);
}
.search-more__button[aria-disabled='true'] {
color: var(--color-text-muted);
cursor: default;
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
// 검색 결과 머리 아래 안내 줄 (디자인 원본 Search14 "검색 준비 중" — 옅은 청록 바탕 · 12.5px · info 아이콘) — 14단계
// - 검색 준비 중: 지난 메일의 색인을 만드는 동안(GET /search-index 의 building) — 그동안의 검색은 낱말이 통째로 같을 때만 맞는다
// - 한 글자 안내: 낱말이 모두 한 글자라 본문은 찾지 않았을 때(색인 방식 — 서버와 같은 낱말 나누기, search-match.ts 의 isSingleCharSearch)
// 둘을 감싼 칸이 role="status" 라 줄이 생기면 화면 낭독기가 읽는다 (감싼 칸은 검색 화면이면 늘 있다)
import AppIcon from '@/components/AppIcon.vue'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
interface Props {
indexing: boolean
singleChar: boolean
}
defineProps<Props>()
</script>
<template>
<div
class="search-notices"
role="status"
>
<p
v-if="indexing"
class="search-notices__line"
data-search-notice="indexing"
>
<AppIcon
name="info"
:size="15"
:stroke-width="1.9"
class="search-notices__icon"
/>
<span>{{ SEARCH_NOTICE_TEXT.indexing }}</span>
</p>
<p
v-if="singleChar"
class="search-notices__line"
data-search-notice="single-char"
>
<AppIcon
name="info"
:size="15"
:stroke-width="1.9"
class="search-notices__icon"
/>
<span>{{ SEARCH_NOTICE_TEXT.singleChar }}</span>
</p>
</div>
</template>
<style scoped>
/* 옅은 청록 바탕 · 아래 선 #E3EEEF · 위아래 10 · 좌우 16 (디자인 원본 Search14) */
.search-notices__line {
display: flex;
align-items: flex-start;
gap: var(--space-8);
margin: 0;
padding: var(--space-10) var(--space-16);
border-bottom: var(--border-width) solid var(--color-brand-tint-border);
background: var(--color-brand-tint);
font-size: var(--font-size-label);
color: var(--color-text-strong);
/* 한국어는 낱말 가운데에서 줄을 바꾸지 않는다 */
word-break: keep-all;
overflow-wrap: anywhere;
}
.search-notices__icon {
flex: 0 0 auto;
margin-top: var(--space-2);
color: var(--color-brand);
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 「조건으로 찾기」 패널의 기간 칸 (14단계 — 디자인 원본 Search14): 기간 고르기(전체 기간 · 최근 1주 · 1달 · 3달 · 1년 · 날짜 고르기)와
// 날짜 고르기의 시작 ~ 끝. 날짜 칸은 고를 수 있는 날짜(SEARCH_DAY_MIN ~ SEARCH_DAY_MAX)와 서로의 날짜로 범위를 둔다 —
// 손으로 친 날짜는 [찾기] 때 다시 본다(useSearchConditionsDraft 의 take).
// 잘못(problem)은 칸 아래에 알리고(role=alert) 그 날짜 칸을 aria-invalid 로 — 넘기지 못했으면 부르는 쪽이 focusInvalid() 로 그 칸에 초점을 둔다.
// 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { SearchPeriodProblem } from '@/composables/useSearchConditionsDraft'
import {
SEARCH_CONDITIONS_TEXT,
SEARCH_DAY_MAX,
SEARCH_DAY_MIN,
SEARCH_PERIOD_LABELS,
SEARCH_PERIOD_ORDER,
type SearchPeriod,
} from '@/shared/constants/mail-search'
import { isSearchDay } from '@/shared/utils/mail-search'
interface Props {
/** 기간 고르기의 id — 부르는 쪽의 이름(label for)이 가리킨다 */
selectId: string
period: SearchPeriod
/** 날짜 고르기의 시작 · 끝 (YYYY-MM-DD, 비면 '') */
since: string
until: string
/** 알릴 잘못 — 없으면 null */
problem: SearchPeriodProblem | null
}
interface Emits {
(e: 'period', period: SearchPeriod): void
(e: 'day', edge: 'since' | 'until', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const sinceRef = ref<HTMLInputElement | null>(null)
const untilRef = ref<HTMLInputElement | null>(null)
const periodModel = computed<SearchPeriod>({
get: () => props.period,
set: (value) => emit('period', value),
})
const sinceModel = computed<string>({
get: () => props.since,
set: (value) => emit('day', 'since', value),
})
const untilModel = computed<string>({
get: () => props.until,
set: (value) => emit('day', 'until', value),
})
const errorId = computed(() => `${props.selectId}-error`)
const errorText = computed(() =>
props.problem === 'range' ? SEARCH_CONDITIONS_TEXT.periodRange : SEARCH_CONDITIONS_TEXT.periodOrder,
)
/** 고를 수 없는 날짜 — 비었으면 아니다 */
const outOfRange = (value: string) => value !== '' && !isSearchDay(value)
/** 순서가 거꾸로면 두 칸 모두, 고를 수 없는 날짜면 그 칸만 */
const sinceInvalid = computed(
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.since)),
)
const untilInvalid = computed(
() => props.problem === 'order' || (props.problem === 'range' && outOfRange(props.until)),
)
/** 날짜 칸의 범위 — 고를 수 있는 날짜 안에서 시작은 끝을, 끝은 시작을 넘지 않게 */
const sinceMax = computed(() => (isSearchDay(props.until) ? props.until : SEARCH_DAY_MAX))
const untilMin = computed(() => (isSearchDay(props.since) ? props.since : SEARCH_DAY_MIN))
defineExpose({
/** 넘기지 못한 까닭의 칸으로 — 고를 수 없는 시작 날짜면 시작, 그 밖은 끝 날짜 */
focusInvalid: () => {
if (props.problem === 'range' && outOfRange(props.since)) sinceRef.value?.focus()
else untilRef.value?.focus()
},
})
</script>
<template>
<div class="period-field">
<div class="period-field__row">
<select
:id="selectId"
v-model="periodModel"
class="form-input period-field__control period-field__select"
data-search-condition="period"
>
<option
v-for="option in SEARCH_PERIOD_ORDER"
:key="option"
:value="option"
>
{{ SEARCH_PERIOD_LABELS[option] }}
</option>
</select>
<span
v-if="period === 'custom'"
class="period-field__dates"
>
<input
ref="sinceRef"
v-model="sinceModel"
class="form-input period-field__control"
type="date"
:min="SEARCH_DAY_MIN"
:max="sinceMax"
:aria-label="SEARCH_CONDITIONS_TEXT.since"
:aria-invalid="sinceInvalid ? 'true' : 'false'"
:aria-describedby="sinceInvalid ? errorId : undefined"
data-search-condition="since"
>
<span
class="period-field__tilde"
aria-hidden="true"
>~</span>
<input
ref="untilRef"
v-model="untilModel"
class="form-input period-field__control"
type="date"
:min="untilMin"
:max="SEARCH_DAY_MAX"
:aria-label="SEARCH_CONDITIONS_TEXT.until"
:aria-invalid="untilInvalid ? 'true' : 'false'"
:aria-describedby="untilInvalid ? errorId : undefined"
data-search-condition="until"
>
</span>
</div>
<p
v-if="problem"
:id="errorId"
class="form-message form-message--error"
role="alert"
:data-search-period-problem="problem"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ errorText }}</span>
</p>
</div>
</template>
<style scoped>
.period-field {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.period-field__row {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
}
/* 칸 높이 40 — 밀도 예외 (필터 조건 칸과 같다) */
.period-field__control {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.period-field__select {
min-width: 8.75rem;
}
.period-field__dates {
min-width: 0;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6);
}
.period-field__tilde {
color: var(--color-text-muted);
}
</style>
@@ -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,6 +8,10 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
// ("휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서, useBulkMailActions 의 conversationFrom).
// 분할 보기는 보이는 글만 "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'
@@ -16,6 +20,7 @@ import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
import { useBulkMailActions } from '@/composables/useBulkMailActions'
import type { IconName } from '@/shared/constants/icons'
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount } from '@/shared/utils/format'
import { useMailStore } from '@/stores/mail.store'
@@ -70,7 +75,16 @@ const rovingTool = computed<ToolId>(() => {
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
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[]>(() => [
@@ -162,7 +176,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"
@@ -231,7 +251,8 @@ watch(disabledTools, (disabled) => {
</div>
<MailPurgeDialog
:open="purgeOpen"
:count="mailStore.checkedCount"
:count="mailStore.checkedActionIds.length"
:from="bulk.conversationFrom.value"
id-prefix="bulk-purge"
@confirm="onPurgeConfirm"
@cancel="purgeOpen = false"
@@ -6,16 +6,18 @@
// 대용량 첨부(11단계 — MessageDetail.largeFiles)도 같은 칸에 첨부처럼 — 「첨부 파일 N개」는 둘을 합한 수 (MessageLargeFileItem).
// 보낸 사람 자신(링크 주인)에게는 칸 아래에 링크 줄 — 받은 수 · [링크 복사] · [링크 끊기] (MessageLargeFileLinkBar — 끝난 링크는 없다)
// 보안 검사 중인 대용량 첨부가 있는 동안은 상태를 가끔 다시 묻고, 막힌 [다운로드]를 누르면 한 번 바로 묻는다 (useLargeFileScanRefresh)
// 검색 결과에서 연 메일(14단계)은 첨부 이름의 맞은 말을 <mark> 로 (nameHighlights — 첨부 id → 맞은 곳, 맞은 첨부만)
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MessageLargeFileItem from '@/components/mail/MessageLargeFileItem.vue'
import MessageLargeFileLinkBar from '@/components/mail/MessageLargeFileLinkBar.vue'
import { useClock } from '@/composables/useClock'
import { useLargeFileScanRefresh } from '@/composables/useLargeFileScanRefresh'
import { useMail } from '@/composables/useMail'
import type { MessageLargeFile } from '@/shared/types/large-file'
import type { Attachment } from '@/shared/types/mail'
import type { Attachment, SearchRange } from '@/shared/types/mail'
import { formatFileSize } from '@/shared/utils/format'
import { isEndedLinkStatus } from '@/shared/utils/large-file-status'
import { useMailStore } from '@/stores/mail.store'
@@ -25,10 +27,13 @@ interface Props {
attachments: Attachment[]
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 온다 */
largeFiles?: MessageLargeFile[]
/** 첨부 이름의 검색에서 맞은 곳 — 서버의 searchHighlight.attachments (14단계) */
nameHighlights?: Readonly<Record<string, readonly SearchRange[]>> | null
}
const props = withDefaults(defineProps<Props>(), {
largeFiles: () => [],
nameHighlights: null,
})
const { attachmentDownloadUrl, downloadAttachment } = useMail()
@@ -126,7 +131,12 @@ function onDownload(event: MouseEvent, attachmentId: string) {
<AppIcon name="file" />
</span>
<div class="attachments__meta">
<span class="attachments__name">{{ attachment.name }}</span>
<span class="attachments__name">
<HighlightedText
:text="attachment.name"
:ranges="nameHighlights?.[attachment.id] ?? null"
/>
</span>
<span class="attachments__size">{{ formatFileSize(attachment.size) }}</span>
</div>
<a
@@ -0,0 +1,951 @@
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 { CLOCK, type Clock } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
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 {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageReader from './MessageReader.vue'
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화" · 계약 2절 "대화 — 14단계")
// 시각은 지역 시각으로 만든다 — 시간대(KST · UTC)와 상관없이 같은 글이 된다. 시간은 손으로 돌리는 시계로 넘긴다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const MAILBOXES = [INBOX, SENT, DRAFTS]
/** 2026년 11월 6일(금) 오후 3시 — 브라우저 시간대 */
const NOW = new Date(2026, 10, 6, 15).getTime()
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다. 단가와 기간을 정리해서 보내 드리겠습니다.',
receivedAt: at(2026, 9, 29, 9, 30),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary, partial: Partial<MessageDetail> = {}): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
...partial,
}
}
/** 내가 보낸 처음 메일 · 이도윤의 회신(안 읽음) · 지금 보는 메일 */
const ORIGINAL = summary('m1', {
mailboxIds: ['sent'],
from: { name: '김아라', email: USER.email },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: '유지보수 계약 갱신 건',
preview: '다음 달에 끝나는 유지보수 계약을 1년 더 이어 가려고 합니다.',
receivedAt: at(2026, 9, 27, 14, 10),
})
const REPLY = summary('m2', { isRead: false })
const CURRENT = summary('m3', {
subject: 'Re: 유지보수 계약 갱신 건',
receivedAt: at(2026, 10, 6, 10, 42),
hasAttachment: true,
})
const THREAD = [ORIGINAL, REPLY, CURRENT]
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((settle) => {
resolve = settle
})
return { promise, resolve }
}
interface Server {
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(NOW)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function serve(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
if (server.detail) return server.detail(request)
const id = request.url.split('/').pop() ?? ''
return ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
}
return ok(null)
})
}
async function mountReader(
options: {
path?: string
source?: MailListSource
current?: MessageSummary
layout?: 'split' | 'list'
/** 손으로 돌리는 시계 대신 줄 시계 (시계가 뒤로 가는 경우) */
clock?: Clock
} = {},
) {
const store = useMailStore()
const current = options.current ?? CURRENT
store.mailboxes = MAILBOXES
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [REPLY, CURRENT]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = detail(current, {
attachments: [{ id: 'a1', name: '유지보수_계약서_수정본.docx', type: 'application/msword', size: 52_000 }],
})
store.selectedId = current.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push(options.path ?? `/mail/inbox/${current.id}`)
await router.isReady()
const wrapper = mount(MessageReader, {
props: { layout: options.layout ?? 'split' },
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: options.clock ?? clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router, store }
}
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
const section = (wrapper: VueWrapper) => wrapper.get('[data-conversation]')
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
describe('칸 모양 (디자인 원본 Thread14)', () => {
it('본문 · 첨부 아래 "같은 대화 3통" — 테두리 상자 안의 줄, 오래된 차례', async () => {
serve()
const { wrapper } = await mountReader()
expect(conversationRequests().map((request) => [request.url, request.params])).toEqual([
['/messages/m3/conversation', { limit: MAIL_CONVERSATION_PAGE_SIZE }],
])
// 본문 · 첨부 다음이 칸 — 화면 낭독용 상태 영역은 늘 그 뒤에 있다(보이지 않는다)
const content = wrapper.get('.reader__content')
const order = [...content.element.children].map((child) => child.className.split(' ')[0])
expect(order.slice(-4)).toEqual(['mail-body-frame', 'attachments', 'conversation', 'visually-hidden'])
expect(content.element.lastElementChild?.getAttribute('role')).toBe('status')
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(section(wrapper).get('h3').attributes('tabindex')).toBe('-1')
expect(section(wrapper).attributes('aria-labelledby')).toBe(section(wrapper).get('h3').attributes('id'))
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual([
'm1',
'm2',
'm3',
])
})
it('내가 보낸 메일은 "나" + 회색 "보낸편지함"(머리글자도 회색) · 다른 줄은 이름 · 미리 보기 한 줄 · 시각', async () => {
serve()
const { wrapper } = await mountReader()
const mine = rowOf(wrapper, 'm1')
expect(mine.get('.conversation-row__who').text()).toBe('나')
expect(mine.get('.conversation-row__mailbox').text()).toBe('보낸편지함')
expect(mine.get('.conversation-row__avatar').text()).toBe('김')
expect(mine.get('.conversation-row__avatar').classes()).toContain('conversation-row__avatar--mine')
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
const other = rowOf(wrapper, 'm2')
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
// 강조 · 뱃지 없이 글로만
expect(section(wrapper).findAll('mark')).toHaveLength(0)
})
it('지금 보는 메일의 줄은 "지금 보고 있는 메일"(aria-current)이고 누를 수 없다 — 다른 줄은 접힌 단추(aria-expanded="false")', async () => {
serve()
const { wrapper } = await mountReader()
const current = rowOf(wrapper, 'm3')
expect(current.find('button').exists()).toBe(false)
expect(current.get('[aria-current="true"]').text()).toContain('지금 보고 있는 메일')
expect(current.get('time').text()).toBe('오늘 오전 10:42')
for (const id of ['m1', 'm2']) {
const button = rowOf(wrapper, id).get('button')
expect(button.attributes('aria-expanded')).toBe('false')
expect(button.attributes('aria-controls')).toBeUndefined()
}
})
it('한 통뿐이면 칸이 없다', async () => {
serve({ conversation: () => page([CURRENT]) })
const { wrapper } = await mountReader()
expect(conversationRequests()).toHaveLength(1)
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
})
it('통 수를 모르면(남은 쪽이 있고 total null) 수 없이 "같은 대화" — 상자 아래 [더 보기]', async () => {
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
serve({
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
})
const { wrapper } = await mountReader()
expect(section(wrapper).get('h3').text()).toBe('같은 대화')
const more = wrapper.get('[data-conversation-more]')
expect(more.text()).toBe('더 보기')
await more.trigger('click')
expect(more.attributes('aria-busy')).toBe('true')
expect(more.text()).toBe('불러오는 중…')
await flushPromises()
expect(conversationRequests().at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' })
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe(`같은 대화 ${MAIL_CONVERSATION_PAGE_SIZE + 1}통`)
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중"', async () => {
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
serve({
conversation: () =>
page([
...THREAD,
summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } }),
summary('s2', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'undo', sendAt } }),
]),
})
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 's1').get('time').text()).toBe('예약 · 11월 7일 오전 9:00')
expect(rowOf(wrapper, 's1').get('time').classes()).toContain('is-pending')
expect(rowOf(wrapper, 's2').get('time').text()).toBe('보내는 중')
expect(rowOf(wrapper, 's2').get('time').attributes('datetime')).toBe(sendAt)
})
})
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()
const { wrapper, store } = await mountReader()
const button = rowOf(wrapper, 'm2').get('button')
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('true')
const panelId = button.attributes('aria-controls')
expect(panelId).toBe('conversation-panel-m2')
expect(rowOf(wrapper, 'm2').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
await flushPromises()
const panel = wrapper.get(`#${panelId}`)
expect(panel.findAll('.conversation-panel__meta').map((line) => line.text())).toEqual([
'받는 사람 김아라 <ara.kim@arasystem.test>',
'제목 회신: 유지보수 계약 갱신 건',
])
const frame = panel.get('iframe')
expect(frame.attributes('srcdoc')).toContain('m2 본문')
expect(frame.attributes('title')).toBe('메일 본문: 이도윤, 10월 29일 (목) 오전 9:30')
expect(panel.get('[data-conversation-reply]').attributes('href')).toBe('/mail/compose?replyTo=m2')
expect(panel.get('[data-conversation-reply]').text()).toBe('답장')
expect(panel.get('[data-conversation-open]').attributes('href')).toBe('/mail/inbox/m2')
expect(panel.get('[data-conversation-open]').text()).toBe('이 메일만 열기')
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
expect(requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data])).toEqual([
['/messages/m2', { isRead: true }],
])
// 목록의 그 줄도 읽음으로 — 읽기 화면에서 연 것과 같은 길
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('false')
expect(wrapper.find(`#${panelId}`).exists()).toBe(false)
})
it('내가 보낸 메일의 [이 메일만 열기]는 그 메일이 든 보낸편지함으로 — 누르면 기록에 쌓아 옮긴다', async () => {
serve()
const { wrapper, router } = await mountReader()
const push = vi.spyOn(router, 'push')
await rowOf(wrapper, 'm1').get('button').trigger('click')
await flushPromises()
const open = rowOf(wrapper, 'm1').get('[data-conversation-open]')
expect(open.attributes('href')).toBe('/mail/sent/m1')
await open.trigger('click')
await flushPromises()
expect(push).toHaveBeenCalled()
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/m1')
})
it('임시 저장본 · 예약한 메일에는 [답장]이 없다 — 임시 저장본의 [이 메일만 열기]는 쓰기 화면에서 이어 쓴다', async () => {
const sendAt = new Date(2026, 10, 7, 9, 0).toISOString()
const draft = summary('d1', { mailboxIds: ['drafts'], from: USER })
const scheduled = summary('s1', { mailboxIds: ['sent'], from: USER, pendingSend: { kind: 'scheduled', sendAt } })
serve({ conversation: () => page([...THREAD, draft, scheduled]) })
const { wrapper } = await mountReader()
expect(rowOf(wrapper, 'd1').get('.conversation-row__mailbox').text()).toBe('임시보관함')
await rowOf(wrapper, 'd1').get('button').trigger('click')
await rowOf(wrapper, 's1').get('button').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'd1').find('[data-conversation-reply]').exists()).toBe(false)
expect(rowOf(wrapper, 'd1').get('[data-conversation-open]').attributes('href')).toBe('/mail/compose?draft=d1')
expect(rowOf(wrapper, 's1').find('[data-conversation-reply]').exists()).toBe(false)
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() ?? '')))) })
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(useToastStore().toasts).toEqual([])
// 읽지 못했으니 읽음으로 바꾸지 않는다
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
failing = false
await error.get('button').trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
})
})
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
const draft = summary('d1', {
mailboxIds: ['drafts'],
from: { name: '김아라', email: USER.email },
isRead: false,
receivedAt: at(2026, 10, 6, 11, 0),
})
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
const { wrapper } = await mountReader()
const unread = rowOf(wrapper, 'm2')
expect(unread.classes()).toContain('is-unread')
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
for (const id of ['m1', 'm3', 'd1']) {
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
}
})
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
const reply = deferred()
serve({ patch: () => reply.promise })
const { wrapper } = await mountReader()
const row = () => rowOf(wrapper, 'm2')
await row().get('button').trigger('click')
await flushPromises()
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
'/messages/m2',
])
expect(row().classes()).not.toContain('is-unread')
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
reply.resolve(fail(503, 'MAIL_001'))
await flushPromises()
expect(row().classes()).toContain('is-unread')
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
const first = deferred()
let firstBulk: Promise<MockResult> | null = first.promise
serve({
patch: (request) => {
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
firstBulk = null
return reply
},
})
const { wrapper, store } = await mountReader()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
await marking
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
store.clearChecked()
store.toggleCheck('m3')
await store.patchChecked({ isRead: false })
await flushPromises()
const current = rowOf(wrapper, 'm3')
expect(current.classes()).toContain('is-unread')
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
await store.refreshConversation()
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
})
})
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
let failing = true
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : page(THREAD)) })
const { wrapper } = await mountReader()
expect(wrapper.find('#reader-subject').exists()).toBe(true)
const notice = wrapper.get('[data-conversation-failed]')
expect(notice.text()).toContain('같은 대화를 불러오지 못했습니다.')
expect(notice.text()).toContain('문의 코드 3f1c0a2e')
expect(useToastStore().toasts).toEqual([])
// 화면 낭독용 상태 영역은 늘 있고 글만 바뀐다 — 안내 줄 자체에는 role 이 없다(두 번 읽지 않게)
expect(notice.attributes('role')).toBeUndefined()
const status = wrapper.get('[data-conversation-status]')
expect(status.attributes('role')).toBe('status')
expect(status.text()).toBe('같은 대화를 불러오지 못했습니다.')
failing = false
await notice.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-conversation-failed]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(wrapper.get('[data-conversation-status]').text()).toBe('')
})
it('없는 메일(404 RES_001 — 이 경로가 없는 옛 서버 포함) · 모양이 틀린 응답이면 칸 없이', async () => {
serve({ conversation: () => fail(404, 'RES_001') })
const missing = await mountReader()
expect(missing.wrapper.find('[data-conversation]').exists()).toBe(false)
expect(missing.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
missing.wrapper.unmount()
setActivePinia((pinia = createPinia()))
useAuthStore().markAuthenticated(USER)
serve({ conversation: () => ok({ usedBytes: 0 }) })
const odd = await mountReader()
expect(odd.wrapper.find('[data-conversation]').exists()).toBe(false)
expect(odd.wrapper.find('[data-conversation-failed]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('보호 화면을 쓸 수 없는 세션(로그인 전 · 제한된 세션)에는 묻지 않는다', async () => {
useAuthStore().clearAuth()
serve()
await mountReader()
expect(conversationRequests()).toEqual([])
})
it('제한된 세션(새 비밀번호를 정하기 전 — 13단계)이면 묻지 않고, 제한이 풀리면 그때 받는다', async () => {
const auth = useAuthStore()
auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
serve()
const { wrapper } = await mountReader()
expect(auth.canUseApp).toBe(false)
expect(conversationRequests()).toEqual([])
auth.markAuthenticated(USER)
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
})
})
describe('다시 받기 — 바뀜 소식은 모아서 · 돌아오면 바로', () => {
it(`다시 받아 달라는 소식이 오면 다시 받는다 — 마지막으로 받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안이면 그때까지 모아 한 번`, async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
expect(conversationRequests()).toHaveLength(1)
clock.advance(1000)
store.requestConversationRefresh()
await flushPromises()
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
thread = [...THREAD, summary('m4', { mailboxIds: ['sent'], from: USER, receivedAt: at(2026, 10, 6, 14, 0) })]
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1000)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 4통')
// 간격이 지난 뒤의 소식은 바로
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(3)
})
it('받아 둔 대화로 돌아오면(쓰기 화면에서 답장을 보내고) 보이는 채로 바로 다시 받는다', async () => {
serve()
const first = await mountReader()
first.wrapper.unmount()
expect(conversationRequests()).toHaveLength(1)
const back = mount(MessageReader, {
attachTo: document.body,
global: { plugins: [pinia, first.router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(back)
expect(back.find('[data-conversation]').exists()).toBe(true)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
})
it('메일을 바꾸면 그 메일로 새로 받고, 앞 메일의 늦은 응답은 버린다', async () => {
const late = deferred()
serve({
conversation: ({ url }) =>
url === '/messages/m3/conversation' ? late.promise : page([summary('x1'), summary('x2', { subject: '다른 대화' })]),
})
const { wrapper, store } = await mountReader()
store.selected = detail(summary('x2', { subject: '다른 대화' }))
store.selectedId = 'x2'
await flushPromises()
late.resolve(page(THREAD))
await flushPromises()
expect(conversationRequests().map((request) => request.url)).toEqual([
'/messages/m3/conversation',
'/messages/x2/conversation',
])
expect(section(wrapper).findAll('ol > li').map((row) => row.attributes('data-conversation-row'))).toEqual(['x1', 'x2'])
})
})
describe('검색 결과에서 연 메일 (/mail/search/:id)', () => {
it('칸이 보이지만 강조가 없다 — 대화 · 펼친 본문 모두 검색 쿼리 없이 받는다', async () => {
serve()
const { wrapper, store } = await mountReader({
path: `/mail/search/m3?q=${encodeURIComponent('계약')}&in=all`,
source: { kind: 'search', q: '계약', scope: 'all' },
})
store.selectedHighlight = { q: '계약' }
expect(section(wrapper).get('h3').text()).toBe('같은 대화 3통')
expect(conversationRequests()[0]?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
expect(requests.find((request) => request.method === 'GET' && request.url === '/messages/m2')?.params).toEqual({})
expect(section(wrapper).findAll('mark')).toHaveLength(0)
// 받아 둔 결과에 있는 메일은 검색 결과 안에서 연다
expect(rowOf(wrapper, 'm2').get('[data-conversation-open]').attributes('href')).toBe(
`/mail/search/m2?q=${encodeURIComponent('계약')}&in=all`,
)
})
})
describe('초점 — 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면', () => {
const focusOn = (element: Element) => (element as HTMLElement).focus()
it('칸의 [다시 시도] — 또 못 받으면 새로 그린 [다시 시도]로, 받으면(받는 동안은 기다렸다가) 칸 제목(h3)으로', async () => {
let failing = true
const retried = deferred()
serve({ conversation: () => (failing ? fail(503, 'MAIL_001') : retried.promise) })
const { wrapper } = await mountReader()
focusOn(wrapper.get('[data-conversation-retry]').element)
await wrapper.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.get('[data-conversation-retry]').element)
failing = false
focusOn(wrapper.get('[data-conversation-retry]').element)
await wrapper.get('[data-conversation-retry]').trigger('click')
await flushPromises()
expect(document.activeElement).toBe(document.body)
retried.resolve(page(THREAD))
await flushPromises()
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
})
it('[더 보기] — 받는 동안은 그 단추에 남고(aria-busy), 마지막 쪽을 받아 단추가 사라지면 칸 제목으로', async () => {
const full = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - 1 }, (_, index) => summary(`p${index}`))
serve({
conversation: ({ params }) => (params.cursor ? page([REPLY], null, null) : page([...full, CURRENT], 'c2', null)),
})
const { wrapper } = await mountReader()
const more = wrapper.get('[data-conversation-more]')
focusOn(more.element)
await more.trigger('click')
expect(document.activeElement).toBe(more.element)
await flushPromises()
expect(wrapper.find('[data-conversation-more]').exists()).toBe(false)
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
})
it('펼친 줄의 [다시 시도] — 본문을 받으면 그 줄의 펼침 단추로', async () => {
let failing = true
serve({
detail: (request) => (failing ? fail(503, 'MAIL_001') : ok(detail(summary(request.url.split('/').pop() ?? '')))),
})
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
failing = false
const retry = rowOf(wrapper, 'm2').get('[data-conversation-detail-error] button')
focusOn(retry.element)
await retry.trigger('click')
await flushPromises()
expect(rowOf(wrapper, 'm2').find('iframe').exists()).toBe(true)
expect(document.activeElement).toBe(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
})
it('다시 받아 초점이 있던 줄이 빠지면 칸 제목으로, 칸이 없어지면(한 통만 남음) 읽기 화면의 본문으로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
async function refreshWith(next: MessageSummary[]) {
thread = next
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
}
focusOn(rowOf(wrapper, 'm2').get('[data-conversation-toggle]').element)
await refreshWith([ORIGINAL, CURRENT])
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(document.activeElement).toBe(section(wrapper).get('h3').element)
focusOn(rowOf(wrapper, 'm1').get('[data-conversation-toggle]').element)
await refreshWith([CURRENT])
expect(wrapper.find('[data-conversation]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('.reader__content iframe').element)
})
it('초점이 칸 밖에 있으면 다시 받아 줄이 빠져도 건드리지 않는다', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
const star = wrapper.get('.reader__star')
focusOn(star.element)
thread = [ORIGINAL, CURRENT]
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
store.requestConversationRefresh()
await flushPromises()
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(document.activeElement).toBe(star.element)
})
})
describe('다시 받기의 때 — 받는 중에 온 소식 · 시계가 뒤로 감', () => {
it('처음 받는 중에 온 소식은 버리지 않는다 — 받기가 끝난 때부터 간격을 세어 한 번 더 받는다', async () => {
const first = deferred()
let calls = 0
serve({
conversation: () => {
calls += 1
return calls === 1 ? first.promise : page(THREAD)
},
})
const { store } = await mountReader()
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
// 받는 동안 흐른 시간은 세지 않는다
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
first.resolve(page(THREAD))
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS - 1)
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(1)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
})
it('이 PC 시계가 뒤로 가도 간격보다 오래 기다리지 않는다', async () => {
let offset = 0
const skewed: Clock = {
now: () => clock.now() + offset,
setTimeout: (callback, ms) => clock.setTimeout(callback, ms),
clearTimeout: (timer) => clock.clearTimeout(timer),
}
serve()
const { store } = await mountReader({ clock: skewed })
offset = -60 * 60 * 1000
store.requestConversationRefresh()
await flushPromises()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await flushPromises()
expect(conversationRequests()).toHaveLength(2)
})
})
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const row = rowOf(wrapper, 'm2')
expect(row.findAll('[data-notice]').map((notice) => [notice.attributes('data-notice'), notice.text()])).toEqual([
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
['truncated', MAIL_BODY_NOTICES.truncated],
])
expect(row.find('iframe').exists()).toBe(true)
})
it('본문이 404 RES_001(그사이 지워짐)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 알리고, 대화를 다시 받아 그 줄을 뺀다', async () => {
const refreshed = deferred()
let calls = 0
serve({
conversation: () => {
calls += 1
return calls === 1 ? page(THREAD) : refreshed.promise
},
detail: () => fail(404, 'RES_001'),
})
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const row = rowOf(wrapper, 'm2')
expect(row.get('[data-conversation-detail-error]').text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(row.find('[data-conversation-detail-error] button').exists()).toBe(false)
expect(row.find('[data-conversation-reply]').exists()).toBe(false)
expect(row.find('[data-conversation-open]').exists()).toBe(false)
expect(row.get('[data-conversation-detail-status]').text()).toBe(ERROR_CODE_MESSAGES.RES_001)
expect(conversationRequests()).toHaveLength(2)
refreshed.resolve(page([ORIGINAL, CURRENT]))
await flushPromises()
expect(wrapper.find('[data-conversation-row="m2"]').exists()).toBe(false)
expect(section(wrapper).get('h3').text()).toBe('같은 대화 2통')
expect(useToastStore().toasts).toEqual([])
})
})
describe('펼친 본문 안의 Ctrl+P — 그 펼친 메일의 인쇄 화면', () => {
it('펼친 본문 안에서 누른 Ctrl+P 는 읽기 화면의 메일이 아니라 펼친 메일의 인쇄 화면을 연다 — 다른 글쇠와 함께면 브라우저에 맡긴다', async () => {
const opened: unknown[][] = []
vi.spyOn(window, 'open').mockImplementation((...args: unknown[]) => {
opened.push(args)
return null
})
serve()
const { wrapper } = await mountReader()
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
const frame = rowOf(wrapper, 'm2').get('iframe').element as HTMLIFrameElement
frame.dispatchEvent(new Event('load'))
const press = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true, cancelable: true })
frame.contentDocument?.dispatchEvent(press)
expect(press.defaultPrevented).toBe(true)
expect(opened).toEqual([['/mail/print/m2', '_blank', 'noopener']])
const withShift = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true })
frame.contentDocument?.dispatchEvent(withShift)
expect(withShift.defaultPrevented).toBe(false)
expect(opened).toHaveLength(1)
})
})
describe('[이 메일만 열기] — 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다', () => {
it.each([
['split', 'push'],
['list', 'replace'],
] as const)('%s 보기 → router.%s', async (layout, method) => {
serve()
const { wrapper, router } = await mountReader({ layout })
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await rowOf(wrapper, 'm2').get('[data-conversation-toggle]').trigger('click')
await flushPromises()
await rowOf(wrapper, 'm2').get('[data-conversation-open]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
expect(method === 'push' ? push : replace).toHaveBeenCalled()
expect(method === 'push' ? replace : push).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,308 @@
<script setup lang="ts">
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
// - 검색 결과에서 연 메일에도 보이지만 칸 안의 줄 · 본문에는 검색 강조를 넣지 않는다
// - 초점: 누른 단추 · 초점이 있던 줄이 다시 그리며 사라지면(받은 [다시 시도] · 마지막 쪽을 받은 [더 보기] · 펼친 줄의 [다시 시도] ·
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
import { useClock } from '@/composables/useClock'
import { useConversationSync } from '@/composables/useConversationSync'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { formatCount, formatTraceCode } from '@/shared/utils/format'
import { conversationRowsOf } from '@/shared/utils/mail-conversation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 읽기 화면에 연 메일 */
messageId: string
/** split: 분할 보기 · list: 목록 보기 — [이 메일만 열기]를 기록에 쌓을지(목록 보기는 바꿔 끼운다) */
layout?: 'split' | 'list'
}
interface Emits {
/** 초점이 있던 칸이 없어졌다 — 읽기 화면이 초점을 옮긴다 (본문으로) */
(e: 'settled'): void
}
const props = withDefaults(defineProps<Props>(), {
layout: 'split',
})
const emit = defineEmits<Emits>()
const mailStore = useMailStore()
const authStore = useAuthStore()
const clock = useClock()
useConversationSync({ messageId: () => props.messageId, clock })
const sectionRef = ref<HTMLElement | null>(null)
const titleRef = ref<HTMLHeadingElement | null>(null)
const failureRef = ref<HTMLElement | null>(null)
/** 화면 낭독용 상태 — 받지 못했을 때의 안내 (영역은 늘 있다) */
const announcement = ref<string>('')
/** 칸이 이 메일의 대화를 가리킬 때만 그린다 — 메일을 바꾼 직후 앞 메일의 대화가 잠깐 보이지 않게 */
const forThisMessage = computed(() => mailStore.conversationMessageId === props.messageId)
const visible = computed(() => forThisMessage.value && mailStore.conversationVisible)
const failed = computed(() => forThisMessage.value && mailStore.conversationFailed)
const showMore = computed(() => mailStore.hasMoreConversation || mailStore.conversationLoadingMore)
const title = computed(() => {
const count = mailStore.conversationCount
return count === null ? MAIL_CONVERSATION_TEXT.title : MAIL_CONVERSATION_TEXT.titleCount(formatCount(count))
})
const traceCode = computed(() => formatTraceCode(mailStore.conversationError?.traceId))
const listedIds = computed<ReadonlySet<string>>(() => new Set(mailStore.listedSummaries.map((message) => message.id)))
const rows = computed(() =>
conversationRowsOf(mailStore.conversationItems, {
currentId: props.messageId,
me: authStore.user,
mailboxes: mailStore.mailboxes,
source: mailStore.listSource,
listedIds: listedIds.value,
now: new Date(clock.now()),
}),
)
function loadMore() {
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
}
// ── 초점 지키기 ─────────────────────────────────────────
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
const renderKey = computed(() => {
const details = mailStore.conversationDetails
const expanded = [...mailStore.conversationExpandedIds].map((id) => `${id}=${details.get(id)?.status ?? ''}`)
return [
visible.value,
failed.value,
showMore.value,
mailStore.conversationStatus,
mailStore.conversationItems.map((item) => item.id).join(','),
expanded.join(','),
].join('|')
})
/** 칸 안에서 잃은 초점 — 초점이 있던 줄(없으면 null). 받는 중이면 다 받을 때까지 남는다 */
let lostFocus: { rowId: string | null } | null = null
function isInside(element: Element | null): boolean {
if (!element) return false
return Boolean(sectionRef.value?.contains(element) || failureRef.value?.contains(element))
}
function rowToggle(rowId: string): HTMLElement | null {
const rowElement = [...(sectionRef.value?.querySelectorAll<HTMLElement>('[data-conversation-row]') ?? [])].find(
(element) => element.dataset.conversationRow === rowId,
)
return rowElement?.querySelector<HTMLElement>('[data-conversation-toggle]') ?? null
}
/** 그린 뒤 — 초점을 잃었으면(body) 갈 곳으로 옮긴다 */
function settleFocus() {
if (!lostFocus) return
const active = document.activeElement
if (active !== null && active !== document.body) {
// 초점이 남아 있거나 사용자가 다른 곳으로 옮겼다
lostFocus = null
return
}
const target =
(lostFocus.rowId ? rowToggle(lostFocus.rowId) : null) ??
failureRef.value?.querySelector<HTMLElement>('[data-conversation-retry]') ??
titleRef.value
if (target) {
lostFocus = null
target.focus()
return
}
// 받는 중 — 칸이 다시 생길 때까지 기다린다
if (mailStore.conversationStatus === 'loading' && forThisMessage.value) return
lostFocus = null
emit('settled')
}
// 그리기 전에 초점이 칸 안에 있었는지 적어 둔다 (pre — 아직 옛 화면이다)
watch(
renderKey,
() => {
const active = document.activeElement
if (isInside(active)) {
const row = active?.closest<HTMLElement>('[data-conversation-row]')
lostFocus = { rowId: row?.dataset.conversationRow ?? null }
}
void nextTick(settleFocus)
},
{ flush: 'pre' },
)
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
watch(failed, async (isFailed) => {
announcement.value = ''
if (!isFailed) return
await nextTick()
announcement.value = MAIL_CONVERSATION_TEXT.loadFailed
})
</script>
<template>
<section
v-if="visible"
ref="sectionRef"
class="conversation"
aria-labelledby="reader-conversation-title"
data-conversation
>
<h3
id="reader-conversation-title"
ref="titleRef"
class="conversation__title"
tabindex="-1"
>
{{ title }}
</h3>
<ol class="conversation__list">
<ConversationMessageRow
v-for="row in rows"
:key="row.message.id"
:row="row"
:layout="layout"
:expanded="mailStore.conversationExpandedIds.has(row.message.id)"
:detail="mailStore.conversationDetails.get(row.message.id) ?? null"
@toggle="mailStore.toggleConversationMessage"
@retry-detail="mailStore.retryConversationDetail"
/>
</ol>
<div
v-if="showMore"
class="conversation__more"
>
<button
type="button"
class="conversation__text-button"
:aria-busy="mailStore.conversationLoadingMore"
data-conversation-more
@click="loadMore"
>
{{ mailStore.conversationLoadingMore ? MAIL_CONVERSATION_TEXT.moreBusy : MAIL_CONVERSATION_TEXT.more }}
</button>
</div>
</section>
<div
v-else-if="failed"
ref="failureRef"
class="conversation conversation--failed"
data-conversation-failed
>
<p class="conversation__failure">
<AppIcon
name="alert-circle"
class="conversation__failure-icon"
/>
<span>{{ MAIL_CONVERSATION_TEXT.loadFailed }}</span>
<button
type="button"
class="conversation__text-button"
data-conversation-retry
@click="mailStore.retryConversation()"
>
{{ MAIL_CONVERSATION_TEXT.retry }}
</button>
<span
v-if="traceCode"
class="conversation__trace"
>{{ MAIL_CONVERSATION_TEXT.traceCode(traceCode) }}</span>
</p>
</div>
<p
class="visually-hidden"
role="status"
data-conversation-status
>
{{ announcement }}
</p>
</template>
<style scoped>
/* 첨부 칸과 같은 칸 머리 — 위 선 · 12.5px 회색 제목 (디자인 원본 Thread14) */
.conversation {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding-top: var(--space-16);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.conversation__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 테두리 상자 — 줄 사이는 옅은 선 (ConversationMessageRow) */
.conversation__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.conversation__more {
display: flex;
}
/* [더 보기] · [다시 시도] 44 — 글자 단추 */
.conversation__text-button {
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-weight: 600;
}
.conversation__text-button:hover {
background: var(--color-brand-subtle);
}
.conversation__text-button[aria-busy='true'] {
color: var(--color-text-muted);
}
.conversation__failure {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.conversation__failure-icon {
color: var(--color-danger);
}
.conversation__trace {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
</style>
+38 -14
View File
@@ -2,13 +2,17 @@
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
// 대화로 묶은 메일함(14단계 — 디자인 원본 ListThreads14)은 한 줄이 대화 하나다 — 줄 규칙은 useConversationRows, 줄은 ConversationListItem
import ConversationListItem from '@/components/mail/ConversationListItem.vue'
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageListItem from '@/components/mail/MessageListItem.vue'
import { useConversationRows } from '@/composables/useConversationRows'
import { useMailListRows } from '@/composables/useMailListRows'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
const rows = useMailListRows()
const conversationRows = useConversationRows()
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
@@ -24,20 +28,40 @@ function onCheck(id: string, range: boolean) {
title-id="message-list-title"
>
<ul class="message-list__items">
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:selected="message.id === mailStore.selectedId"
:checked="mailStore.checkedIds.has(message.id)"
:checking="mailStore.checkedCount > 0"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
<template v-if="mailStore.isConversationList">
<ConversationListItem
v-for="row in mailStore.conversations"
:key="row.id"
:row="row"
:who="conversationRows.whoOf(row, 'split')"
:to="conversationRows.locationOf(row)"
:unread="conversationRows.isUnread(row)"
:draft="conversationRows.isDraft.value"
:held="conversationRows.isHeld(row)"
:selected="conversationRows.isSelected(row)"
:checked="mailStore.checkedIds.has(row.id)"
:checking="mailStore.checkedCount > 0"
@toggle-flag="conversationRows.toggleFlag"
@check="onCheck"
/>
</template>
<template v-else>
<MessageListItem
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:selected="message.id === mailStore.selectedId"
:checked="mailStore.checkedIds.has(message.id)"
:checking="mailStore.checkedCount > 0"
:search="Boolean(mailStore.search)"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</template>
</ul>
</MailListFrame>
</template>
@@ -3,6 +3,8 @@
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
// 아직 보내지 않은 내 메일(9단계)은 시각 자리에 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록 — 디자인 원본 MailSendLater)
// 검색 결과(14단계 — 디자인 원본 Search14)는 세 줄(최소 64): 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기 한 줄.
// 서버가 준 searchHighlight 의 위치로 제목 · 미리 보기의 맞은 말을 <mark>(HighlightedText — v-html 없음)로 그린다. 강조가 없으면(엔진 방식) 글 그대로
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
@@ -12,16 +14,17 @@ import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.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 type { MailboxRole, MessageListItem } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
message: MessageListItem
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
to: RouteLocationRaw
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
@@ -36,6 +39,8 @@ interface Props {
checked?: boolean
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
checking?: boolean
/** 검색 결과의 줄 — 미리 보기 줄을 더하고 맞은 말을 강조한다 (14단계) */
search?: boolean
}
interface Emits {
@@ -50,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
selected: false,
checked: false,
checking: false,
search: false,
})
const emit = defineEmits<Emits>()
@@ -63,6 +69,11 @@ const unread = computed(() => isRowUnread(props.message, props.role))
const draft = computed(() => isDraftRole(props.role))
/** 아직 보내지 않은 메일 — 고를 수 없다 */
const held = computed(() => isHeldMessage(props.message))
/** 검색 강조 (14단계) — 제목이 비었으면 "(제목 없음)"에는 강조가 없다 */
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
/** 검색 결과의 미리 보기 — 본문에서 맞았으면 맞은 말 둘레, 아니면 미리 보기 */
const preview = computed(() => props.message.searchHighlight?.preview.text ?? props.message.preview)
const previewRanges = computed(() => props.message.searchHighlight?.preview.ranges ?? null)
/**
* Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다).
@@ -84,7 +95,13 @@ function onMouseDown(event: MouseEvent) {
<li
:class="[
'message-row',
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
{
'is-selected': selected,
'is-unread': unread,
'is-checked': checked,
'is-checking': checking,
'message-row--search': search,
},
]"
:data-row-id="message.id"
@mousedown="onMouseDown"
@@ -119,9 +136,14 @@ function onMouseDown(event: MouseEvent) {
:datetime="message.pendingSend?.sendAt ?? message.receivedAt"
>{{ time }}</time>
</span>
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만. 검색 결과만 미리 보기 한 줄 (14단계) -->
<span class="message-row__bottom">
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">
<HighlightedText
:text="subject"
:ranges="search ? subjectRanges : null"
/>
</span>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
@@ -131,6 +153,16 @@ function onMouseDown(event: MouseEvent) {
class="message-row__clip"
/>
</span>
<span
v-if="search"
class="message-row__preview"
data-row-preview
>
<HighlightedText
:text="preview"
:ranges="previewRanges"
/>
</span>
</RouterLink>
<button
type="button"
@@ -226,6 +258,11 @@ function onMouseDown(event: MouseEvent) {
padding-left: var(--space-10);
}
/* 검색 결과 — 세 줄 최소 64 (디자인 원본 Search14 — 분할 보기 2줄 행 --size-list-row 의 검색판) */
.message-row--search .message-row__main {
min-height: 4rem;
}
.message-row__main:hover {
color: inherit;
}
@@ -290,6 +327,24 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-text);
}
/* 검색 결과의 미리 보기 한 줄 — 12.5px 흐린 글자 (디자인 원본 Search14) */
.message-row__preview {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/*
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
*/
.message-row__preview :deep(.search-mark) {
color: var(--color-text-secondary);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
@@ -215,7 +215,7 @@ async function onBlock(target: string, moveToJunk: boolean) {
const nextId = mailStore.neighborAfterRemoval(message.id)
const outcome = await senderBlock.block(message, target, moveToJunk)
if (!outcome.moved || outcome.stale) return
const removed = !mailStore.messages.some((item) => item.id === message.id)
const removed = !mailStore.isListedMessage(message.id)
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
}
@@ -9,7 +9,7 @@ import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useM
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Contact } from '@/shared/types/contacts'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
@@ -338,7 +338,7 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
store.mailboxes = [INBOX, TRASH]
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
@@ -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 () => {
@@ -537,7 +537,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
store.mailboxes = mailboxes
store.listSource = { kind: 'mailbox', mailboxId: box }
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = open
@@ -636,8 +636,9 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await items[1]?.trigger('click')
await flushPromises()
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
// 읽기 화면은 열릴 때 같은 대화를 받는다(14단계). 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /messages/m1/conversation',
'GET /mail-filters',
'GET /contacts',
'GET /contacts',
@@ -2,7 +2,7 @@ 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 type { BlockedSender, Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
@@ -144,7 +144,7 @@ describe('MessageReader — 9단계', () => {
const store = useMailStore()
store.mailboxes = MAILBOXES
store.listSource = source
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }]
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
store.selected = message
@@ -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)
@@ -426,7 +426,7 @@ describe('MessageReader — 9단계', () => {
await wrapper.get('[data-scheduled-cancel]').trigger('click')
// 기다리는 사이 받은편지함의 다른 메일을 연다
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [{ ...NEWSLETTER, id: 'i1' }]
store.messages = [{ ...NEWSLETTER, id: 'i1' }] as MessageSummary[]
store.selectedId = 'i1'
await router.push('/mail/inbox/i1')
cancel.resolve(ok({ draftId: 's1' }))
@@ -445,7 +445,7 @@ describe('MessageReader — 9단계', () => {
extra: (request) => (request.method === 'DELETE' ? (cancel.promise as unknown as MockResult) : undefined),
})
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }]
store.messages = [...store.messages, { ...NEWSLETTER, id: 'm3', mailboxIds: ['sent'] }] as MessageSummary[]
await wrapper.get('[data-reader-delete]').trigger('click')
await flushPromises()
await wrapper.get('[data-scheduled-delete-confirm]').trigger('click')
@@ -587,7 +587,7 @@ describe('MessageReader — 9단계', () => {
expect(wrapper.get('#block-dialog-title').text()).toBe('보낸 사람 차단')
expect((dialog.get('[data-block-scope="address"]').element as HTMLInputElement).checked).toBe(true)
expect(dialog.text()).toContain('news@cloudbrief.example')
expect(dialog.text()).toContain('@cloudbrief.example 에서 오는 메일 모두')
expect(dialog.text()).toContain('@cloudbrief.example에서 오는 메일 모두')
expect((dialog.get('[data-block-move]').element as HTMLInputElement).checked).toBe(true)
expect(document.activeElement).toBe(wrapper.get('[data-block-confirm]').element)
@@ -629,7 +629,7 @@ describe('MessageReader — 9단계', () => {
await wrapper.get('[data-block-confirm]').trigger('click')
// 기다리는 사이 보낸편지함의 메일을 연다
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }]
store.messages = [{ ...SCHEDULED, id: 'x1', pendingSend: null }] as MessageSummary[]
store.selectedId = 'x1'
await router.push('/mail/sent/x1')
blocking.resolve(ok({ address: 'news@cloudbrief.example', blockedAt: '2026-10-29T06:00:00.000Z' }, 201))
+25 -5
View File
@@ -13,22 +13,27 @@
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
// 검색 결과에서 연 메일(14단계)은 제목 · 본문 · 첨부 이름의 맞은 말이 노랑이다 — 서버의 searchHighlight(제목 · 첨부 이름 · 글 본문의 위치)와
// 서식 본문 안의 <mark>. 강조가 든 응답은 여기서 보이기만 한다 — [답장] · [전달] · 인쇄는 그 메일을 검색 쿼리 없이 따로 받는다 (계약 2절 "검색 — 14단계")
// 본문 · 첨부 아래에 "같은 대화 N통"(14단계 — MessageConversation, 한 통뿐이면 없다). 칸 안에는 검색 강조가 없다
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
import HighlightedText from '@/components/mail/HighlightedText.vue'
import MailBodyFrame from '@/components/mail/MailBodyFrame.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailToolButton from '@/components/mail/MailToolButton.vue'
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import MessageConversation from '@/components/mail/MessageConversation.vue'
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { useMailPrint } from '@/composables/useMailPrint'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
@@ -64,18 +69,21 @@ const searchMailboxName = computed(() => {
if (!mailStore.search || !message.value) return null
return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null
})
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
const fromName = computed(() => displayName(message.value?.from) || MAIL_NO_SENDER)
const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
/** 검색 강조 (14단계) — 검색 결과에서 연 메일만 온다. "(제목 없음)"에는 없다 */
const subjectRanges = computed(() => (message.value?.subject ? (message.value.searchHighlight?.subject ?? null) : null))
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 서버가 준다 (필드가 없는 서버면 없다) */
const largeFiles = computed(() => message.value?.largeFiles ?? [])
/** 다시 시도 — 검색 결과에서 연 메일이면 같은 강조 쿼리로 */
function retry() {
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId, mailStore.selectedHighlight)
}
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
@@ -92,7 +100,7 @@ function onPrintKey(event: KeyboardEvent) {
print.openPrint(current)
}
/** 안내 줄의 단추가 줄과 함께 사라졌다 — 초점을 잃었으면 본문으로 */
/** 안내 줄의 단추가 줄과 함께 사라졌다 · 초점이 있던 같은 대화 칸이 없어졌다(14단계) — 초점을 잃었으면 본문으로 */
function onNoticeSettled() {
const active = document.activeElement
if (active !== null && active !== document.body) return
@@ -194,7 +202,10 @@ onBeforeUnmount(() => {
id="reader-subject"
class="reader__subject"
>
{{ subject }}
<HighlightedText
:text="subject"
:ranges="subjectRanges"
/>
</h2>
<button
type="button"
@@ -265,6 +276,7 @@ onBeforeUnmount(() => {
:text-body="message.textBody"
:images-shown="message.imagesShown === true"
:fold-large-files="largeFiles.length > 0"
:text-highlights="message.searchHighlight?.textBody ?? null"
:title="`메일 본문: ${subject}`"
@frame-keydown="onPrintKey"
/>
@@ -274,6 +286,14 @@ onBeforeUnmount(() => {
:message-id="message.id"
:attachments="attachments"
:large-files="largeFiles"
:name-highlights="message.searchHighlight?.attachments ?? null"
/>
<!-- 같은 대화 (14단계) — 한 통뿐이거나 아직 받지 못했으면 아무것도 그리지 않는다. 초점이 있던 칸이 없어지면 본문으로 -->
<MessageConversation
:message-id="message.id"
:layout="layout"
@settled="onNoticeSettled"
/>
</div>
</article>
@@ -93,10 +93,10 @@ describe('MessageTable (목록 보기)', () => {
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
})
it('머리에 "전체 N · 안 읽음 M" 과 보기 전환이 있다', async () => {
it('머리에 "메일 N통, 안 읽은 메일 M통"(14단계 — 디자인 원본 ListThreads14) 과 보기 전환이 있다', async () => {
const { wrapper } = await mountTable()
expect(wrapper.get('.list-header__count').text()).toBe('전체 15 · 안 읽음 2')
expect(wrapper.get('.list-header__count').text()).toBe('메일 15통, 안 읽은 메일 2통')
expect(wrapper.find('[role="group"][aria-label="보기 방식"]').exists()).toBe(true)
})
+60 -19
View File
@@ -3,23 +3,35 @@
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
// 대화로 묶은 메일함(14단계 — 보드 ListThreads14 에 표가 없어 정했다)은 한 줄이 대화 하나다(ConversationTableRow):
// 고르기 · 별 · 참여자(+ 통 수 — 칸 머리도 "참여자") · 제목 · 날짜 — 크기 칸은 없다(대화의 크기는 마지막 메일의 크기가 아니다)
import { computed } from 'vue'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import ConversationTableRow from '@/components/mail/ConversationTableRow.vue'
import MailListFrame from '@/components/mail/MailListFrame.vue'
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
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 { useMailStore } from '@/stores/mail.store'
const router = useRouter()
const mailStore = useMailStore()
const rows = useMailListRows()
const conversationRows = useConversationRows()
const caption = computed(() =>
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
)
/** 대화로 묶은 메일함 — 크기 칸이 없고 사람 칸에 참여자를 보인다 */
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 ? MAIL_CONVERSATION_LIST_TEXT.participantsColumn : rows.whoHeader.value))
/** Shift 면 범위, 아니면 하나 */
function onCheck(id: string, range: boolean) {
@@ -40,7 +52,7 @@ function open(location: RouteLocationRaw) {
>
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
<div class="message-table__scroll">
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value, 'mail-table--threads': threads }]">
<caption class="visually-hidden">
{{ caption }}
</caption>
@@ -54,26 +66,48 @@ function open(location: RouteLocationRaw) {
>
<col class="mail-table__col-subject">
<col class="mail-table__col-date">
<col class="mail-table__col-size">
<col
v-if="!threads"
class="mail-table__col-size"
>
</colgroup>
<MessageTableHead
:who-header="rows.whoHeader.value"
:who-header="whoHeader"
:mixed="rows.mixed.value"
:threads="threads"
/>
<tbody>
<MessageTableRow
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:checked="mailStore.checkedIds.has(message.id)"
@open="open"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
<template v-if="threads">
<ConversationTableRow
v-for="row in mailStore.conversations"
:key="row.id"
:row="row"
:who="conversationRows.whoOf(row, 'table')"
:to="conversationRows.locationOf(row)"
:unread="conversationRows.isUnread(row)"
:draft="conversationRows.isDraft.value"
:held="conversationRows.isHeld(row)"
:checked="mailStore.checkedIds.has(row.id)"
@open="open"
@toggle-flag="conversationRows.toggleFlag"
@check="onCheck"
/>
</template>
<template v-else>
<MessageTableRow
v-for="message in mailStore.messages"
:key="message.id"
:message="message"
:to="rows.locationOf(message)"
:role="rows.roleOf(message)"
:mixed="rows.mixed.value"
:mailbox-name="rows.mailboxNameOf(message)"
:checked="mailStore.checkedIds.has(message.id)"
@open="open"
@toggle-flag="mailStore.toggleFlag"
@check="onCheck"
/>
</template>
</tbody>
</table>
</div>
@@ -171,6 +205,13 @@ function open(location: RouteLocationRaw) {
font-variant-numeric: tabular-nums;
}
/* 대화로 묶은 메일함 — 크기 칸이 없어 날짜 칸이 마지막 칸이다 (14단계 — 1100px 미만은 아래 규칙이 같은 폭을 준다) */
@media (min-width: 68.75rem) {
.mail-table--threads .mail-table__col-date {
width: calc(var(--table-col-date) + var(--space-8));
}
}
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
@media (max-width: 68.6875rem) {
.mail-table__col-size,
@@ -1,10 +1,12 @@
<script setup lang="ts">
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
// 대화로 묶은 메일함(14단계)은 불러온 대화 모두 고르기이고 크기 칸이 없다
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
import AppIcon from '@/components/AppIcon.vue'
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -12,10 +14,13 @@ interface Props {
whoHeader: string
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
mixed?: boolean
/** 대화로 묶은 메일함 — 크기 칸이 없다 */
threads?: boolean
}
withDefaults(defineProps<Props>(), {
mixed: false,
threads: false,
})
const mailStore = useMailStore()
@@ -33,7 +38,7 @@ const mailStore = useMailStore()
class="mail-table-head__check"
:checked="mailStore.checkState === 'all'"
:mixed="mailStore.checkState === 'some'"
:label="MAIL_BULK_TEXT.selectAll"
:label="threads ? MAIL_CONVERSATION_LIST_TEXT.selectAll : MAIL_BULK_TEXT.selectAll"
@toggle="mailStore.toggleCheckAll()"
/>
</th>
@@ -66,6 +71,7 @@ const mailStore = useMailStore()
날짜
</th>
<th
v-if="!threads"
scope="col"
class="mail-table__size"
>
@@ -5,21 +5,23 @@
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import HighlightedText from '@/components/mail/HighlightedText.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 type { MailboxRole, MessageListItem } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
interface Props {
message: MessageSummary
message: MessageListItem
to: RouteLocationRaw
role: MailboxRole | null
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
@@ -51,6 +53,8 @@ const draft = computed(() => isDraftRole(props.role))
const pendingLabel = computed(() => pendingSendLabel(props.message))
/** 아직 보내지 않은 메일 — 고를 수 없다 */
const held = computed(() => isHeldMessage(props.message))
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent) {
@@ -125,7 +129,10 @@ function onMouseDown(event: MouseEvent) {
:to="to"
:data-message-id="message.id"
>
{{ subject }}
<HighlightedText
:text="subject"
:ranges="subjectRanges"
/>
</RouterLink>
<AppIcon
v-if="message.hasAttachment"
+23 -7
View File
@@ -13,6 +13,9 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -22,16 +25,24 @@ export function useBulkMailActions() {
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
const busy = ref<boolean>(false)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
const allFlagged = computed<boolean>(
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
)
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 (대화 목록은 고른 줄이 모두 중요 표시가 있을 때) */
const allFlagged = computed<boolean>(() => {
const rows = mailStore.isConversationList ? mailStore.checkedConversations : mailStore.checkedMessages
return rows.length > 0 && rows.every((row) => row.isFlagged)
})
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
/** 스팸메일함을 보는 중 — 「스팸 신고」가 「스팸 아님」이 된다 (스팸메일함 안의 검색 포함) */
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
/**
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
*/
const conversationFrom = computed<string | null>(() =>
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
)
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
@@ -56,17 +67,19 @@ export function useBulkMailActions() {
const openId = mailStore.selectedId
const source = mailStore.listSource
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
if (mailStore.messages.some((message) => message.id === openId)) return
if (mailStore.isListedMessage(openId)) return
await router.replace(listLocationOf(source))
}
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
const from = conversationFrom.value
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id, options)
await leaveRemovedMessage(result)
feedback.announceMove(result, mailbox.name)
feedback.announceMove(result, mailbox.name, { from })
} finally {
busy.value = false
}
@@ -98,11 +111,13 @@ export function useBulkMailActions() {
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
async function purge(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 옮기기와 같은 까닭 — 대화로 묶은 휴지통이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (시작할 때의 메일함)
const from = conversationFrom.value
busy.value = true
try {
const result = await mailStore.purgeChecked()
await leaveRemovedMessage(result)
feedback.announcePurge(result)
feedback.announcePurge(result, { from })
} finally {
busy.value = false
}
@@ -114,6 +129,7 @@ export function useBulkMailActions() {
inTrash,
inJunk,
spamTarget,
conversationFrom,
moveTargets,
markRead,
toggleFlagged,
@@ -0,0 +1,61 @@
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useToast } from './useToast'
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationSummary, MailboxRole } from '@/shared/types/mail'
import { conversationWho, isConversationHeld } from '@/shared/utils/mail-conversation-list'
import { isDraftRole, isRowUnread, type RowWho } from '@/shared/utils/mail-list-row'
import { messageLocationOf } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
/**
* 대화로 묶은 메일함의 줄마다 필요한 값 (14단계 — 디자인 원본 ListThreads14) — 분할 보기 줄(ConversationListItem)과 목록 보기 표(ConversationTableRow)가 함께 쓴다.
* - 이름 자리: 참여자(내 주소는 "나" — 끝에), 보낸편지함 · 임시보관함에서 나뿐이면 "받는 사람: …" (mail-conversation-list.ts 의 conversationWho)
* - 안 읽음: 하나라도 안 읽었으면 굵게 — 임시보관함은 메일 목록처럼 굵게 하지 않는다
* - 누르면 갈 곳: 그 메일함의 마지막 메일 — 임시보관함이면 쓰기 화면에서 이어 쓴다(메일 목록과 같다)
* - 열린 메일이 든 줄: 마지막 메일이거나 줄에 든 메일([이 메일만 열기]로 연 더 오래된 메일)
* - 별: 줄의 메일 모두(붙잡힌 메일은 빼고 — 스토어). 줄의 중요 표시가 붙잡힌 메일에만 있어 풀리지 않았으면 그 까닭을 알린다
*/
export function useConversationRows() {
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
/** 목록의 메일함 역할 — 묶은 목록은 늘 메일함 하나다 */
const role = computed<MailboxRole | null>(() => mailStore.currentMailbox?.role ?? null)
/** 임시보관함 — 제목 없는 저장본을 흐린 글자로 */
const isDraft = computed<boolean>(() => isDraftRole(role.value))
function whoOf(row: ConversationSummary, view: 'split' | 'table'): RowWho {
return conversationWho(row, { myEmail: authStore.user?.email, role: role.value, view })
}
function isUnread(row: ConversationSummary): boolean {
return isRowUnread({ isRead: row.isRead }, role.value)
}
/** 줄의 메일이 모두 붙잡힌 메일 — 고르기 · 별을 쓸 수 없다 */
function isHeld(row: ConversationSummary): boolean {
return isConversationHeld(row)
}
function locationOf(row: ConversationSummary): RouteLocationRaw {
if (isDraftRole(role.value)) return { name: 'mail-compose', query: { draft: row.latest.id } }
const source = mailStore.listSource
return source ? messageLocationOf(source, row.latest.id) : { name: 'mail' }
}
function isSelected(row: ConversationSummary): boolean {
const id = mailStore.selectedId
return id !== null && (row.latest.id === id || row.messageIds.includes(id))
}
async function toggleFlag(rowId: string): Promise<void> {
const { heldFlagged } = await mailStore.toggleConversationFlag(rowId)
if (heldFlagged) toast.error(MAIL_CONVERSATION_LIST_TEXT.heldFlagNote)
}
return { role, isDraft, whoOf, isUnread, isHeld, locationOf, isSelected, toggleFlag }
}
@@ -0,0 +1,112 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
// 읽기 화면의 "같은 대화" 칸을 언제 받을지 (14단계 — 계약 2절 "대화 — 14단계"). 칸(MessageConversation)이 시작하고, 칸이 사라지면 멈춘다
// - 메일이 바뀌면 그 메일의 대화를 받는다(스토어의 loadConversation — 같은 대화의 다른 메일이면 보이던 줄을 둔 채)
// - 그 메일의 대화를 이미 받아 두었으면(쓰기 화면에서 답장을 보내고 돌아옴 · 보기 방식을 바꿈) 보이는 채로 바로 다시 받는다
// - 다시 받아 달라는 소식(conversationRefreshSeq — 메일함들의 통 수가 바뀌었을 때만 오른다, mail.store.ts)이 오면 다시 받는다 —
// 마지막으로 받기가 끝난 지 MAIL_CONVERSATION_REFRESH_GAP_MS 가 지나지 않았으면 그때까지 모았다가 한 번에 (잦지 않게).
// 끝난 때는 스토어가 실제로 물었을 때만 센다(conversationBusy 가 꺼질 때). 묻는 중에 온 소식은 끝난 뒤 한 번 더(그때부터 간격)
// - 기다림은 간격보다 길지 않다 — 이 PC 시계가 뒤로 가도 오래 멈추지 않게
// - 보호 화면을 쓸 수 있는 세션(canUseApp)일 때만 묻는다 — 로그인 전 · 제한된 세션(13단계)에는 묻지 않는다
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
export interface ConversationSyncOptions {
/** 읽기 화면에 연 메일 */
messageId: () => string
clock?: Clock
}
export function useConversationSync(options: ConversationSyncOptions): void {
const mailStore = useMailStore()
const authStore = useAuthStore()
const clock = options.clock ?? systemClock
/** 마지막으로 받기가 끝난 때 — 이 칸이 뜬 뒤 실제로 물은 것만 */
let lastFinishedAt: number | null = null
let timer: ClockTimer | null = null
/** 묻는 중에 온 소식 — 끝난 뒤 한 번 더 */
let pending = false
let stopped = false
function cancelTimer() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
/** 지금 다시 받는다 — 묻는 중이면 끝난 뒤로 미룬다 */
function refreshNow() {
if (stopped || !authStore.canUseApp) return
if (mailStore.conversationBusy) {
pending = true
return
}
void mailStore.refreshConversation()
}
/**
* 다시 받아 달라 — 묻는 중이면 끝난 뒤로 미루고, 간격이 지났으면 바로, 아니면 간격이 끝날 때 한 번 (그사이 온 요청은 함께).
* 간격이 끝나면 다시 재지 않고 받는다 — 시계가 뒤로 가도 간격보다 오래 기다리지 않게
*/
function requestRefresh() {
if (stopped || !authStore.canUseApp) return
if (mailStore.conversationBusy) {
pending = true
return
}
if (timer !== null) return
const wait =
lastFinishedAt === null
? 0
: Math.min(MAIL_CONVERSATION_REFRESH_GAP_MS, lastFinishedAt + MAIL_CONVERSATION_REFRESH_GAP_MS - clock.now())
if (wait <= 0) {
refreshNow()
return
}
timer = clock.setTimeout(() => {
timer = null
refreshNow()
}, wait)
}
// 받기가 끝날 때마다(받기 · 다시 받기 · [더 보기] · 붙잡힌 메일 다시 보기) 그때부터 간격을 센다 — 묻는 중에 온 소식은 그때 한 번 더
watch(
() => mailStore.conversationBusy,
(busy, wasBusy) => {
if (stopped || busy || !wasBusy) return
lastFinishedAt = clock.now()
if (!pending) return
pending = false
requestRefresh()
},
)
watch(
[() => options.messageId(), () => authStore.canUseApp],
([messageId, usable]) => {
cancelTimer()
pending = false
if (!usable) return
if (mailStore.conversationMessageId === messageId && mailStore.conversationStatus === 'ready') {
requestRefresh()
return
}
void mailStore.loadConversation(messageId)
},
{ immediate: true },
)
watch(
() => mailStore.conversationRefreshSeq,
() => requestRefresh(),
)
onScopeDispose(() => {
stopped = true
cancelTimer()
})
}
@@ -336,7 +336,7 @@ describe('useLargeFileUploads', () => {
expect(uploads.items.value.map((item) => [item.name, item.phase, item.errorText])).toEqual([
// 한도 글은 MB · GB 로 적는다 — 이 테스트의 12바이트 한도는 "0MB" 로 보인다
['too-big.zip', 'rejected', '파일 하나는 0MB 까지 보낼 수 있습니다.'],
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
+73 -6
View File
@@ -10,8 +10,12 @@ import {
MAIL_SEND_TIMEOUT_MS,
MAIL_UPLOAD_TIMEOUT_MS,
} from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
import type {
Attachment,
ConversationListPage,
ConversationListQuery,
ConversationPage,
Draft,
DraftPayload,
DraftSaved,
@@ -32,6 +36,8 @@ import type {
MessageDeletionRequest,
MessageDeletionResult,
MessageDetail,
MessageHighlightQuery,
MessageListItem,
MessageListQuery,
MessageSummary,
MessagesPatch,
@@ -40,12 +46,15 @@ import type {
RecipientSuggestionQuery,
SaveSignaturePayload,
ScheduledMessageCancel,
SearchIndexState,
SendMessagePayload,
SentMessage,
SignatureImage,
UpdateMessagePayload,
} from '@/shared/types/mail'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
import { conversationPageOf } from '@/shared/utils/mail-conversation'
import { conversationListPageOf } from '@/shared/utils/mail-conversation-list'
import { pendingSendOf } from '@/shared/utils/pending-send'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
@@ -54,6 +63,11 @@ export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
export interface GetMessageOptions extends MailRequestOptions {
showImages?: boolean
/**
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — q · from · to · subject · body 만, mail-search.ts 의 messageHighlightQueryOf).
* ⚠️ 읽기 화면(스토어의 열린 메일)만 넘긴다 — 답장 · 전달의 원문 · 인쇄는 넘기지 않는다(강조가 든 응답은 보이기만 한다 — 계약 2절)
*/
highlight?: MessageHighlightQuery | null
}
export interface SendMessageOptions extends MailRequestOptions {
@@ -225,31 +239,81 @@ export function useMail() {
/**
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
* 검색(q · 조건 — 14단계)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다.
* 색인으로 찾은 검색은 hasNext 인데 항목이 적거나 0개일 수 있다 — 이어 묻는 것은 부르는 쪽(목록 스토어)이다
*/
function listMessages(
query: MessageListQuery,
options?: MailRequestOptions,
): Promise<PaginatedResult<MessageSummary>> {
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/messages', {
): Promise<PaginatedResult<MessageListItem>> {
return api.get<PaginatedResult<MessageListItem>, PaginatedResult<MessageListItem>>('/messages', {
...options,
params: query,
})
}
/**
* 지난 메일 색인의 상태 (14단계 — 계약 2절 "검색 색인") — 조회지만 상태 줄을 만들고 멈춤을 푼다(멱등).
* 검색을 시작할 때 조용히 묻는다 — building 이면 결과 머리 아래 "검색 준비 중" 줄
*/
function getSearchIndex(options?: MailRequestOptions): Promise<SearchIndexState> {
return api.get<SearchIndexState, SearchIndexState>('/search-index', options)
}
/**
* 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
* showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기").
* highlight 면 그 검색의 맞은 곳을 강조한 본문(searchHighlight · htmlBody 의 <mark> — 14단계)
*/
function getMessage(id: string, options: GetMessageOptions = {}): Promise<MessageDetail> {
const { showImages, ...requestOptions } = options
const { showImages, highlight, ...requestOptions } = options
const params: Record<string, string> = {}
if (showImages) params[MAIL_IMAGES_PARAM] = MAIL_IMAGES_SHOW
for (const [name, value] of Object.entries(highlight ?? {})) {
if (typeof value === 'string' && value !== '') params[name] = value
}
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, {
...requestOptions,
...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
...(Object.keys(params).length > 0 ? { params } : {}),
})
}
/**
* 그 메일의 같은 대화 — 오래된 차례, 한 통뿐이면 그 메일 하나 (14단계 — 계약 2절 "대화 — 14단계").
* hasNext 인데 항목이 적거나 0개인 쪽이 올 수 있다(엔진 방식) — 이어 묻는 것은 부르는 쪽(스토어의 conversation.ts)이다.
* 커서는 그 메일에서만 통한다. 응답은 모양을 맞춰 돌려준다 — 틀리면 빈 대화(칸이 없다)
*/
async function getConversation(
id: string,
query: CursorPaginationQuery,
options?: MailRequestOptions,
): Promise<ConversationPage> {
const page = await api.get<unknown, unknown>(`/messages/${segment(id)}/conversation`, {
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
...options,
params: query,
})
return conversationPageOf(page)
}
/**
* 그 메일함을 대화로 묶은 목록 — 한 줄이 대화 하나, 그 메일함 안 마지막 받은 시각 최신부터 (14단계 — 계약 2절 "대화로 묶어 보기").
* 커서는 그 메일함 · 그 기준에서만 통한다. 엔진 방식은 한 요청이 15초까지 걸릴 수 있어 같은 대화와 같은 시간 한도를 쓴다.
* 응답은 모양을 맞춰 돌려준다 — 모양이 틀린 줄은 빼고, 마지막 메일은 늘 messageIds 에 든다
*/
async function listConversations(
query: ConversationListQuery,
options?: MailRequestOptions,
): Promise<ConversationListPage> {
const page = await api.get<unknown, unknown>('/conversations', {
timeout: MAIL_CONVERSATION_TIMEOUT_MS,
...options,
params: query,
})
return conversationListPageOf(page)
}
function updateMessage(
id: string,
changes: UpdateMessagePayload,
@@ -453,7 +517,10 @@ export function useMail() {
downloadMessageSource,
getMailState,
listMessages,
getSearchIndex,
getMessage,
getConversation,
listConversations,
updateMessage,
updateMessages,
deleteMessages,
@@ -6,15 +6,18 @@ import {
purgedMessageText,
restoredMessageText,
type BulkOrigin,
type BulkTextOptions,
} from '@/shared/utils/mail-bulk'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
/**
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고, 한 메일함으로 되돌렸으면 "메일 N통을 X으로 되돌렸습니다.",
* 여러 곳이면 "메일 N통을 되돌렸습니다.")
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다."
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
* - "메일 N통을 영구 삭제했습니다."
* - 대화로 묶은 목록에서 줄에 한 일(14단계)은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 통 수는 대화 전체라서 (options.from)
* - "메일 N통을 영구 삭제했습니다." — 대화로 묶은 휴지통에서 줄에 한 일이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (같은 까닭 · options.from)
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
@@ -23,31 +26,42 @@ export function useMailMoveFeedback() {
const mailStore = useMailStore()
const toast = useToast()
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
if (origins.length !== 1) return null
const mailboxId = origins[0]?.mailboxId
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
}
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
const result = await mailStore.restoreMoved(origins, options)
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
}
function announceMove(result: BulkMoveResult, mailboxName: string) {
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: BulkTextOptions = {}) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done === 0 && skipped === 0) return
const origins = result.origins
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
const text =
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
result.isJunk === false
? notSpamMovedText(done, mailboxName, skipped, textOptions)
: movedMessageText(done, mailboxName, skipped, textOptions)
toast.success(
text,
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
)
}
function announcePurge(result: MailBulkResult) {
/** textOptions.from — 대화로 묶은 휴지통에서 줄에 한 일이면 그 메일함 이름 ("휴지통에 있는 메일 2통을 영구 삭제했습니다.") */
function announcePurge(result: MailBulkResult, textOptions: BulkTextOptions = {}) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped, textOptions))
}
return { announceMove, announcePurge }
@@ -6,14 +6,21 @@ import { useMailNavigation } from './useMailNavigation'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
exceedsSearchWords,
isSearchRouteName,
listLocationOf,
normalizeSearchText,
sameListSource,
scopeMailboxIdOf,
searchSourceFromQuery,
searchSourceOf,
type SearchListSource,
} from '@/shared/utils/mail-list-source'
import {
exceedsCombinedSearchWords,
hasSearchConditions,
normalizeSearchConditions,
type SearchConditions,
} from '@/shared/utils/mail-search'
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
/** 메일함 화면(목록 · 연 메일)의 라우트 */
@@ -24,9 +31,12 @@ function paramOf(value: unknown): string | null {
}
/**
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
* 검색 화면 오가기 (계약 2절 "검색" · "검색 — 14단계" · 디자인 README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
* - 상단 검색 칸의 Enter · 「조건으로 찾기」의 [찾기]: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서),
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다.
* 검색어도 패널의 조건도 없는 [찾기]는 조건이 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다 (조건은 그대로)
* - 조건 줄의 [조건 지우기]: 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
*/
export function useMailSearchNavigation() {
@@ -70,28 +80,50 @@ export function useMailSearchNavigation() {
}
/**
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
* Enter · [찾기] — 검색어도 조건도 없으면 아무것도 하지 않는다. 같은 검색으로 다시 누르면 보이는 채로 다시 찾는다.
* conditions 를 주면(「조건으로 찾기」) 그 조건으로, 주지 않으면(검색 칸의 Enter) 걸려 있는 조건 그대로 찾는다.
* 조건이 걸린 검색에서 패널의 칸을 모두 비우고 검색어도 없이 [찾기]를 누르면 조건 줄의 [조건 지우기]처럼 검색을 닫는다
* (2026-11-07 — 패널의 [조건 지우기]가 칸만 비우게 되어, 조건만 걸린 검색은 [찾기]로도 조건을 뺄 수 있게).
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로 둔다.
* 찾았으면(검색을 닫았으면) true (패널이 닫힌다)
*/
async function startSearch(text: string): Promise<void> {
async function startSearch(text: string, conditions?: Partial<SearchConditions> | null): Promise<boolean> {
const q = normalizeSearchText(text)
if (!q) return
if (exceedsSearchWords(q)) {
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
return
}
const current = routeSearch.value
const nextConditions = conditions === undefined ? (current?.conditions ?? null) : normalizeSearchConditions(conditions)
if (!q && !hasSearchConditions(nextConditions)) {
if (!current || !hasSearchConditions(current.conditions)) return false
await closeSearch()
return true
}
if (exceedsCombinedSearchWords(q, nextConditions)) {
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
return false
}
if (current) {
if (current.q === q) {
await mailStore.openSearch(q, current.scope, { force: true })
return
const next = searchSourceOf(q, current.scope, nextConditions)
if (sameListSource(current, next)) {
await mailStore.openSearch(q, current.scope, { force: true, conditions: next.conditions })
return true
}
await router.replace(listLocationOf({ ...current, q }))
return
await router.replace(listLocationOf(next))
return true
}
const origin = originOfCurrentScreen()
mailStore.rememberSearchOrigin(origin)
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
await router.push(listLocationOf(searchSourceOf(q, origin.mailboxId ?? SEARCH_SCOPE_ALL, nextConditions)))
return true
}
/** 조건 줄의 [조건 지우기] — 조건만 빼고 같은 검색어 · 범위로 다시 찾는다. 검색어가 없으면 검색을 닫는다 */
async function clearConditions(): Promise<void> {
const current = routeSearch.value
if (!current || !hasSearchConditions(current.conditions)) return
if (!current.q) {
await closeSearch()
return
}
await router.replace(listLocationOf(searchSourceOf(current.q, current.scope)))
}
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
@@ -106,5 +138,5 @@ export function useMailSearchNavigation() {
await backOrReplace(returnLocation())
}
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
return { isSearching, routeSearch, originMailbox, startSearch, clearConditions, setScope, closeSearch }
}
+21 -6
View File
@@ -9,20 +9,35 @@ export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'si
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
const segment = (value: string) => encodeURIComponent(value)
/** GET · PATCH /mail-settings 가 실제로 주는 모양 — 14단계 앞의 서버는 groupByConversation 이 없다 */
type MailSettingsReply = Omit<MailSettings, 'groupByConversation'> & { groupByConversation?: unknown }
/** 설정 응답 → 화면의 값. groupByConversation 이 불리언 true 가 아니면 묶지 않는 것으로 본다 (처음 값 — 계약 2절 "보내기 · 읽기 설정") */
function mailSettingsOf(reply: MailSettingsReply): MailSettings {
// 응답을 바꾼 게이트웨이 등으로 객체가 아닐 수도 있다 — 그때도 던지지 않고 묶지 않는 것으로 본다
const value: unknown = reply
const grouped =
typeof value === 'object' && value !== null && (value as { groupByConversation?: unknown }).groupByConversation === true
return { ...reply, groupByConversation: grouped }
}
// 보내기 · 읽기 설정 · 보낸 사람 차단 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<MailSettings> {
return api.get<MailSettings, MailSettings>('/mail-settings', options)
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기 · 묶지 않음) */
async function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
return mailSettingsOf(await api.get<MailSettingsReply, MailSettingsReply>('/mail-settings', options))
}
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체. groupByConversation 은 불리언만 받는다(그 밖은 400 VAL_001) */
async function updateSettings(
patch: Partial<MailSettings>,
options?: MailSettingsRequestOptions,
): Promise<MailSettings> {
return mailSettingsOf(await api.patch<MailSettingsReply, MailSettingsReply>('/mail-settings', patch, options))
}
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
// 메일 화면이 탭으로 돌아올 때 보내기 · 읽기 설정을 다시 받는다 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다)
// 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
describe('useMailSettingsRefresh (탭으로 돌아오면 설정 다시 받기)', () => {
let scope: EffectScope
let clock: ManualClock
let visibility: DocumentVisibilityState
let server: MailSettings
let load: () => MockResult
let requests: MockRequest[]
beforeEach(async () => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
scope = effectScope()
clock = createManualClock()
visibility = 'visible'
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
server = SETTINGS
load = () => ok(server)
requests = installMockApi((request) => (request.method === 'GET' ? load() : ok(server)))
// 메일 화면에 들어올 때 받아 둔다 (MailLayout 의 ensureLoaded)
await useMailSettingsStore().load()
})
afterEach(() => {
scope.stop()
Reflect.deleteProperty(document, 'visibilityState')
useToastStore().clear()
restoreApiAdapter()
})
function start() {
scope.run(() => useMailSettingsRefresh({ clock }))
}
async function comeBack(afterMs: number) {
visibility = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
clock.advance(afterMs)
visibility = 'visible'
document.dispatchEvent(new Event('visibilitychange'))
await flushPromises()
}
const fetched = () => requests.filter((request) => request.method === 'GET' && request.url === '/mail-settings').length
it('마지막으로 받은 지 60초가 지난 뒤 탭으로 돌아오면 조용히 다시 받아 그 값을 쓴다 — 묶기 · 보내기 취소 초 · 외부 이미지', async () => {
start()
server = { undoSendSeconds: 30, externalImages: 'always', groupByConversation: false }
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(fetched()).toBe(2)
expect(requests.at(-1)?.config.silent).toBe(true)
const store = useMailSettingsStore()
expect(store.groupByConversation).toBe(false)
expect(store.knownUndoSendSeconds).toBe(30)
expect(store.settings?.externalImages).toBe('always')
expect(useToastStore().toasts).toEqual([])
})
it('60초가 안 됐으면 다시 받지 않는다 — 받은 뒤에는 또 60초를 센다', async () => {
start()
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
expect(fetched()).toBe(1)
await comeBack(1)
expect(fetched()).toBe(2)
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
expect(fetched()).toBe(2)
})
it('숨는 때에는 받지 않는다 — 다시 보일 때만', async () => {
start()
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS * 2)
visibility = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
await flushPromises()
expect(fetched()).toBe(1)
})
it('세션을 쓸 수 없으면(제한된 세션 · 로그아웃) 받지 않는다', async () => {
start()
// 같은 직원의 세션에 제한이 붙었다 — 받아 둔 설정은 그대로지만 묻지 않는다
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(useMailSettingsStore().status).toBe('ready')
expect(fetched()).toBe(1)
useAuthStore().clearAuth()
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(fetched()).toBe(1)
})
it('받지 못하면 알리지 않고 지금 값을 둔다 — 60초 뒤 다시 보일 때 또 해 본다', async () => {
start()
load = () => fail(503, 'SYS_001')
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(fetched()).toBe(2)
expect(useMailSettingsStore().settings).toEqual(SETTINGS)
expect(useToastStore().toasts).toEqual([])
load = () => ok({ ...SETTINGS, groupByConversation: false })
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(fetched()).toBe(3)
expect(useMailSettingsStore().groupByConversation).toBe(false)
})
it('화면이 사라지면 그만 본다', async () => {
start()
scope.stop()
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
expect(fetched()).toBe(1)
})
})
@@ -0,0 +1,51 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock } from './useClock'
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
// 보내기 · 읽기 설정을 메일 화면에서 다시 받는 때 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 [대화로 묶기]가 열어 둔 탭에 남았다)
// - 메일 화면(MailLayout)이 있는 동안, 탭이 다시 보이게 될 때 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(알리지 않는다)
// — 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 열고(MailboxPage), 보내기 취소 초 · 외부 이미지도 받은 값을 쓴다
// - 마지막으로 받은 때는 화면에 들어온 때(그때 받아 두었거나 지금 받는다)와 받을 때마다(스토어의 fetchSeq)다. 다시 받기를 시작하면 그때로 —
// 받지 못하면(알리지 않는다) 60초 뒤 다시 보일 때 또 해 본다
// - 세션이 끝났으면(로그아웃 · 세션 만료 · 제한된 세션) 받지 않는다. 저장하는 중 · 받는 사이 고른 값은 스토어가 지킨다(refresh)
export interface MailSettingsRefreshOptions {
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
}
function isPageVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState === 'visible'
}
export function useMailSettingsRefresh(options: MailSettingsRefreshOptions = {}): void {
const clock = options.clock ?? systemClock
const authStore = useAuthStore()
const settingsStore = useMailSettingsStore()
/** 설정을 마지막으로 받은 때 (ms) */
let fetchedAt = clock.now()
function onVisibilityChange() {
if (!isPageVisible() || !authStore.canUseApp) return
const now = clock.now()
if (now - fetchedAt < MAIL_SETTINGS_REFRESH_AFTER_MS) return
fetchedAt = now
void settingsStore.refresh()
}
watch(
() => settingsStore.fetchSeq,
() => {
fetchedAt = clock.now()
},
)
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange)
onScopeDispose(() => document.removeEventListener('visibilitychange', onVisibilityChange))
}
}
@@ -21,6 +21,7 @@ import { useMailStore } from '@/stores/mail.store'
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
// 대화로 묶은 메일함(14단계)은 같은 결로 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄의 마지막 메일 (스토어의 neighborAfterRemoval).
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
export interface MessageRemovalOutcome {
@@ -55,7 +56,7 @@ export function useMessageRemoval() {
}
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
const removed = done && !stale && !mailStore.isListedMessage(id)
return { removed, nextId: removed ? nextId : null }
}
@@ -160,4 +160,30 @@ describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', (
expect(listRequests()).toEqual([])
expect(clock.pendingCount()).toBe(0)
})
it('읽기 화면 "같은 대화" 칸에만 있는 붙잡힌 메일도 지켜본다 — 보낼 시각 + 2초에 그 대화를 알리지 않고 다시 받아 "보내는 중"이 내려간다 (14단계)', async () => {
let thread = [summary('m3', null), summary('u1', { kind: 'undo', sendAt: at(10_000) })]
requests = installMockApi((request) => {
if (request.url === '/scheduled-messages') return page([])
if (request.url.endsWith('/conversation')) {
return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
}
return ok({})
})
const store = useMailStore()
await store.loadConversation('m3')
scope.run(() => usePendingSendRefresh({ clock }))
const conversations = () => requests.filter((request) => request.url.endsWith('/conversation'))
expect(conversations()).toHaveLength(1)
thread = [summary('m3', null), summary('u1', null)]
await advance(11_999)
expect(conversations()).toHaveLength(1)
await advance(1)
expect(conversations()).toHaveLength(2)
expect(conversations()[1]?.config.silent).toBe(true)
expect(store.conversationItems.find((item) => item.id === 'u1')?.pendingSend).toBeNull()
expect(clock.pendingCount()).toBe(0)
})
})
@@ -17,8 +17,9 @@ import { useMailStore } from '@/stores/mail.store'
// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄) · 읽기 화면 "같은 대화" 칸의 줄(14단계)의
// pendingSend.sendAt — 같은 메일은 하나로 본다
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수 · 붙잡힌 메일이 든 같은 대화, 알리지 않는다)
// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
@@ -40,7 +41,13 @@ export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
const givenUp = new Set<string>()
const watches = computed<PendingWatch[]>(() =>
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
pendingWatchesOf([
// 보이는 목록의 줄 — 대화로 묶은 메일함은 줄마다 마지막 메일 (14단계 — 줄 안의 다른 붙잡힌 메일은 시각을 몰라 다음 맞추기를 기다린다)
...mailStore.listedSummaries,
mailStore.selected,
mailStore.nextScheduled,
...mailStore.conversationItems,
]),
)
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
@@ -74,7 +74,7 @@ export function useScheduledMessageActions() {
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
function removedFromList(messageId: string): boolean {
return !mailStore.messages.some((message) => message.id === messageId)
return !mailStore.isListedMessage(messageId)
}
/** [예약 취소] — 보내지 않고 임시보관함으로 */
@@ -0,0 +1,127 @@
import { reactive, ref } from 'vue'
import type { Clock } from '@/composables/useClock'
import type { SearchPeriod } from '@/shared/constants/mail-search'
import {
isReversedPeriod,
isSearchDay,
normalizeSearchConditions,
periodOf,
periodRange,
type SearchConditions,
} from '@/shared/utils/mail-search'
/** 기간 칸의 잘못 — 시작이 끝보다 늦음(order) · 고를 수 없는 날짜(range — 1970-01-01 ~ 9999-12-30 밖) */
export type SearchPeriodProblem = 'order' | 'range'
/** 「조건으로 찾기」 패널의 칸 값 — 날짜는 <input type="date"> 값(YYYY-MM-DD, 비면 '') */
export interface SearchConditionsDraft {
from: string
to: string
subject: string
body: string
period: SearchPeriod
since: string
until: string
hasAttachment: boolean
}
/**
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take), 모두 비운다(clear — [조건 지우기]).
* - 기간은 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기로 채운다. 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다
* - 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다 — 조금만 고치면 되게
* - 날짜 고르기의 시작이 끝보다 늦거나 고를 수 없는 날짜면 넘기지 않고 periodProblem 을 둔다 (칸 아래에 알린다)
* 오늘은 주입한 시계로 센다 (테스트는 손으로 돌리는 시계)
*/
export function useSearchConditionsDraft(clock: Clock) {
const draft = reactive<SearchConditionsDraft>({
from: '',
to: '',
subject: '',
body: '',
period: 'all',
since: '',
until: '',
hasAttachment: false,
})
const periodProblem = ref<SearchPeriodProblem | null>(null)
/** 바로 전에 고른 기간 — 날짜 고르기로 바꿀 때 그 날짜로 칸을 채운다 */
let previousPeriod: SearchPeriod = 'all'
function today(): Date {
return new Date(clock.now())
}
/** 걸린 조건으로 칸을 채운다 (열 때) */
function fill(conditions: SearchConditions | null) {
const value = normalizeSearchConditions(conditions)
draft.from = value.from
draft.to = value.to
draft.subject = value.subject
draft.body = value.body
draft.period = periodOf(value.since, value.until, today())
draft.since = value.since ?? ''
draft.until = value.until ?? ''
draft.hasAttachment = value.hasAttachment
previousPeriod = draft.period
periodProblem.value = null
}
/**
* 칸을 모두 비운다 — 패널의 [조건 지우기] (2026-11-07): 글 칸 · 기간(전체 기간) · 날짜 · 첨부 체크 · 칸 아래 알림까지.
* 찾지는 않는다 — 걸린 조건은 [찾기]를 누를 때까지 그대로다
*/
function clear() {
fill(null)
}
/** 기간 고르기를 바꾼다 */
function changePeriod(period: SearchPeriod) {
draft.period = period
periodProblem.value = null
if (period === 'custom' && !draft.since && !draft.until) {
const range = periodRange(previousPeriod, today())
draft.since = range.since ?? ''
draft.until = range.until ?? ''
}
previousPeriod = period
}
/** 날짜 칸을 바꾼다 — 알리던 잘못을 지운다 */
function changeDay(edge: 'since' | 'until', value: string) {
draft[edge] = value
periodProblem.value = null
}
/** 넘길 조건 — 기간이 잘못됐으면 null (periodProblem 에 까닭) */
function take(): SearchConditions | null {
let since: string | null
let until: string | null
if (draft.period === 'custom') {
since = draft.since || null
until = draft.until || null
if ((since !== null && !isSearchDay(since)) || (until !== null && !isSearchDay(until))) {
periodProblem.value = 'range'
return null
}
if (isReversedPeriod(since, until)) {
periodProblem.value = 'order'
return null
}
} else {
;({ since, until } = periodRange(draft.period, today()))
}
periodProblem.value = null
return normalizeSearchConditions({
from: draft.from,
to: draft.to,
subject: draft.subject,
body: draft.body,
since,
until,
hasAttachment: draft.hasAttachment,
})
}
return { draft, periodProblem, fill, clear, changePeriod, changeDay, take }
}
@@ -0,0 +1,74 @@
import { nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { useRoute } from 'vue-router'
/** 닫을 때 초점을 [조건]으로 돌릴지 — 기본은 돌린다 */
export interface SearchConditionsCloseOptions {
restoreFocus?: boolean
}
interface Options {
/** 검색 칸(form) — 이 안을 누르거나 이 안으로 옮기는 초점은 바깥이 아니다 */
container: Ref<HTMLElement | null>
/** [조건] — 닫을 때 초점을 돌려줄 곳 */
toggle: Ref<{ focus: () => void } | null>
/** 연 뒤(패널이 그려진 뒤) — 첫 칸으로 초점 */
onOpened: () => void
}
/**
* 검색 칸의 「조건으로 찾기」 패널 열고 닫기 (14단계 — 디자인 원본 Search14).
* 열린 동안 바깥을 누르거나(pointerdown — 초점은 누른 곳으로 간다) 키보드로 검색 칸 밖으로 나가거나(focusout)
* 다른 화면으로 가면(뒤로 가기 등) 닫는다 — 이때는 초점을 옮기지 않는다. 그 밖에 닫을 때는 [조건]으로 초점을 돌린다(restoreFocus 로 끈다).
* 패널의 칸은 열 때마다 걸린 조건으로 다시 채운다(패널이 v-if 로 새로 그려진다)
*/
export function useSearchConditionsPopup(options: Options) {
const route = useRoute()
const isOpen = ref<boolean>(false)
function onDocumentPointerDown(event: PointerEvent) {
const target = event.target instanceof Node ? event.target : null
if (target && options.container.value?.contains(target)) return
close({ restoreFocus: false })
}
function listenOutside(on: boolean) {
if (on) document.addEventListener('pointerdown', onDocumentPointerDown, true)
else document.removeEventListener('pointerdown', onDocumentPointerDown, true)
}
/** 키보드로 패널 · 검색 칸 밖으로 나가면 닫는다 (마우스로 누른 곳은 pointerdown 이 본다 — relatedTarget 이 없다) */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget instanceof Node ? event.relatedTarget : null
if (!isOpen.value || !next || options.container.value?.contains(next)) return
close({ restoreFocus: false })
}
async function open() {
if (isOpen.value) return
isOpen.value = true
listenOutside(true)
await nextTick()
if (isOpen.value) options.onOpened()
}
function close(closeOptions: SearchConditionsCloseOptions = {}) {
if (!isOpen.value) return
isOpen.value = false
listenOutside(false)
if (closeOptions.restoreFocus !== false) void nextTick(() => options.toggle.value?.focus())
}
function toggle() {
if (isOpen.value) close()
else void open()
}
watch(
() => route.fullPath,
() => close({ restoreFocus: false }),
)
onBeforeUnmount(() => listenOutside(false))
return { isOpen, open, close, toggle, onFocusOut }
}
@@ -262,7 +262,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
settings.settings = { undoSendSeconds: 5, externalImages: 'ask', groupByConversation: false }
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]))
@@ -322,7 +322,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(wrapper.find('#compose-recipient-error').exists()).toBe(true)
})
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’ 에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
it('고르기 첫 줄로 [예약] — 발송 본문에 sendAt(UTC) · 알림 "‘시각’에 보냅니다." + [예약됨 보기] · 예약한 메일 수를 다시 묻는다', async () => {
const preset = schedulePresets(NOW)[0]
if (!preset) throw new Error('고르기가 없다')
const sendAt = sendAtOf(preset.at)
@@ -344,7 +344,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'success',
message: `${formatScheduleTime(sendAt)} 에 보냅니다.`,
message: `${formatScheduleTime(sendAt)}에 보냅니다.`,
action: { label: '예약됨 보기', to: { name: 'mail-scheduled' } },
expiresAt: null,
}),
+19 -1
View File
@@ -181,6 +181,24 @@ describe('ComposePage', () => {
expect(quote.text()).not.toContain('받는 사람')
})
it('검색 결과에서 강조해 연 메일이 스토어에 있어도 답장의 원문은 검색 쿼리 없이 다시 받는다 — 인용에 <mark> 가 없다 (14단계)', async () => {
const store = useMailStore()
store.selectedId = 'm1'
store.selected = {
...ORIGINAL,
htmlBody: '<p><mark>계약</mark>서를 보냅니다</p>',
searchHighlight: { subject: [[0, 2]], attachments: {}, textBody: [] },
}
store.selectedHighlight = { q: '계약' }
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
const originals = requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
expect(originals).toHaveLength(1)
expect(originals[0]?.params).toEqual({})
expect(wrapper.get('#compose-body').html()).not.toContain('<mark')
expect(wrapper.get('#compose-body').text()).toContain('김아라님, 안녕하세요.')
})
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
await vi.waitFor(
@@ -618,7 +636,7 @@ describe('ComposePage', () => {
])
expect(wrapper.get('[data-large-upload]').text()).toContain('도면.pdf')
expect(wrapper.get('[data-large-switch]').text()).toBe(
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB 를 넘어 링크로 보냅니다.',
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB를 넘어 링크로 보냅니다.',
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } 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'
// 메일 화면의 바뀜 소식(1분 확인 · 실시간 — MailLayout 의 onChange) → 메일함 개수 맞추기 → 같은 대화 칸 다시 받기 (14단계).
// 읽음 · 중요 표시만 바뀐 소식(메일함들의 통 수가 그대로)으로는 대화를 다시 받지 않는다 — 대화 경로의 요청 수 한도를 아끼려고(리뷰 중간 2).
// 실제 메일 화면(MailLayout → MailboxPage → 읽기 화면 → 같은 대화 칸)을 띄우고 손으로 돌리는 시계로 1분 확인을 넘긴다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: `미리 보기 ${id}`,
receivedAt: new Date(2026, 10, 5, 9, 30).toISOString(),
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
const page = (items: unknown[], total = items.length) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
describe('MailLayout — 바뀜 소식으로 같은 대화 칸을 다시 받는다 (메일함들의 통 수가 바뀔 때만)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let state: string
let inboxTotal: number
let inboxUnread: number
let thread: MessageSummary[]
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
state = 's1'
inboxTotal = 2
inboxUnread = 1
thread = [summary('m1', { mailboxIds: ['sent'], from: USER }), summary('m3')]
requests = installMockApi(({ url, method }) => {
if (url === '/mail-state') return ok({ state })
if (url === '/mailboxes') {
return page([mailbox('inbox', 'inbox', '받은편지함', inboxTotal, inboxUnread), mailbox('sent', 'sent', '보낸편지함', 1, 0)])
}
if (url === '/mail-quota') {
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
}
if (url === '/mail-signature') {
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
}
if (url === '/mail-vacation') {
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
}
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
if (url === '/mail-settings') return ok({ undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false })
if (url === '/scheduled-messages') return page([])
if (url === '/messages' && method === 'GET') return page([summary('m3'), summary('m2', { threadId: 't2' })])
if (url.endsWith('/conversation')) return ok({ items: thread, pageInfo: { hasNext: false, nextCursor: null, total: thread.length }, basis: 'index' })
if (url === '/messages/m3') return ok(detail(summary('m3')))
return ok(null)
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
document.title = ''
})
async function settle() {
for (let round = 0; round < 4; round += 1) await flushPromises()
}
async function mountMail() {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/mail/inbox/m3')
await router.isReady()
const wrapper = mount(
{ render: () => h(RouterView) },
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
)
mounted.push(wrapper)
await settle()
return wrapper
}
const conversationRequests = () => requests.filter((request) => request.url.endsWith('/conversation'))
const mailboxRequests = () => requests.filter((request) => request.url === '/mailboxes')
/** 1분 확인을 넘긴다 — 바뀐 state 면 메일 화면이 메일함 개수 · 보이는 목록을 맞춘다(onChange) */
async function nextPoll(nextState: string) {
state = nextState
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
await settle()
}
it('읽음 표시만 바뀐 소식(통 수 그대로)으로는 다시 받지 않고, 새 메일로 통 수가 바뀌면 다시 받는다', async () => {
const wrapper = await mountMail()
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
expect(conversationRequests()).toHaveLength(1)
// 다른 곳에서 메일 한 통을 읽었다 — 안 읽은 수만 바뀐다
const mailboxLoads = mailboxRequests().length
inboxUnread = 0
await nextPoll('s2')
expect(mailboxRequests().length).toBeGreaterThan(mailboxLoads)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await settle()
expect(conversationRequests()).toHaveLength(1)
// 같은 대화에 새 메일이 왔다 — 받은편지함의 통 수가 바뀐다
inboxTotal = 3
thread = [...thread, summary('m4', { receivedAt: new Date(2026, 10, 6, 15, 2).toISOString() })]
await nextPoll('s3')
expect(conversationRequests()).toHaveLength(2)
expect(conversationRequests()[1]?.config.silent).toBe(true)
expect(wrapper.get('[data-conversation] h3').text()).toBe('같은 대화 3통')
})
it(`받기가 끝난 지 ${MAIL_CONVERSATION_REFRESH_GAP_MS}ms 안에 온 소식은 그때까지 모았다가 한 번 받는다`, async () => {
await mountMail()
expect(conversationRequests()).toHaveLength(1)
// 처음 받기가 끝난 바로 뒤 — 실시간 소식처럼 곧바로 메일함 개수를 맞춘 것으로 본다
inboxTotal = 3
useMailStore().syncCounts()
await settle()
expect(conversationRequests()).toHaveLength(1)
clock.advance(MAIL_CONVERSATION_REFRESH_GAP_MS)
await settle()
expect(conversationRequests()).toHaveLength(2)
})
})
+7 -2
View File
@@ -8,7 +8,8 @@
// 들어올 때 예약한 메일 수(패널의 「예약됨」) · 차단 목록(읽기 화면 ⋯ 의 차단 · 스팸메일함의 안내 줄)을 받아 둔다 (9단계 — 못 받으면 알리지 않는다).
// 예약한 메일 수는 새 메일 확인 · 실시간 알림이 바뀜을 알릴 때 다시 묻는다 (계약 2절 "보내기 취소 · 예약 발송").
// 엔진이 붙잡은 발송을 풀 때(보낼 시각)는 새 메일 확인이 알아채지 못한다 — 그 시각이 지나면 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다)
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
import { provide } from 'vue'
@@ -19,6 +20,7 @@ import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
@@ -44,8 +46,10 @@ void useMailVacationStore().load()
void mailStore.loadScheduledCount()
// 읽기 화면 ⋯ 「보낸 사람 차단」 · 「차단 풀기」와 스팸메일함의 "차단한 보낸 사람의 메일" 줄
useMailBlockStore().ensureLoaded()
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다)
// 쓰기 화면의 [보내기 취소] 알림 마감 — 설정한 초 (모르면 서버가 준 보낼 시각으로 센다). 메일함 목록의 묶기도 이 설정이다
useMailSettingsStore().ensureLoaded()
// 탭이 다시 보일 때 — 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다 (다른 탭 · PC 에서 바꾼 값)
useMailSettingsRefresh({ clock })
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
@@ -57,6 +61,7 @@ useMailStatePolling({
void mailStore.loadScheduledCount()
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
void mailStore.refreshOpenedLargeFiles()
// 읽기 화면의 "같은 대화" 칸은 위 syncCounts 로 받은 메일함들의 통 수가 바뀌었을 때만 다시 받는다 (14단계 — mail.store.ts 의 mailboxTotalsKey)
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
@@ -8,6 +8,7 @@ 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 { useMailStore } from '@/stores/mail.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'
@@ -168,6 +169,16 @@ describe('MailPrintPage', () => {
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('src="/api/mail-images/t0k"')
})
it('검색 결과에서 강조해 연 메일도 인쇄는 검색 쿼리 없이 다시 받는다 — 인쇄 주소에도 싣지 않는다 (14단계)', async () => {
const store = useMailStore()
store.selected = { ...MESSAGE, htmlBody: '<p><mark>계약</mark></p>', searchHighlight: { subject: [], attachments: {}, textBody: [] } }
store.selectedHighlight = { q: '계약' }
const { wrapper, requests } = await mountAt('/mail/print/m1')
expect(requests[0]?.params).toEqual({})
expect(wrapper.get('iframe').attributes('srcdoc')).not.toContain('<mark>')
})
it('메일을 찾을 수 없으면 "메일을 찾을 수 없습니다" + [닫기] — 인쇄 창은 띄우지 않는다', async () => {
const close = vi.spyOn(window, 'close').mockImplementation(() => undefined)
const { wrapper, router } = await mountAt('/mail/print/m1', () => fail(404, 'RES_001'))
@@ -162,7 +162,7 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
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('[data-notice="scheduled"]').text()).toContain(`${formatScheduleTime(FIRST_SEND_AT)}에 보낼 메일입니다.`)
expect(opened.wrapper.get('.reader').text()).toContain('목록으로')
})
@@ -1,14 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { CLOCK } from '@/composables/useClock'
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
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 { createManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import highlightedTextSource from '@/components/mail/HighlightedText.vue?raw'
import messageListItemSource from '@/components/mail/MessageListItem.vue?raw'
import MailboxPage from './MailboxPage.vue'
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
@@ -60,6 +72,8 @@ describe('MailboxPage — 검색 결과', () => {
let pinia: Pinia
let requests: MockRequest[]
let results: MessageSummary[]
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) */
let indexStatus: 'building' | 'ready'
beforeEach(() => {
window.localStorage.clear()
@@ -68,8 +82,10 @@ describe('MailboxPage — 검색 결과', () => {
useAuthStore().markAuthenticated(USER)
useMailStore().mailboxes = MAILBOXES
results = [summary('r1'), summary('r2')]
indexStatus = 'ready'
requests = installMockApi(({ url }) => {
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
if (url === '/search-index') return ok({ status: indexStatus, done: 10, total: indexStatus === 'ready' ? 10 : 40 })
return ok(detail(url.split('/').pop() ?? 'r1'))
})
})
@@ -107,15 +123,18 @@ describe('MailboxPage — 검색 결과', () => {
expect(wrapper.findAll('.message-row')).toHaveLength(2)
})
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
it('결과가 없으면 "다른 말로 찾아 보세요."(걸린 조건이 없다)와 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
results = []
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
const push = vi.spyOn(router, 'push')
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
expect(wrapper.text()).not.toContain('조건을 줄여')
// 색인이 준비됐으면 낱말 단위의 한계 안내 · 검색 준비 중 줄은 없다 (14단계)
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(false)
await wrapper.get('.search-empty__all').trigger('click')
await flushPromises()
@@ -206,3 +225,441 @@ describe('MailboxPage — 검색 결과', () => {
expect(store.checkedCount).toBe(0)
})
})
describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 · 검색 준비 중)', () => {
let pinia: Pinia
let requests: MockRequest[]
let handler: (request: MockRequest) => MockResult | Promise<MockResult>
/** 2026년 11월 6일 오전 9시(브라우저 시간대) */
const clock = createManualClock(new Date(2026, 10, 6, 9).getTime())
const HIGHLIGHTED: MessageListItem = {
...summary('r1', { subject: 'Re: 유지보수 계약 갱신 건', hasAttachment: true }),
preview: '지난번 말씀하신 계약서 초안을 첨부했습니다.',
searchHighlight: {
subject: [[9, 11]],
preview: { text: '…말씀하신 계약서 초안을 첨부했습니다.', ranges: [[6, 8]] },
},
}
function indexState(status: 'building' | 'ready'): MockResult {
return ok({ status, done: 10, total: 40 })
}
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
useMailStore().mailboxes = MAILBOXES
handler = ({ url }) => {
if (url === '/search-index') return indexState('ready')
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
return ok(detail(url.split('/').pop() ?? 'r1'))
}
requests = installMockApi((request) => handler(request))
})
/** 그린 화면 — 테스트마다 떼어 낸다 (초점을 보는 테스트는 문서에 붙여 그린다) */
let mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted) wrapper.unmount()
mounted = []
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
async function mountAt(path: string, options: { attach?: boolean } = {}) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailboxPage, {
attachTo: options.attach ? document.body : undefined,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
function heldResult() {
let resolve: (value: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
const listRequests = () => requests.filter((request) => request.url === '/messages')
const CONDITIONS_QUERY = `from=${encodeURIComponent('이도윤')}&since=2026-10-06&until=2026-11-06&attachment=1`
it('주소의 조건으로 찾고 찾을 곳 줄 아래에 "조건: …" 줄 — [조건 지우기]는 조건만 빼고 다시 찾는다', async () => {
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
expect(listRequests()[0]?.params).toEqual({
q: '회의',
mailboxId: 'inbox',
from: '이도윤',
after: new Date(2026, 9, 6).toISOString(),
before: new Date(2026, 10, 7).toISOString(),
hasAttachment: 'true',
limit: 20,
})
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
)
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
})
it('조건만으로 찾으면 머리는 검색어 없이 수만 — 결과 없음은 "조건에 맞는 메일이 없습니다"', async () => {
handler = ({ url }) =>
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
expect(wrapper.get('.search-header__count').text()).toBe('0통')
expect(wrapper.text()).toContain('조건에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain('조건을 바꾸거나 줄여 보세요.')
})
it('검색어와 조건으로 찾아 결과가 없으면 "다른 말로 찾거나 조건을 줄여 보세요."', async () => {
handler = ({ url }) =>
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
})
it('조건 줄 — 보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일", 하루만 고른 기간은 "… 하루"', async () => {
const mine = `from=${encodeURIComponent('김아라')}&to=${encodeURIComponent(USER.email.toUpperCase())}&since=2026-11-06&until=2026-11-06`
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${mine}`)
expect(wrapper.get('[data-search-condition-line]').text()).toContain(
'조건: 내가 보낸 메일, 내가 받은 메일, 2026. 11. 6. 하루',
)
})
it('total 이 null 이면 통 수를 보이지 않는다', async () => {
handler = ({ url }) =>
url === '/search-index'
? indexState('ready')
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’')
})
it('결과 줄은 세 줄 — 보낸 사람 · 시각 / 제목(+ 첨부) / 미리 보기, 맞은 말은 <mark> (v-html 없이)', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
const row = wrapper.get('.message-row')
expect(row.classes()).toContain('message-row--search')
expect(row.get('.message-row__subject').text()).toBe('Re: 유지보수 계약 갱신 건')
expect(row.get('.message-row__subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
const preview = row.get('[data-row-preview]')
expect(preview.text()).toBe('…말씀하신 계약서 초안을 첨부했습니다.')
expect(preview.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
})
it('미리 보기 줄의 맞은 말만 글자색을 진하게(--color-text-secondary) — 흐린 글자 위 노랑은 대비가 모자라다, 제목 줄의 강조는 글자색 그대로 (2026-11-07)', () => {
// jsdom 은 컴포넌트의 style 을 적용하지 않는다 — 규칙을 원본에서 읽는다 (주석은 뺀다)
const rulesOf = (source: string) =>
[...source.slice(source.indexOf('<style')).replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^}]*)\}/g)].map(
([, selector = '', body = '']) => ({ selector: selector.replace('<style scoped>', '').trim(), body }),
)
// 강조 자체는 글자색을 물려받는다 — 제목 줄 · 읽기 화면 · 첨부 이름은 진한 글자 위다
const mark = rulesOf(highlightedTextSource).find((rule) => rule.selector === '.search-mark')
expect(mark?.body).toMatch(/color:\s*inherit;/)
// 목록 줄은 미리 보기 줄 안의 강조만 바꾼다 (제목 줄의 강조를 바꾸는 규칙은 없다)
const overrides = rulesOf(messageListItemSource).filter((rule) => rule.selector.includes('search-mark'))
expect(overrides.map((rule) => rule.selector)).toEqual(['.message-row__preview :deep(.search-mark)'])
expect(overrides[0]?.body).toMatch(/color:\s*var\(--color-text-secondary\);/)
})
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
handler = ({ url }) => {
if (url === '/search-index') return indexState('building')
return ok({
items: [{ ...HIGHLIGHTED, searchHighlight: null }],
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
})
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
const row = wrapper.get('.message-row')
expect(row.findAll('mark')).toHaveLength(0)
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
const notice = wrapper.get('[data-search-notice="indexing"]')
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
expect(notice.text()).toBe(
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
)
expect(notice.element.closest('[role="status"]')).not.toBeNull()
})
it('색인을 준비하는 중의 결과 없음에는 찾지 못하는 예를 더한다 — 위 "검색 준비 중" 줄과 같은 말을 되풀이하지 않는다', async () => {
handler = ({ url }) =>
url === '/search-index' ? indexState('building') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
})
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
handler = ({ url }) =>
url === '/search-index' ? fail(503, 'SYS_001') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.find('[data-search-notice]').exists()).toBe(false)
expect(wrapper.find('[data-search-word-limit]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('한 글자 낱말뿐이면(색인 방식) 결과 머리에 한 줄 — 본문은 찾지 않았다', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
expect(wrapper.get('[data-search-notice="single-char"]').text()).toBe(
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
)
const longer = await mountAt(`/mail/search?q=${encodeURIComponent('김 계약')}&in=all`)
expect(longer.wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
})
it('색인을 준비하는 중에는 한 글자 안내를 보이지 않는다 — 낱말 단위로 찾는 동안은 "검색 준비 중" 줄만', async () => {
handler = ({ url }) =>
url === '/search-index'
? indexState('building')
: ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
const { wrapper } = await mountAt(`/mail/search?q=${encodeURIComponent('김 a')}&in=all`)
expect(wrapper.find('[data-search-notice="single-char"]').exists()).toBe(false)
expect(wrapper.find('[data-search-notice="indexing"]').exists()).toBe(true)
})
it('[더 찾기]는 찾는 동안 aria-busy · "찾는 중…"(다시 눌러도 묻지 않는다) — 찾아서 사라지면 초점을 목록 칸으로', async () => {
let next = 0
let hold: ReturnType<typeof heldResult> | null = null
handler = ({ url }) => {
if (url === '/search-index') return indexState('ready')
if (url !== '/messages') return ok(detail('r1'))
next += 1
if (hold) return hold.promise
return ok({ items: [], pageInfo: { hasNext: true, nextCursor: `c${next}`, total: null } })
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`, { attach: true })
const more = wrapper.get('[data-search-more]')
;(more.element as HTMLButtonElement).focus()
hold = heldResult()
await more.trigger('click')
await flushPromises()
expect(more.attributes('aria-busy')).toBe('true')
expect(more.attributes('aria-disabled')).toBe('true')
expect(more.text()).toBe('찾는 중…')
const sent = listRequests().length
await more.trigger('click')
await flushPromises()
expect(listRequests().length).toBe(sent)
hold.resolve(ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: null } }))
await flushPromises()
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
expect(wrapper.findAll('.message-row')).toHaveLength(1)
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
})
it('조건만 걸린 검색에서 조건 줄의 [조건 지우기]는 검색을 닫는다 — 검색 전 메일함으로 (패널의 [조건 지우기]는 칸만 비운다 — 2026-11-07)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
const clear = wrapper.get('[data-search-condition-line-clear]')
;(clear.element as HTMLButtonElement).focus()
await clear.trigger('click')
await flushPromises()
expect(wrapper.find('[data-search-condition-line]').exists()).toBe(false)
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
})
it('검색어와 조건의 낱말이 8개를 넘는 주소(손으로 고친 주소)는 찾지 않고 "찾을 말이 너무 많습니다" — 연 메일은 강조 없이 받는다', async () => {
const words = encodeURIComponent('하나 둘 셋 넷 다섯')
const body = encodeURIComponent('여섯 일곱 여덟 아홉')
const { wrapper } = await mountAt(`/mail/search?q=${words}&in=inbox&body=${body}`)
expect(listRequests()).toEqual([])
const empty = wrapper.get('[data-search-too-many-words]')
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
expect(empty.text()).toContain(
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
)
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
expect(useToastStore().toasts).toEqual([])
wrapper.unmount()
mounted = []
// 그 주소로 연 메일은 강조 쿼리 없이 받는다 — 서버가 400 으로 거절해 메일을 열지 못하는 일이 없게
const opened = await mountAt(`/mail/search/r1?q=${words}&in=inbox&body=${body}`)
expect(requests.filter((request) => request.url === '/messages/r1').map((request) => request.params)).toEqual([{}])
expect(opened.wrapper.find('#reader-subject').exists()).toBe(true)
})
it('주소의 날짜가 거꾸로면 기간을 버리고, 고를 수 없는 날짜(1970-01-01 ~ 9999-12-30 밖)는 그 날짜만 버린다 — 나머지 조건으로 찾는다', async () => {
const reversed = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-06&until=2026-10-01&attachment=1`)
expect(listRequests()[0]?.params).toEqual({ q: '회의', hasAttachment: 'true', limit: 20 })
expect(reversed.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 첨부 있음')
reversed.wrapper.unmount()
mounted = []
requests.length = 0
await mountAt(`/mail/search?q=${Q}&in=all&since=1969-12-31&until=2026-11-01`)
expect(listRequests()[0]?.params).toEqual({ q: '회의', before: new Date(2026, 10, 2).toISOString(), limit: 20 })
for (const wrapper of mounted) wrapper.unmount()
mounted = []
requests.length = 0
const outside = await mountAt(`/mail/search?q=${Q}&in=all&since=2026-11-01&until=9999-12-31`)
expect(listRequests()[0]?.params).toEqual({ q: '회의', after: new Date(2026, 10, 1).toISOString(), limit: 20 })
expect(outside.wrapper.get('[data-search-condition-line]').text()).toContain('조건: 2026. 11. 1.부터')
})
it('빈 쪽이 이어져 아직 못 찾았으면 결과 없음 대신 [더 찾기] — 누르면 이어 찾고, 더 없으면 결과 없음', async () => {
let next = 0
let exhausted = false
handler = ({ url, params }) => {
if (url === '/search-index') return indexState('ready')
if (url !== '/messages') return ok(detail('r1'))
next += 1
const done = exhausted && params.cursor !== undefined
return ok({ items: [], pageInfo: { hasNext: !done, nextCursor: done ? null : `c${next}`, total: null } })
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
const sent = listRequests().length
exhausted = true
await wrapper.get('[data-search-more]').trigger('click')
await flushPromises()
expect(listRequests().length).toBe(sent + 1)
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
})
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
handler = ({ url, params }) => {
if (url === '/search-index') return indexState('ready')
if (url === '/messages') return ok({ items: [HIGHLIGHTED], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
const highlighted = params.q !== undefined
return ok({
...detail('r1'),
subject: 'Re: 유지보수 계약 갱신 건',
htmlBody: null,
textBody: '계약서 초안을 첨부했습니다.',
attachments: [{ id: 'a1', name: '유지보수_계약서.docx', type: 'application/msword', size: 100, isInline: false }],
searchHighlight: highlighted ? { subject: [[9, 11]], attachments: { a1: [[5, 7]] }, textBody: [[0, 2]] } : null,
})
}
const { wrapper, router } = await mountAt(`/mail/search/r1?q=${Q}&in=inbox&${CONDITIONS_QUERY}&subject=${encodeURIComponent('갱신')}`)
const detailRequest = requests.find((request) => request.url === '/messages/r1')
expect(detailRequest?.params).toEqual({ q: '회의', from: '이도윤', subject: '갱신' })
expect(wrapper.get('#reader-subject').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
expect(wrapper.get('.attachments__name').findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<mark>계약</mark>서 초안을 첨부했습니다.')
// 같은 메일을 메일함에서 열면 강조가 든 본문을 쓰지 않고 쿼리 없이 다시 받는다
await router.push('/mail/inbox/r1')
await flushPromises()
const plain = requests.filter((request) => request.url === '/messages/r1').at(-1)
expect(plain?.params).toEqual({})
expect(wrapper.get('#reader-subject').findAll('mark')).toHaveLength(0)
})
})
describe('MailboxPage — 14단계 검색 · 목록 보기', () => {
let pinia: Pinia
let total: number | null
const HIGHLIGHTED: MessageListItem = {
...summary('r1', { subject: '견적서와 계약 조건 정리' }),
searchHighlight: { subject: [[5, 7]], preview: { text: '', ranges: [] } },
}
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
useMailStore().mailboxes = MAILBOXES
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
total = 1
installMockApi(({ url }) => {
if (url === '/search-index') return ok({ status: 'ready', done: 1, total: 1 })
if (url === '/messages') {
return ok({ items: [HIGHLIGHTED, summary('r2')], pageInfo: { hasNext: false, nextCursor: null, total } })
}
return ok(detail(url.split('/').pop() ?? 'r1'))
})
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
async function mountAt(path: string) {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
await flushPromises()
return { wrapper, router }
}
it('표의 제목에도 맞은 말을 <mark> 로 — 행 높이는 그대로(미리 보기 줄은 분할 보기만)', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
const link = wrapper.get('tbody .mail-table__link')
expect(link.text()).toBe('견적서와 계약 조건 정리')
expect(link.findAll('mark').map((mark) => mark.text())).toEqual(['계약'])
expect(wrapper.find('[data-row-preview]').exists()).toBe(false)
})
it('total 이 null 이면 연 메일의 위치 "n / 전체"를 보이지 않는다 — 이전 · 다음은 그대로', async () => {
total = null
const { wrapper } = await mountAt(`/mail/search/r1?q=${Q}&in=all`)
expect(wrapper.find('.nav-position').exists()).toBe(false)
expect(wrapper.get('button[aria-label="다음 메일"]').attributes('disabled')).toBeUndefined()
})
})
@@ -0,0 +1,704 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
ConversationSummary,
Mailbox,
MailSettings,
MessageDetail,
MessagesPatch,
MessageSummary,
} from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { groupByConversationHintKey } from '@/stores/mail-settings.store'
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
// 14단계 3차 — 목록의 "대화로 묶어 보기" (디자인 원본 ListThreads14 · 계약 2절 "대화로 묶어 보기")
// 실제 메일 화면(MailLayout → MailboxPage → 목록 · 읽기 화면)을 띄운다 — 설정(groupByConversation)이 목록 출처를 정한다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const ME = { name: '김아라', email: USER.email }
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: DOYUN,
to: [ME],
subject: `제목 ${id}`,
preview: '',
// 한국 시간 · UTC 의 날짜가 같은 시각 (시간대와 상관없이 같은 글)
receivedAt: '2026-10-20T03:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [DOYUN],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
const ROWS: ConversationSummary[] = [
conversation('c1', ['m6', 'm5'], {
latest: summary('m6', { subject: 'Re: 전사 회의 참석 안내', isRead: false, hasAttachment: true }),
participants: [HR, ME, MINSU],
messageCount: 6,
isRead: false,
hasAttachment: true,
}),
conversation('c2', ['m4'], { latest: summary('m4', { subject: '11월 비용 정산 안내' }) }),
conversation('c3', ['m3', 'm2'], { latest: summary('m3', { subject: 'Re: 유지보수 계약 갱신 건' }), participants: [ME, DOYUN] }),
]
const page = (items: unknown[], total: number | null = items.length) =>
ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let settings: MailSettings
let rows: ConversationSummary[]
let state: string
let patchSettings: ((request: MockRequest) => MockResult) | null
/** GET /mail-settings 의 응답을 바꾼다 (받지 못함 등) */
let loadSettings: (() => MockResult) | null
/** GET /messages 의 응답을 바꾼다 (붙잡아 두기 등) */
let listMessages: (() => MockResult | Promise<MockResult>) | null
/** 읽기 화면이 여는 메일(GET /messages/{id})의 메일함 — 처음은 받은편지함 */
let openedMailboxIds: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
rows = ROWS
state = 's1'
patchSettings = null
loadSettings = null
listMessages = null
openedMailboxIds = ['inbox']
requests = installMockApi((request) => {
const { url, method } = request
if (url === '/mail-state') return ok({ state })
if (url === '/mailboxes') {
return page([
mailbox('inbox', 'inbox', '받은편지함', 9, 2),
mailbox('drafts', 'drafts', '임시보관함', 1, 0),
mailbox('trash', 'trash', '휴지통', 0, 0),
])
}
if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
if (url === '/mail-signature') {
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
}
if (url === '/mail-vacation') {
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
}
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
if (url === '/mail-settings' && method === 'GET') return loadSettings ? loadSettings() : ok(settings)
if (url === '/mail-settings' && method === 'PATCH') {
if (patchSettings) return patchSettings(request)
settings = { ...settings, ...(request.data as Partial<MailSettings>) }
return ok(settings)
}
if (url === '/scheduled-messages') return page([])
if (url === '/messages' && method === 'PATCH') {
const data = request.data as MessagesPatch
// 옮긴 메일은 서버의 묶은 목록에서도 빠진다 — 다시 맞춘 목록이 옛 줄을 되살리지 않게
if (data.mailboxId) {
rows = rows
.map((row) => {
const messageIds = row.messageIds.filter((id) => !data.ids.includes(id))
const latestId = messageIds[0]
return latestId && latestId !== row.latest.id ? { ...row, messageIds, latest: summary(latestId) } : { ...row, messageIds }
})
.filter((row) => row.messageIds.length > 0)
}
return ok({ updatedIds: data.ids, failedIds: [] })
}
if (url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
if (url === '/conversations') return ok({ items: rows, pageInfo: { hasNext: false, nextCursor: null, total: 6 }, basis: 'index' })
if (url === '/messages' && method === 'GET') {
return listMessages ? listMessages() : page([summary('m6', { isRead: false }), summary('m4')], 9)
}
if (url.endsWith('/conversation')) return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 1 }, basis: 'index' })
const opened = /^\/messages\/([^/]+)$/.exec(url)?.[1]
if (opened && method === 'GET') return ok(detail(summary(opened, { mailboxIds: openedMailboxIds })))
return ok(null)
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
document.title = ''
})
async function settle() {
for (let round = 0; round < 4; round += 1) await flushPromises()
}
async function mountMail(path = '/mail/inbox'): Promise<{ wrapper: VueWrapper; router: Router }> {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push(path)
await router.isReady()
const wrapper = mount(
{ render: () => h(RouterView) },
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
)
mounted.push(wrapper)
await settle()
return { wrapper, router }
}
/** 이 브라우저가 마지막으로 안 묶어 보기 값 — 설정을 받기 전의 짐작 (mail-settings.store) */
function rememberGrouped(grouped = true) {
window.localStorage.setItem(groupByConversationHintKey(USER.email), String(grouped))
}
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
/** 목록 머리의 개수 칸 — 보이는 글 · 화면 낭독 글 · title (분할 보기는 줄인 글을 보인다) */
const headCount = (wrapper: VueWrapper) => {
const count = wrapper.get('.list-header__count')
const shown = count.find('[aria-hidden="true"]')
const spoken = count.find('.visually-hidden')
return {
shown: shown.exists() ? shown.text() : count.text(),
spoken: spoken.exists() ? spoken.text() : count.text(),
title: count.attributes('title'),
}
}
const rowTexts = (wrapper: VueWrapper) =>
wrapper.findAll('.thread-row').map((row) => ({
who: row.get('.thread-row__who').text(),
count: row.find('[data-thread-count]').exists() ? row.get('[data-thread-count]').text() : null,
subject: row.get('.thread-row__subject').text(),
unread: row.classes('is-unread'),
}))
it('켜져 있으면 묶은 목록 — 참여자(나는 끝에) · 통 수(한 통이면 없음) · 마지막 메일 제목 · 하나라도 안 읽었으면 굵게, 머리는 대화 수', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests().map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
// 짐작(이 브라우저가 마지막으로 안 값)이 있으면 메일 목록을 받았다 버리지 않는다
expect(messageListRequests()).toHaveLength(0)
expect(rowTexts(wrapper)).toEqual([
{ who: '안 읽음, 인사팀, 김민수, 나', count: '6통', subject: 'Re: 전사 회의 참석 안내', unread: true },
{ who: '이도윤', count: null, subject: '11월 비용 정산 안내', unread: false },
{ who: '이도윤, 나', count: '2통', subject: 'Re: 유지보수 계약 갱신 건', unread: false },
])
expect(wrapper.findAll('.thread-row')[0]?.find('[aria-label="첨부 있음"]').exists()).toBe(true)
// 분할 보기(좁은 머리)는 안 읽은 메일이 있으면 그 수만 보이고 전체 글은 title · 화면 낭독에 (2026-11-06 실사용 시험)
expect(headCount(wrapper)).toEqual({
shown: '안 읽은 메일 2통',
spoken: '대화 6개, 안 읽은 메일 2통',
title: '대화 6개, 안 읽은 메일 2통',
})
const toggle = wrapper.get('[data-group-toggle]')
expect(toggle.attributes('aria-pressed')).toBe('true')
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
expect(toggle.attributes('title')).toBe('대화로 묶기')
expect(toggle.text()).toBe('대화로 묶기')
})
it('처음 이 브라우저(짐작 없음)면 메일 목록으로 열었다가 설정을 받으면 묶은 목록으로 — 받은 값을 짐작으로 기억한다', async () => {
const { wrapper } = await mountMail()
expect(messageListRequests()).toHaveLength(1)
expect(threadRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('true')
})
it('짐작과 서버 값이 다르면 서버 값이 이긴다 — 다른 PC 에서 껐다', async () => {
rememberGrouped()
settings = { ...settings, groupByConversation: false }
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('false')
})
it('[대화로 묶기]를 누르면 저장하고 곧바로 바꾼다 — 끄면 메일 목록과 "메일 9통, 안 읽은 메일 2통", 다시 켜면 처음 쪽부터', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(messageListRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(headCount(wrapper)).toEqual({
shown: '안 읽은 메일 2통',
spoken: '메일 9통, 안 읽은 메일 2통',
title: '메일 9통, 안 읽은 메일 2통',
})
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
})
it('저장하지 못하면 묶은 목록으로 되돌린다 — 알림은 인터셉터가', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
patchSettings = () => fail(400, 'VAL_001')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
})
it('줄을 누르면 그 메일함의 마지막 메일을 연다 — 연 메일이 든 줄이 골라진 모양', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail()
await wrapper.findAll('.thread-row__main')[2]?.trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
expect(wrapper.get('#reader-subject').text()).toBe('제목 m3')
expect(wrapper.findAll('.thread-row')[2]?.classes()).toContain('is-selected')
})
it('메일이 모두 보내기를 기다리는 줄은 고를 수 없다 — 고르기 칸 · 별이 aria-disabled 이고 누르면 아무것도 하지 않는다', async () => {
rememberGrouped()
rows = [conversation('held', ['m9'], { pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const heldRow = wrapper.findAll('.thread-row')[0]
expect(heldRow?.get('[role="checkbox"]').attributes('aria-disabled')).toBe('true')
expect(heldRow?.get('[data-thread-star]').attributes('aria-disabled')).toBe('true')
await heldRow?.get('[role="checkbox"]').trigger('click')
await heldRow?.get('[data-thread-star]').trigger('click')
await settle()
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
})
it('고르면 도구 줄 "대화 1개 선택됨" — 분할 보기는 "1개 선택됨"만 보이고 화면 낭독은 전체, 하는 일은 그 줄의 메일 모두', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
// 단추 일곱과 한 줄에 들게 보이는 글만 줄인다 (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 두 줄로 넘쳤다)
const count = wrapper.get('.bulk-bar__count')
expect(count.get('[aria-hidden="true"]').text()).toBe('1개 선택됨')
expect(count.get('.visually-hidden').text()).toBe('대화 1개 선택됨')
expect(count.attributes('id')).toBe(wrapper.get('.mail-list-frame').attributes('aria-labelledby'))
// 단추 차례는 그대로
expect(wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))).toEqual([
'markRead',
'markUnread',
'flag',
'move',
'delete',
'clear',
])
await wrapper.get('[data-tool="markRead"]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6', 'm5'], isRead: true },
])
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
})
it('줄에 한 삭제의 알림은 "받은편지함에 있는 메일 N통을 휴지통으로 …" — 줄의 통 수는 대화 전체라서, 되돌리면 "메일 N통을 받은편지함으로 되돌렸습니다."', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6', 'm5'], mailboxId: 'trash' },
])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').at(-1)?.data).toEqual({
ids: ['m6', 'm5'],
mailboxId: 'inbox',
})
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
})
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창 · 알림은 "휴지통에 있는 메일 N통을 …"(줄의 통 수는 대화 전체라서)이고 그 줄의 메일을 POST /message-deletions', async () => {
rememberGrouped()
rows = [
conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 }),
conversation('t2', ['m1']),
]
const { wrapper } = await mountMail('/mail/trash')
// 줄은 대화 전체의 "5통" — 지우는 것은 휴지통의 2통이다 (2026-11-07)
expect(wrapper.findAll('.thread-row')[0]?.get('[data-thread-count]').text()).toBe('5통')
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
const dialog = wrapper.get('#bulk-purge-title')
expect(dialog.text()).toBe('휴지통에 있는 메일 2통을 영구 삭제할까요?')
expect(wrapper.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8', 'm7'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('휴지통에 있는 메일 2통을 영구 삭제했습니다.')
})
it('묶은 휴지통이어도 읽기 화면의 영구 삭제(한 통)는 지금 글 그대로 — "메일 1통을 영구 삭제할까요?" · "메일 1통을 영구 삭제했습니다."', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 })]
openedMailboxIds = ['trash']
const { wrapper } = await mountMail('/mail/trash/m8')
const remove = wrapper.get('[data-reader-delete]')
expect(remove.text()).toBe('영구 삭제')
await remove.trigger('click')
await settle()
expect(wrapper.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
// 고른 줄이 없어 도구 줄(과 그 확인 창)은 없다 — 확인 단추는 읽기 화면의 것 하나
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 1통을 영구 삭제했습니다.')
})
it('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', async () => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const { wrapper, router } = await mountMail()
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
expect(heads).not.toContain('크기')
// 사람 칸 머리는 어느 메일함이든 "참여자" — 줄이 대화의 참여자다 (2026-11-06 실사용 시험)
expect(heads).toContain('참여자')
expect(heads).not.toContain('보낸 사람')
// 목록 보기(넓은 머리)는 개수 글을 다 보인다
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
expect(wrapper.find('.list-header__count .visually-hidden').exists()).toBe(false)
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 대화 모두 고르기')
expect(wrapper.get('table.mail-table caption').text()).toBe('받은편지함 대화 목록')
expect(wrapper.findAll('tbody tr').map((row) => row.get('[data-message-id]').attributes('data-message-id'))).toEqual([
'm6',
'm4',
'm3',
])
// 목록 보기에서는 이름까지
expect(wrapper.get('[data-group-toggle]').attributes('title')).toBeUndefined()
await router.push('/mail/inbox/m4')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('2 / 6')
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
})
it('새 메일 확인 — 바뀌었으면 묶은 목록을 불러온 줄 수만큼 조용히 다시 받는다', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests()).toHaveLength(1)
rows = [conversation('c9', ['m9'], { latest: summary('m9', { subject: '새 대화' }) }), ...ROWS]
state = 's2'
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(threadRequests()[1]?.config.silent).toBe(true)
expect(messageListRequests()).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
})
it('다른 탭에서 끈 묶기 — 60초 뒤 탭으로 돌아오면 설정을 조용히 다시 받아 메일 목록으로 다시 연다 (2026-11-06 실사용 시험)', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
const settingsRequests = () => requests.filter((request) => request.url === '/mail-settings' && request.method === 'GET')
expect(settingsRequests()).toHaveLength(1)
// 다른 탭이 껐다
settings = { ...settings, groupByConversation: false }
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(settingsRequests()).toHaveLength(1)
clock.advance(1)
document.dispatchEvent(new Event('visibilitychange'))
await settle()
expect(settingsRequests()).toHaveLength(2)
expect(settingsRequests()[1]?.config.silent).toBe(true)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
expect(useToastStore().toasts).toEqual([])
})
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
it.each([
['로그아웃', () => useAuthStore().clearAuth()],
['세션 만료', () => useAuthStore().expireSession()],
])('묶기를 켠 채 %s — 메일 목록 · 묶은 목록 요청이 0건이고 비운 목록이 다시 차지 않는다', async (_name, end) => {
rememberGrouped()
await mountMail()
const before = { messages: messageListRequests().length, threads: threadRequests().length }
end()
await settle()
expect({ messages: messageListRequests().length, threads: threadRequests().length }).toEqual(before)
expect(useMailStore().conversations).toEqual([])
expect(useMailStore().listSource).toBeNull()
})
})
describe('읽기 화면 [삭제] 뒤 갈 곳 — 분할 보기 (리뷰 L3)', () => {
it('그 줄에 메일이 남으면 그 줄의 다음 칸(더 오래된 메일), 줄이 비면 다음 줄의 마지막 메일', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail('/mail/inbox/m6')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6'], mailboxId: 'trash' },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m5')
expect(wrapper.findAll('.thread-row')[0]?.classes()).toContain('is-selected')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c2', 'c3'])
})
})
describe('목록 보기의 읽기 화면 — 대화 사이를 오간다', () => {
beforeEach(() => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
})
it('줄의 마지막 메일이 아닌 메일도 그 메일이 든 줄의 위치 — [다음]은 다음 대화, [이전]은 앞 대화의 마지막 메일', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
expect(wrapper.get('.nav-position').text()).toBe('1 / 6')
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
await router.push('/mail/inbox/m2')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('3 / 6')
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
})
it('[목록으로] — 표로 돌아가 그 메일이 든 줄의 링크(마지막 메일)로 초점을 돌려준다', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
const back = wrapper.findAll('a').find((link) => link.text() === '목록으로')
await back?.trigger('click', { button: 0 })
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(document.activeElement?.getAttribute('data-message-id')).toBe('m6')
})
it('메일이 모두 보내기를 기다리는 줄 — Ctrl · Shift + 클릭으로도 고르지 않고, 날짜 칸은 예약 표시', async () => {
rows = [
conversation('held', ['m9'], {
latest: summary('m9', { pendingSend: { sendAt: '2026-11-10T01:00:00Z', kind: 'scheduled' } }),
pendingSendIds: ['m9'],
}),
...ROWS,
]
const { wrapper } = await mountMail()
const heldRow = wrapper.get('tr[data-row-id="held"]')
await heldRow.get('td.mail-table__subject').trigger('click', { ctrlKey: true })
await heldRow.get('td.mail-table__subject').trigger('click', { shiftKey: true })
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(heldRow.get('td.mail-table__date').text()).toMatch(/^예약 · /)
// 다른 줄은 Ctrl + 클릭으로 고른다
await wrapper.get('tr[data-row-id="c2"] td.mail-table__subject').trigger('click', { ctrlKey: true })
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
})
})
it('빠르게 켬 → 끔 → 켬 — 마지막 목록만 남고 버린 메일 목록 요청은 끊는다 (리뷰 L7)', async () => {
rememberGrouped()
let releaseMessages: (result: MockResult) => void = () => undefined
listMessages = () =>
new Promise<MockResult>((resolve) => {
releaseMessages = resolve
})
const { wrapper } = await mountMail()
const toggle = () => wrapper.get('[data-group-toggle]')
await toggle().trigger('click')
await settle()
expect(messageListRequests()).toHaveLength(1)
await toggle().trigger('click')
await settle()
expect(messageListRequests()[0]?.config.signal?.aborted).toBe(true)
releaseMessages(page([summary('late')], 1))
await settle()
expect(toggle().attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.message-row')).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c1', 'c2', 'c3'])
expect(settings.groupByConversation).toBe(true)
})
it('설정을 받지 못했으면 짐작대로 묶고 [대화로 묶기]도 쓸 수 있다 — 저장 응답을 받은 설정으로 (리뷰 L4)', async () => {
rememberGrouped()
loadSettings = () => fail(503, 'SYS_001')
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/mail-settings').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
})
it('줄의 중요 표시가 보내기를 기다리는 메일에만 있으면 별로 풀리지 않는다 — 다시 맞춰 보고 그 까닭을 알린다', async () => {
rememberGrouped()
rows = [conversation('hf', ['m9', 'm8'], { isFlagged: true, pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const syncs = threadRequests().length
await wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m8'], isFlagged: false },
])
expect(threadRequests().length).toBe(syncs + 1)
expect(wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').attributes('aria-pressed')).toBe('true')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
])
})
it('임시보관함의 제목 없는 저장본 — 흐린 "(제목 없음)", 굵게 하지 않고 누르면 쓰기 화면에서 이어 쓴다 (리뷰 L5)', async () => {
rememberGrouped()
rows = [
conversation('d1', ['d1'], {
latest: summary('d1', { subject: '', mailboxIds: ['drafts'], isRead: false }),
participants: [ME],
isRead: false,
}),
]
const { wrapper } = await mountMail('/mail/drafts')
const row = wrapper.get('.thread-row')
expect(row.get('.thread-row__subject').text()).toBe('(제목 없음)')
expect(row.get('.thread-row__subject').classes()).toContain('is-placeholder')
expect(row.classes()).not.toContain('is-unread')
expect(row.get('.thread-row__who').text()).toBe('받는 사람: 김아라')
expect(row.get('.thread-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
})
it('검색 결과는 묶지 않는다 — 켜져 있어도 GET /messages 이고 [대화로 묶기]가 없다', async () => {
rememberGrouped()
const { wrapper } = await mountMail('/mail/search?q=%ED%9A%8C%EC%9D%98&in=inbox')
expect(threadRequests()).toHaveLength(0)
expect(messageListRequests()[0]?.params).toMatchObject({ q: '회의', mailboxId: 'inbox' })
expect(wrapper.find('[data-group-toggle]').exists()).toBe(false)
})
})
+37 -13
View File
@@ -7,6 +7,10 @@
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
// 대화로 묶어 보기(14단계 — 설정 groupByConversation)를 켰으면 메일함 목록의 출처가 GET /conversations 다 — 주소는 같고 한 줄이 대화 하나다.
// 설정을 받기 전 · 받지 못했으면 이 브라우저의 짐작(mail-settings.store — 처음이면 끔)으로 연다(기다리게 하지 않는다) — 받아 보니 다르면 그때 바꾼다.
// 켜고 끄면(목록 머리의 [대화로 묶기] · 설정 화면) 앞 목록의 요청을 끊고 처음 쪽부터 받는다. 검색 결과 · 예약됨은 묶지 않는다.
// 로그아웃 · 세션 만료 · 제한된 세션(canUseApp false)에는 목록을 부르지 않는다 — 떠나는 화면이 막 비운 목록을 다시 채우지 않게 (리뷰 M1)
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -18,13 +22,18 @@ import MessageReader from '@/components/mail/MessageReader.vue'
import MessageTable from '@/components/mail/MessageTable.vue'
import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { isScheduledRouteName } from '@/shared/utils/mail-list-source'
import { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailViewStore } from '@/stores/mail-view.store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const viewStore = useMailViewStore()
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
@@ -40,10 +49,13 @@ const isListView = computed(() => viewStore.mode === 'list')
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
// 메일함 · 묶어 보기가 바뀔 때 — 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 스토어가 보이던 채로 새로 고친다.
// 세션이 끝났으면 부르지 않는다 (머리말 — 로그아웃은 스토어를 비우고 로그인 화면으로 간다)
watch(
mailboxId,
(id) => {
if (id) void mailStore.openMailbox(id)
[mailboxId, () => settingsStore.groupByConversation, () => authStore.canUseApp],
([id, grouped, usable]) => {
if (!id || !usable) return
void (grouped ? mailStore.openConversations(id) : mailStore.openMailbox(id))
},
{ immediate: true },
)
@@ -57,25 +69,37 @@ watch(
{ immediate: true },
)
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
// 검색어 · 범위 · 조건(14단계)이 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
// 검색어도 조건도 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
watch(
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
([searching, q, scope]) => {
[isSearching, () => (routeSearch.value ? listKeyOf(routeSearch.value) : null)],
([searching, key]) => {
if (!searching) return
if (q === null || scope === null) {
const search = routeSearch.value
if (key === null || !search) {
void closeSearch()
return
}
void mailStore.openSearch(q, scope)
void mailStore.openSearch(search.q, search.scope, { conditions: search.conditions })
},
{ immediate: true },
)
/**
* 검색 결과에서 연 메일의 강조 쿼리 (14단계 — 검색어와 글 조건 넷만). 메일함에서 연 메일은 null —
* 쿼리가 바뀌면 같은 메일이어도 다시 받는다(검색 결과의 강조 본문이 메일함 화면에 남지 않게).
* 낱말이 한도를 넘는 주소(손으로 고친 주소)면 싣지 않는다 — 서버가 400 으로 거절해 메일을 열지 못한다
*/
const highlightQuery = computed(() =>
isSearching.value && routeSearch.value && !isSearchTooLong(routeSearch.value)
? messageHighlightQueryOf(routeSearch.value.q, routeSearch.value.conditions)
: null,
)
watch(
messageId,
(id) => {
if (id) void mailStore.openMessage(id)
[messageId, () => messageHighlightKey(highlightQuery.value)],
([id]) => {
if (id) void mailStore.openMessage(id, highlightQuery.value)
else mailStore.closeMessage()
},
{ immediate: true },
@@ -276,7 +276,7 @@ describe('MailLargeFilesPage', () => {
expect(view.get('[data-large-usage]').text()).toBe('0 / 10 GB')
expect(view.get('[data-large-empty]').text()).toContain('링크로 보낸 파일이 없습니다')
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다.')
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB가 넘는 파일을 고르면 링크로 보냅니다.')
expect(view.find('#large-unsent-title').exists()).toBe(false)
})
@@ -13,10 +13,10 @@ import MailSendReadPage from './MailSendReadPage.vue'
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
interface Server {
settings?: MailSettings
settings?: Partial<MailSettings>
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
load?: MockResult
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
@@ -118,6 +118,37 @@ describe('MailSendReadPage', () => {
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('대화로 묶기(14단계 — 목록 위 단추와 같은 이름) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
const wrapper = await mountPage()
const grouping = () =>
wrapper.findAll('[data-group-by-conversation]').map((button) => [button.text(), button.attributes('aria-pressed')])
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶기')
expect(grouping()).toEqual([
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
expect(patches()).toEqual([{ groupByConversation: true }])
expect(grouping()).toEqual([
['끔', 'false'],
['켬', 'true'],
])
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('묶어 보기 필드가 없는 서버(14단계 앞)면 끔으로 그린다', async () => {
const wrapper = await mountPage({ load: ok({ undoSendSeconds: 10, externalImages: 'ask' }) })
expect(wrapper.get('[data-group-by-conversation="false"]').attributes('aria-pressed')).toBe('true')
})
it('지금 값을 다시 누르면 보내지 않는다', async () => {
const wrapper = await mountPage()
@@ -3,14 +3,20 @@
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
// 「대화로 묶기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정 · 같은 이름) + 설명.
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { EXTERNAL_IMAGES_OPTIONS, MAIL_SETTINGS_TEXT, UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import {
EXTERNAL_IMAGES_OPTIONS,
GROUP_BY_CONVERSATION_OPTIONS,
MAIL_SETTINGS_TEXT,
UNDO_SEND_OPTIONS,
} from '@/shared/constants/mail-settings'
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
@@ -28,6 +34,10 @@ function chooseImages(option: ExternalImagesSetting) {
void settingsStore.update('externalImages', option)
}
function chooseGrouping(grouped: boolean) {
void settingsStore.update('groupByConversation', grouped)
}
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
void settingsStore.load()
</script>
@@ -135,6 +145,44 @@ void settingsStore.load()
{{ MAIL_SETTINGS_TEXT.imagesNote }}
</p>
</fieldset>
<hr class="send-read__rule">
<section
class="send-read__group"
aria-labelledby="send-read-group-title"
>
<h2
id="send-read-group-title"
class="send-read__title"
>
{{ MAIL_SETTINGS_TEXT.groupTitle }}
</h2>
<div
role="group"
class="segmented segmented--outline send-read__undo"
aria-labelledby="send-read-group-title"
aria-describedby="send-read-group-hint"
>
<button
v-for="grouped in GROUP_BY_CONVERSATION_OPTIONS"
:key="String(grouped)"
type="button"
class="segmented__item"
:aria-pressed="settingsStore.settings.groupByConversation === grouped"
:data-group-by-conversation="String(grouped)"
@click="chooseGrouping(grouped)"
>
{{ grouped ? MAIL_SETTINGS_TEXT.groupOn : MAIL_SETTINGS_TEXT.groupOff }}
</button>
</div>
<p
id="send-read-group-hint"
class="form-message"
>
{{ MAIL_SETTINGS_TEXT.groupHint }}
</p>
</section>
</template>
<div
+1 -1
View File
@@ -120,7 +120,7 @@ describe('SharePage', () => {
expect(view.get('.share__safety').text()).toBe(
'이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
)
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr(으)로 알려 주세요.')
expect(view.get('.share__english').text()).toBe(
'Files from 김아라 (ara.kim@arasystem.it.kr). Download by Nov 29, 2026 (KST). This page will never ask you to log in or enter a password.',
)
+6 -1
View File
@@ -22,6 +22,7 @@ import { APP_NAME } from '@/shared/constants/brand'
import { SHARE_PAGE_TEXT, SHARE_SCAN_POLL_DELAYS_MS, SHARE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize } from '@/shared/utils/format'
import { directionParticle } from '@/shared/utils/korean'
import {
formatKstEnglishDate,
formatKstFullDate,
@@ -71,6 +72,10 @@ const view = computed(() => {
return 'active'
})
/** 신고 주소 뒤의 글 — 조사는 주소에 붙이고 받침에 맞춘다 (영문으로 끝나면 "(으)로") */
const reportAfter = computed(() =>
link.value ? SHARE_PAGE_TEXT.reportAfter(directionParticle(link.value.reportAddress)) : '',
)
const untilText = computed(() => (link.value ? SHARE_PAGE_TEXT.until(formatKstFullDate(link.value.expiresAt)) : ''))
const untilTail = computed(() => {
if (!link.value) return ''
@@ -395,7 +400,7 @@ watch(
v-if="link && view !== 'loading'"
class="share__report"
>
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ SHARE_PAGE_TEXT.reportAfter }}
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ reportAfter }}
</p>
<p
v-if="english"
+17
View File
@@ -330,6 +330,23 @@ export const ICONS = {
path('M7 19h5'),
rect(16, 12, 6, 10, 2),
],
// ── 14단계: 검색 (디자인 원본 Search14) ──
/** 검색 칸의 [조건] (조절 막대 셋) */
sliders: [
line(4, 21, 4, 14),
line(4, 10, 4, 3),
line(12, 21, 12, 12),
line(12, 8, 12, 3),
line(20, 21, 20, 16),
line(20, 12, 20, 3),
line(1, 14, 7, 14),
line(9, 8, 15, 8),
line(17, 16, 23, 16),
],
// ── 14단계: 대화로 묶어 보기 (디자인 원본 ListThreads14) ──
/** 목록 머리의 [대화로 묶기] (말풍선) */
'message-square': [path('M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z')],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
+13 -5
View File
@@ -90,9 +90,12 @@ export const LARGE_FILE_TEXT = {
`받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다. 오늘 보내면 ${until}까지입니다.`,
/** 보관 사용량이 80%를 넘을 때 링크 묶음 머리 오른쪽 */
quotaWarning: (usage: string) => `보관 ${usage}`,
/** 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status) */
/**
* 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status).
* total 은 늘 "25MB" 꼴(formatMegabytes — 메가바이트로 읽어 받침이 없다)이라 조사를 '를'로 붙여 쓴다
*/
switchedByTotal: (name: string, size: string, total: string) =>
`‘${name}’(${size})는 메일에 담으면 합계가 ${total} 를 넘어 링크로 보냅니다.`,
`‘${name}’(${size})는 메일에 담으면 합계가 ${total}를 넘어 링크로 보냅니다.`,
sendAsLink: '링크로 보내기',
pause: '멈춤',
resume: '이어 올리기',
@@ -123,7 +126,7 @@ export const LARGE_FILE_TEXT = {
missingExpired: '기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요.',
missingDeleted: '[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.',
missingUnknown: '파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.',
tooLarge: (max: string) => `파일 하나는 ${max} 까지 보낼 수 있습니다.`,
tooLarge: (max: string) => `파일 하나는 ${max}까지 보낼 수 있습니다.`,
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다.`,
overQuota: (limit: string) =>
`대용량 첨부는 ${limit}까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
@@ -271,8 +274,9 @@ export const LARGE_FILE_SETTINGS_TEXT = {
more: '더 보기',
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
/** max 는 늘 "20MB" 꼴(formatMegabytes — 받침이 없다)이라 조사를 '가'로 붙여 쓴다 */
emptyHint: (max: string, days: string) =>
`메일 쓰기에서 ${max} 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
`메일 쓰기에서 ${max}가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
noSubject: '(제목 없음)',
} as const
@@ -300,7 +304,11 @@ export const SHARE_PAGE_TEXT = {
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다.',
noLogin: '이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
reportBefore: '받을 이유가 없는 파일이면 ',
reportAfter: ' 로 알려 주세요.',
/**
* 신고 주소 뒤 — 조사는 주소에 붙여 쓰고 받침에 맞춘다(korean.ts 의 directionParticle).
* 주소는 운영 설정이고 영문으로 끝나 보통 "(으)로" — "postmaster@arasystem.it.kr(으)로 알려 주세요."
*/
reportAfter: (particle: string) => `${particle} 알려 주세요.`,
englishActive: (sender: string, email: string, until: string) =>
`Files from ${sender} (${email}). Download by ${until} (KST). This page will never ask you to log in or enter a password.`,
scanningTitle: '보안 검사 중입니다',
+2 -2
View File
@@ -60,8 +60,8 @@ export const MAIL_BLOCK_TEXT = {
scopeGroup: '차단할 범위',
scopeAddress: '이 주소만',
scopeDomain: '이 도메인 전체',
/** "@cloudbrief.example 에서 오는 메일 모두" */
scopeDomainHint: (domain: string) => `${domain} 에서 오는 메일 모두`,
/** "@cloudbrief.example에서 오는 메일 모두" — 조사는 붙여 쓴다 ('에서'는 받침과 상관없다) */
scopeDomainHint: (domain: string) => `${domain}에서 오는 메일 모두`,
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
@@ -0,0 +1,92 @@
// 같은 대화 — 읽기 화면의 "같은 대화" 칸 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "읽기 화면 — 같은 대화 (Thread14)")
// 와 목록의 "대화로 묶어 보기" (같은 README › "목록 — 대화로 묶어 보기 (ListThreads14)" — 아래 MAIL_CONVERSATION_LIST_TEXT)
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
/** 한 번에 받는 수 — 계약의 처음 값이자 최대(100). 대화는 대개 한 쪽이다 */
export const MAIL_CONVERSATION_PAGE_SIZE = 100
/**
* 쪽이 모자라면 이어 묻는 횟수 — 칸을 열 때 · [더 보기] 한 번에 요청을 이만큼까지 보낸다(검색의 이어 찾기 MAIL_SEARCH_FILL_MAX_REQUESTS 와 같은 결).
* 엔진 방식(그 직원의 색인을 채우는 동안)은 한 요청이 스레드를 1,000통까지만 훑어 hasNext 인데 항목이 적거나 0개인 쪽이 온다 —
* 새로 한 쪽(100통)이 차거나 더 없으면 멈추고, 그래도 남으면 [더 보기]로 사용자가 잇는다
*/
export const MAIL_CONVERSATION_FILL_MAX_REQUESTS = 5
/**
* 요청 하나의 시간 한도 — 서버는 엔진 방식의 한 요청을 15초 안에 끝내고(넘으면 503 MAIL_001) 그 전에 색인을 1.5초 맞춘다.
* 기본 10초(useApi)로는 서버가 답하기 전에 끊기므로 넉넉히 둔다
*/
export const MAIL_CONVERSATION_TIMEOUT_MS = 30_000
/**
* 바뀜 소식으로 다시 받는 사이의 최소 간격 — 마지막으로 받기가 **끝난** 뒤 이만큼 안에 온 소식은 모았다가 간격이 끝날 때 한 번 받는다.
* 소식은 메일함들의 통 수가 바뀌었을 때만 온다(새 메일 · 지움 · 옮김 — 읽음 · 중요 표시만 바뀐 것은 아니다, mail.store.ts).
* 대화 경로도 요청 수 한도(경로마다 · IP 마다 1분 100회 — 사무실은 한 IP)를 함께 쓴다
*/
export const MAIL_CONVERSATION_REFRESH_GAP_MS = 10_000
/**
* 접힌 줄의 받아 둔 본문 수 — 최근에 본 것부터 이만큼만 둔다(다시 펴면 다시 받는다). 펼친 줄의 본문은 세지 않고 늘 둔다
*/
export const MAIL_CONVERSATION_DETAIL_CACHE_MAX = 10
/** 읽기 화면의 "같은 대화" 칸 (디자인 원본 Thread14) */
export const MAIL_CONVERSATION_TEXT = {
/** 칸 제목 — 통 수를 모르면(total null — 쪽을 넘긴 뒤 · 엔진 방식의 큰 대화) 수 없이 */
title: '같은 대화',
titleCount: (count: string) => `같은 대화 ${count}통`,
/** 내가 보낸 메일의 이름 자리 (3차 "대화로 묶어 보기"의 참여자 줄도 같은 말) */
me: '나',
current: '지금 보고 있는 메일',
reply: '답장',
openOnly: '이 메일만 열기',
/**
* 펼친 줄의 본문 아래 · [답장] 위 — 첨부 목록은 읽기 화면에만 있다. 수는 읽기 화면의 "첨부 파일 N개"와 같다(본문 그림 빼고 · 대용량 첨부 더해)
* (2026-11-06 실사용 시험 — 펼친 줄에 첨부가 있다는 표시가 없었다)
*/
attachmentsNote: (count: string) => `첨부 파일 ${count}개는 [이 메일만 열기]를 누르면 볼 수 있습니다.`,
/** 펼친 줄의 머리 — 읽기 화면 머리와 같은 이름 ("받는 사람 김아라 <…>" · "제목 …") */
recipients: '받는 사람',
subject: '제목',
/** 펼친 줄의 본문을 받는 동안 (화면 낭독) */
bodyLoading: '메일 불러오는 중',
/** 펼친 줄의 본문 틀 이름 (화면 낭독) — 같은 대화는 제목이 같아 보낸 사람 · 시각으로 가른다 */
bodyTitle: (who: string, time: string) => `메일 본문: ${who}, ${time}`,
more: '더 보기',
moreBusy: '불러오는 중…',
loadFailed: '같은 대화를 불러오지 못했습니다.',
retry: '다시 시도',
traceCode: (code: string) => `문의 코드 ${code}`,
} as const
// ── 목록 — 대화로 묶어 보기 (디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)") ─────────
/** 목록 머리 · 대화 줄 · 여러 통 처리의 글 (디자인 원본 ListThreads14 — 화면 문구는 README 「화면 문구 — 사용자에게 말하듯」) */
export const MAIL_CONVERSATION_LIST_TEXT = {
/** 목록 머리 오른쪽의 눌림 단추 (aria-pressed) — 분할 보기에서는 아이콘만이라 title 도 이 글이다 */
toggle: '대화로 묶기',
/** 목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (안 읽은 메일이 없으면 뒤를 뺀다) */
conversationCount: (count: string) => `대화 ${count}개`,
messageCount: (count: string) => `메일 ${count}통`,
unreadCount: (count: string) => `안 읽은 메일 ${count}통`,
/** 대화 줄의 통 수 — 회색 글 (한 통이면 없다) */
rowCount: (count: string) => `${count}통`,
/** 여러 통 처리 도구 줄의 머리 — 고른 것은 대화다 */
selected: (count: string) => `대화 ${count}개 선택됨`,
/**
* 분할 보기(목록 칸이 좁다)의 도구 줄 머리 — 보이는 글만 줄인다(단추 일곱이 한 줄에 들게). 화면 낭독은 selected 전체를 읽는다
* (2026-11-06 실사용 시험 — "대화 1개 선택됨"이면 단추가 두 줄로 넘쳤다)
*/
selectedShort: (count: string) => `${count}개 선택됨`,
/** 표 머리의 고르기 상자 */
selectAll: '불러온 대화 모두 고르기',
/**
* 목록 보기 표의 사람 칸 머리 — 어느 메일함이든 같다(줄이 대화의 참여자라서. 묶지 않은 표는 "보낸 사람" · 보낸편지함 "받는 사람" 그대로)
* (2026-11-06 실사용 시험 — 보낸편지함에서 머리는 "보낸 사람"인데 줄은 "받는 사람: …"이었다)
*/
participantsColumn: '참여자',
/** 목록 보기 표의 caption — 메일 목록은 "받은편지함 메일 목록" */
tableCaption: (mailbox: string) => `${mailbox} 대화 목록`,
/** 줄의 메일이 모두 보내기를 기다리는 메일일 때 별(중요 표시)의 까닭 — 고르기 칸은 메일 줄과 같은 글(SEND_LATER_TEXT.heldNotSelectable) */
heldFlagNote: '보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
} as const
@@ -0,0 +1,137 @@
// 메일 검색 14단계 — 조건 검색 · 이어 찾기 · 검색 준비 중 · 한 글자 안내 (계약 2절 "검색 — 14단계" · 디자인 README 「14단계 — 검색 · 대화 묶기」)
// 3단계의 검색 칸 · 결과 머리 글(MAIL_SEARCH_TEXT)과 검색어 한도는 shared/constants/mail.ts 에 있다.
// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다 — 이 파일이 원본이다.
/**
* 화면 주소(/mail/search?…)에 싣는 조건의 이름 — 검색어처럼 앞단 nginx 의 접근 로그에 쿼리가 남지 않는다 (계약 2절 "검색 — 14단계").
* 글 칸 넷은 API 와 같은 이름이고, 기간은 브라우저 시간대의 날짜(YYYY-MM-DD — 시작 날짜 · 끝 날짜 모두 든다),
* 첨부는 켰을 때만 `attachment=1`. API 의 after · before 는 보낼 때 이 날짜로 만든다(mail-search.ts)
*/
export const SEARCH_ROUTE_PARAMS = {
from: 'from',
to: 'to',
subject: 'subject',
body: 'body',
since: 'since',
until: 'until',
attachment: 'attachment',
} as const
/**
* 고를 수 있는 날짜의 범위 (YYYY-MM-DD — 두 날짜 모두 든다). 끝은 9999-12-30 — 9999-12-31 은 UTC 의 다음 날 0시가 10000년이라
* 서버가 받지 않는다(어느 시간대에서도 보낼 수 있게). 주소의 날짜도 이 밖이면 버린다 (mail-search.ts 의 isSearchDay)
*/
export const SEARCH_DAY_MIN = '1970-01-01'
export const SEARCH_DAY_MAX = '9999-12-30'
/** 주소의 attachment 값 — 첨부 파일이 있는 메일만 */
export const SEARCH_ATTACHMENT_ROUTE_VALUE = '1'
/** API 의 hasAttachment 값 — 이것만 받는다 (다른 값은 400 VAL_001) */
export const SEARCH_HAS_ATTACHMENT_VALUE = 'true'
/**
* 결과가 모자라면 이어 묻는 횟수 — 한 번 찾을 때(검색을 열 때 · [메일 더 보기] · [더 찾기] · 목록 보기의 [다음]) 요청을 이만큼까지 보낸다.
* 서버(색인 방식)는 한 요청에 메일 3,000통까지만 확인해 hasNext 인데 항목이 적거나 0개일 수 있다 — 한 쪽(20통)을 채우거나 더 없으면 멈춘다.
* 5번이면 1만 5천 통을 훑는다(한 요청 1초 안 — 계약). 넘으면 [더 찾기]로 사용자가 잇는다
*/
export const MAIL_SEARCH_FILL_MAX_REQUESTS = 5
/** 기간 고르기 — 최근 N주 · 달 · 해는 그만큼 앞의 같은 날부터 오늘까지(두 날짜 모두 든다) */
export type SearchPeriod = 'all' | 'week' | 'month' | 'quarter' | 'year' | 'custom'
/** 미리 정한 기간 — 오늘에서 뺄 날 · 달 수 */
export interface SearchPeriodPreset {
id: Exclude<SearchPeriod, 'all' | 'custom'>
days?: number
months?: number
}
export const SEARCH_PERIOD_PRESETS: readonly SearchPeriodPreset[] = [
{ id: 'week', days: 7 },
{ id: 'month', months: 1 },
{ id: 'quarter', months: 3 },
{ id: 'year', months: 12 },
]
/** 기간 고르기의 차례 · 이름 (디자인 원본 Search14) */
export const SEARCH_PERIOD_LABELS: Readonly<Record<SearchPeriod, string>> = {
all: '전체 기간',
week: '최근 1주',
month: '최근 1달',
quarter: '최근 3달',
year: '최근 1년',
custom: '날짜 고르기',
}
export const SEARCH_PERIOD_ORDER: readonly SearchPeriod[] = ['all', 'week', 'month', 'quarter', 'year', 'custom']
/** 검색 칸의 [조건] 단추와 「조건으로 찾기」 패널 (디자인 원본 Search14) */
export const SEARCH_CONDITIONS_TEXT = {
toggle: '조건',
toggleTitle: '조건으로 찾기',
/** 조건이 걸려 있을 때 [조건] 단추에 더해 읽어 주는 글 */
toggleApplied: '조건이 걸려 있음',
title: '조건으로 찾기',
from: '보낸 사람',
to: '받는 사람',
subject: '제목',
body: '내용',
period: '기간',
attachment: '첨부',
fromPlaceholder: '이름이나 메일 주소',
toPlaceholder: '이름이나 메일 주소',
subjectPlaceholder: '제목에 든 말',
/** 칸 이름("내용")과 같은 말로 — 조건 줄도 "내용에 ‘…’이 든 메일"이다 */
bodyPlaceholder: '내용에 든 말',
since: '시작 날짜',
until: '끝 날짜',
attachmentOnly: '첨부 파일이 있는 메일만',
hint: '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
periodOrder: '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.',
periodRange: '날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.',
clear: '조건 지우기',
apply: '찾기',
} as const
/** 결과 머리 아래 "조건: …" 줄 (디자인 원본 Search14 "조건으로 찾음") — 조건은 쉼표로 잇는다 */
export const SEARCH_CONDITION_LINE_TEXT = {
prefix: '조건: ',
separator: ', ',
from: (who: string) => `${who} 님이 보낸 메일`,
to: (who: string) => `${who} 님이 받은 메일`,
/** 보낸 사람 · 받는 사람 칸의 값이 내 이름이나 내 주소일 때 (2026-11-06 실사용 시험 — "김아라 님이 보낸 메일"보다 바로 읽힌다) */
fromMe: '내가 보낸 메일',
toMe: '내가 받은 메일',
/** 조사(이 · 가)는 부르는 쪽이 붙인다 */
subject: (text: string, particle: string) => `제목에 ‘${text}’${particle} 든 메일`,
body: (text: string, particle: string) => `내용에 ‘${text}’${particle} 든 메일`,
since: (day: string) => `${day}부터`,
until: (day: string) => `${day}까지`,
range: (since: string, until: string) => `${since}부터 ${until}까지`,
/** 시작 날짜와 끝 날짜가 같은 날 — "2026. 11. 6.부터 11. 6.까지" 대신 */
oneDay: (day: string) => `${day} 하루`,
attachment: '첨부 있음',
clear: '조건 지우기',
} as const
/** 결과 머리 아래 안내 줄 · 결과가 모자랄 때 (디자인 원본 Search14 "검색 준비 중") */
export const SEARCH_NOTICE_TEXT = {
/**
* 지난 메일의 색인을 만드는 동안 (GET /search-index 의 building). 색인은 밤에만 채우므로 그렇게 말한다 —
* 낮 내내 같은 글이면 멈춘 줄 안다 (2026-11-06 실사용 시험)
*/
indexing:
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
singleChar:
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
/** 이어 찾을 것이 남았는데 아직 하나도 찾지 못했을 때 */
partialTitle: '아직 맞는 메일을 찾지 못했습니다',
partialHint: '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.',
more: '더 찾기',
moreBusy: '찾는 중…',
/** 검색어와 조건을 합친 낱말이 한도를 넘는 주소로 들어왔을 때 — 찾지 않고 이 안내만 (조건이 있으면 조건 줄의 [조건 지우기]) */
tooManyWordsTitle: '찾을 말이 너무 많습니다',
tooManyWordsHint: (max: number) =>
`검색어와 조건에 넣은 낱말은 모두 합쳐 ${max}개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.`,
} as const
@@ -60,8 +60,8 @@ export const SEND_LATER_TEXT = {
undone: '보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.',
/** 쓰기 화면으로 가지 못했을 때(지금 화면의 나가기 확인에서 머묾 등 — 보드에 없다) — 단추는 [고치기](edit) */
undoneStayed: '보내기를 취소하고 임시보관함으로 옮겼습니다.',
/** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00 에 보냅니다." (보드 그대로 띄어 쓴다) */
scheduled: (time: string) => `${time} 에 보냅니다.`,
/** time 은 formatScheduleTime — "10월 30일 (금) 오전 9:00에 보냅니다." (조사는 붙여 쓴다 — 2026-11-06 실사용 시험) */
scheduled: (time: string) => `${time}에 보냅니다.`,
viewScheduled: '예약됨 보기',
// ── 예약됨 (메일함 패널 · 목록 · 읽기 화면) ──
@@ -74,8 +74,8 @@ export const SEND_LATER_TEXT = {
emptyHint: '메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.',
/** 표 · 목록의 보낼 시각 칸 이름 */
sendAtColumn: '보낼 시각',
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
notice: (time: string) => `${time} 에 보낼 메일입니다.`,
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00에 보낼 메일입니다." */
notice: (time: string) => `${time}에 보낼 메일입니다.`,
edit: '고치기',
cancelSchedule: '예약 취소',
canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
@@ -8,6 +8,15 @@ export const UNDO_SEND_OPTIONS: readonly UndoSendSeconds[] = [0, 5, 10, 20, 30]
/** 외부 이미지 — 고르는 차례 그대로 */
export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask', 'trusted', 'always']
/** 대화로 묶어 보기 (14단계) — 고르는 차례 그대로 (끔 · 켬 — 보내기 취소처럼 끔이 앞) */
export const GROUP_BY_CONVERSATION_OPTIONS: readonly boolean[] = [false, true]
/**
* 메일 화면이 탭으로 돌아올 때 설정을 다시 받는 간격 — 마지막으로 받은 지 이만큼 지났을 때만 (useMailSettingsRefresh).
* 다른 탭 · PC 에서 바꾼 묶기 · 보내기 취소 초 · 외부 이미지가 열어 둔 탭에 남지 않게 하되, 탭을 오갈 때마다 묻지는 않는다
*/
export const MAIL_SETTINGS_REFRESH_AFTER_MS = 60_000
export const MAIL_SETTINGS_TEXT = {
title: '보내기 · 읽기',
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
@@ -31,4 +40,15 @@ export const MAIL_SETTINGS_TEXT = {
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
/**
* 대화로 묶기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다).
* 이름은 목록 위 단추와 같다. 분할 보기의 그 단추는 아이콘(말풍선)만이라 설명에서 모양을 말한다 — 이름까지 넣으면 목록 머리에 개수 글이 들 자리가 없다
* (2026-11-06 실사용 시험: 이름까지 99px · 아이콘만 36px)
*/
groupTitle: '대화로 묶기',
groupOff: '끔',
groupOn: '켬',
groupHint:
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
} as const
+48 -6
View File
@@ -241,6 +241,22 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
/**
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)과
* 같은 대화 칸의 줄(ConversationMessageRow — unreadPrefix, 2026-11-07)이 쓴다.
* 메일 줄(MessageListItem · MessageTableRow)도 같은 글이다 — 고칠 때는 함께
*/
export const MAIL_LIST_ROW_TEXT = {
/** 안 읽은 줄의 이름 자리 앞 (화면에는 보이지 않는다) */
unreadPrefix: '안 읽음, ',
/** 별 단추 */
flag: '중요 표시',
/** 첨부 클립 */
attachment: '첨부 있음',
} as const
/** 보낸 사람이 없는 메일 — 목록 · 읽기 화면 · 같은 대화 칸의 이름 자리 */
export const MAIL_NO_SENDER = '(보낸 사람 없음)'
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 — 실시간 연결이 살아 있으면 묻지 않는다 (5단계, mail-notify.ts) */
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
@@ -268,15 +284,27 @@ export const MAIL_SEARCH_TEXT = {
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
closeTitle: (place: string) => `검색을 닫고 ${place} 돌아가기`,
title: '검색 결과',
quoted: (query: string) => `‘${query}’`,
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
searching: (query: string) => `‘${query}’ · 찾는 중`,
/** 결과 머리의 검색어 — 조건만으로 찾았으면(14단계) 검색어 없이 수만 · 전체 수를 모르면(total null) 수를 뺀다 */
quoted: (query: string) => (query ? `‘${query}’` : ''),
count: (query: string, count: string) => (query ? `‘${query}’ · ${count}통` : `${count}통`),
searching: (query: string) => (query ? `‘${query}’ · 찾는 중` : '찾는 중'),
scopeLabel: '찾을 곳',
scopeAll: '전체 메일함',
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
emptyHint:
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
/**
* 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 검색어와 조건으로 찾았으면 emptyHint, 조건만으로 찾았으면 emptyConditionsHint,
* 검색어만으로 찾았으면(걸린 조건이 없다) emptyWordsHint — 없는 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
*/
emptyHint: '다른 말로 찾거나 조건을 줄여 보세요.',
emptyConditionsHint: '조건을 바꾸거나 줄여 보세요.',
emptyWordsHint: '다른 말로 찾아 보세요.',
/**
* 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 14단계 GET /search-index 의 building)에만 결과 없음에 더하는 한계 안내.
* 바로 위의 "검색 준비 중" 줄과 같은 말(낱말 단위)을 되풀이하지 않고 찾지 못하는 예만 든다 (2026-11-06 실사용 시험)
*/
wordLimitHint:
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
searchAll: '전체 메일함에서 찾기',
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
loading: '찾는 중',
@@ -311,10 +339,21 @@ export const MAIL_BULK_TEXT = {
selectOne: (subject: string) => `고르기: ${subject}`,
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("보낸편지함으로") */
moved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다.`,
/**
* 대화로 묶은 목록에서 줄에 한 옮기기 · 삭제 · 스팸 신고 (14단계) — 줄의 "4통"은 대화 전체라 옮긴 것은 지금 메일함의 메일임을 밝힌다
* ("받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 2026-11-06 실사용 시험)
*/
movedFrom: (from: string, count: string, place: string) => `${from}에 있는 메일 ${count}통을 ${place} 옮겼습니다.`,
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
/** 대화로 묶은 휴지통에서 줄에 한 영구 삭제 (14단계 — movedFrom 과 같은 꼴, 2026-11-07) — "휴지통에 있는 메일 2통을 영구 삭제했습니다." */
purgedFrom: (from: string, count: string) => `${from}에 있는 메일 ${count}통을 영구 삭제했습니다.`,
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
/** 한 메일함으로 되돌렸을 때 — place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로"). 여러 곳이면 restored */
restoredTo: (count: string, place: string) => `메일 ${count}통을 ${place} 되돌렸습니다.`,
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
/** 대화로 묶은 휴지통에서 줄을 골랐을 때의 확인 창 제목 (purgedFrom 과 같은 꼴) — "휴지통에 있는 메일 2통을 영구 삭제할까요?" */
purgeTitleFrom: (from: string, count: string) => `${from}에 있는 메일 ${count}통을 영구 삭제할까요?`,
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
purgeConfirm: '영구 삭제',
purgeCancel: '취소',
@@ -328,6 +367,9 @@ export const MAIL_SPAM_TEXT = {
notSpam: '스팸 아님',
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
/** 대화로 묶은 목록에서 줄에 한 스팸 아님 (14단계 — MAIL_BULK_TEXT.movedFrom 과 같은 꼴) */
notSpamMovedFrom: (from: string, count: string, place: string) =>
`${from}에 있는 메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
} as const
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
+129 -4
View File
@@ -3,7 +3,7 @@
import type { ContactEmailLabel } from '@/shared/types/contacts'
import type { DraftLargeFile, MessageLargeFile } from '@/shared/types/large-file'
import type { CursorPaginationQuery } from '@/shared/types/pagination'
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
export interface Address {
name: string | null
@@ -57,6 +57,33 @@ export interface MessageSummary {
pendingSend?: PendingSend | null
}
/** 검색에서 맞은 곳 — [시작, 끝) UTF-16 위치 (끝은 들지 않는다 — 계약 2절 "검색 — 14단계") */
export type SearchRange = readonly [start: number, end: number]
/**
* 목록 항목의 검색 강조 (14단계) — 색인으로 찾은 검색 결과에만 온다. 강조가 있으면 subject 는 NFC 로 맞춘 글이고 위치는 그 글 기준이다.
* preview.text 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 미리 보기다
*/
export interface MessageListHighlight {
subject: SearchRange[]
preview: { text: string; ranges: SearchRange[] }
}
/** GET /messages 의 항목 — 요약 + 검색 강조. 메일함 목록 · 엔진 방식 검색은 null, 필드가 없으면(14단계 앞 서버) 없는 것으로 본다 */
export interface MessageListItem extends MessageSummary {
searchHighlight?: MessageListHighlight | null
}
/**
* 읽기 화면의 검색 강조 (14단계 — 검색 쿼리를 함께 보냈을 때만). 강조가 있으면 subject · 첨부 이름 · textBody 는 NFC 로 맞춘 글이고
* htmlBody 는 맞은 글 조각에 속성 없는 <mark> 가 들어 있다. attachments 는 맞은 첨부만 (첨부 id → 맞은 곳)
*/
export interface MessageDetailHighlight {
subject: SearchRange[]
attachments: Record<string, SearchRange[]>
textBody: SearchRange[]
}
export interface Attachment {
id: string
name: string
@@ -108,6 +135,11 @@ export interface MessageDetail extends MessageSummary {
* 그때 htmlBody 의 덩이 바깥 요소에 data-ara-large-files 가 붙는다(읽기 화면이 접는다). 필드가 없으면(11단계 앞 서버) 없는 것으로 본다
*/
largeFiles?: MessageLargeFile[]
/**
* 검색 강조 (14단계) — 검색 결과에서 연 메일을 검색 쿼리(q · from · to · subject · body)와 함께 받았을 때만, 없으면 null.
* ⚠️ 강조가 든 응답은 읽기 화면에 보이기만 한다 — 답장 · 전달의 원문 인용 · 인쇄는 검색 쿼리 없이 다시 받는다 (계약 2절 "검색 — 14단계")
*/
searchHighlight?: MessageDetailHighlight | null
}
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
@@ -125,13 +157,101 @@ export interface MailQuota {
}
/**
* GET /messages 쿼리 — mailboxId 와 q 중 하나는 있어야 한다 (계약 2절 "검색").
* q 만 있으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
* GET /messages 쿼리 — mailboxId 와 검색(q 나 조건 하나) 가운데 하나는 있어야 한다 (계약 2절 "검색" · "검색 — 14단계").
* mailboxId 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. q 와 조건 넷(from · to · subject · body)을 합친 낱말은 8개까지
*/
export interface MessageListQuery extends CursorPaginationQuery {
mailboxId?: string
/** 검색어 — 앞뒤 공백을 뺀 1~200자, 낱말 8개까지 */
q?: string
/** 보낸 사람(이름 · 주소) — 검사는 q 와 같다 */
from?: string
/** 받는 사람과 참조 */
to?: string
subject?: string
/** 본문 */
body?: string
/** 받은 시각이 이 시각 이상 — ISO 8601 UTC (화면은 시작 날짜의 0시를 보낸다) */
after?: string
/** 받은 시각이 이 시각 미만 — ISO 8601 UTC (화면은 끝 날짜의 다음 날 0시를 보낸다) */
before?: string
/** 'true' 면 첨부 파일이 있는 메일만 (다른 값은 400 VAL_001) */
hasAttachment?: 'true'
}
/**
* GET /messages/{id} 의 강조 쿼리 (14단계) — 검색 주소의 조건 가운데 이 다섯만 보낸다(after · before · hasAttachment · cursor · limit 은 400 VAL_001).
* from · to 는 서버가 받기만 하고 강조하지 않는다
*/
export interface MessageHighlightQuery {
q?: string
from?: string
to?: string
subject?: string
body?: string
}
/** 지난 메일 색인의 상태 (GET /search-index — 14단계) — building 이면 그 직원의 검색은 엔진 방식(낱말 통째 일치)이다 */
export type SearchIndexStatus = 'building' | 'ready'
export interface SearchIndexState {
status: SearchIndexStatus
/** 채운 메일 수 */
done: number
/** 채우기를 시작할 때의 메일 수 — 시작 전이면 null */
total: number | null
}
// ── 대화 (계약 2절 "대화 — 14단계") ─────────────────────────
/** 대화의 기준 — index 우리 색인(답장 관계 + 말머리를 뗀 제목이 같음) · engine 엔진의 스레드(그 직원의 색인을 채우는 동안) */
export type ConversationBasis = 'index' | 'engine'
/**
* GET /messages/{id}/conversation 응답 — 그 메일의 같은 대화, 오래된 차례(한 통뿐이면 그 메일 하나).
* hasNext 인데 항목이 limit 보다 적거나 0개일 수 있다(engine — 한 요청이 스레드를 1,000통까지만 훑는다).
* total 은 index 면 첫 쪽에서만, engine 이면 쪽마다(1,000통을 넘으면 null). 모양이 틀린 응답은 빈 대화 · basis null 로 맞춘다 (useMail().getConversation)
*/
export interface ConversationPage extends PaginatedResult<MessageSummary> {
basis: ConversationBasis | null
}
/**
* GET /conversations 의 한 줄 — 그 메일함 안의 대화 하나 (14단계 — 계약 2절 "대화로 묶어 보기").
* 고르기 · 읽음 · 중요 · 이동 · 삭제는 messageIds 에서 pendingSendIds 를 뺀 메일에 한다 (붙잡힌 메일은 취소로만 보낸편지함을 떠난다)
*/
export interface ConversationSummary {
/** 대화 id — 불투명(기준마다 다르다). 한 목록 안에서 줄을 가리는 데만 쓴다 */
id: string
/** 그 메일함 안의 마지막 메일 — 줄의 제목 · 시각 · 붙잡은 발송, 누르면 연다 */
latest: MessageSummary
/** 대화 전체의 보낸 사람 — 받은 차례, 주소마다 한 번, 10명까지 (화면은 내 주소를 "나"로) */
participants: Address[]
/** 대화 전체의 메일 수 — 보낸편지함 포함, 휴지통 · 스팸메일함에만 든 메일은 뺀다. 엔진 방식에서 상한에 걸린 줄은 어림이다 */
messageCount: number
/** 그 메일함 안의 그 대화 메일 — 최신부터 500통까지 (useMail 이 마지막 메일을 늘 넣어 둔다) */
messageIds: string[]
/** messageIds 가운데 붙잡힌 메일(보내기 취소 · 예약) — 없으면 빈 배열 */
pendingSendIds: string[]
/** 그 메일함 안의 메일을 모두 읽었다 */
isRead: boolean
/** 그 메일함 안에 중요 표시한 메일이 하나라도 있다 */
isFlagged: boolean
/** 그 메일함 안에 첨부가 있는 메일이 하나라도 있다 */
hasAttachment: boolean
}
/** GET /conversations 쿼리 — 검색 쿼리(q · 조건)는 받지 않는다 */
export interface ConversationListQuery extends CursorPaginationQuery {
mailboxId: string
}
/**
* GET /conversations 응답 — 그 메일함 안 마지막 받은 시각 최신부터. total 은 index 면 첫 쪽에서만(그 메일함의 대화 수), engine 이면 쪽마다.
* 모양이 틀린 줄은 뺀다 (useMail().listConversations)
*/
export interface ConversationListPage extends PaginatedResult<ConversationSummary> {
basis: ConversationBasis | null
}
/** PATCH /messages/{id} 본문 — 하나 이상 지정한다 */
@@ -252,10 +372,15 @@ export type UndoSendSeconds = 0 | 5 | 10 | 20 | 30
/** 외부 이미지 — 늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 */
export type ExternalImagesSetting = 'ask' | 'trusted' | 'always'
/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 */
/** GET · PATCH /mail-settings — 직원마다 서버 DB 에 하나(어느 PC 에서나 같다). 처음 값은 10초 · 늘 묻기 · 묶지 않음 */
export interface MailSettings {
undoSendSeconds: UndoSendSeconds
externalImages: ExternalImagesSetting
/**
* 메일함 목록을 대화로 묶어 보기 (14단계 — 계약 2절 "대화 — 14단계"). 서버는 저장해 돌려주기만 하고 묶은 목록을 부를지는 화면이 정한다.
* 필드가 없는 서버(14단계 앞)면 false 로 본다 (useMailSettings 가 맞춘다)
*/
groupByConversation: boolean
}
// ── 보낸 사람 차단 (계약 2절 "보낸 사람 차단" — 9단계) ─────────────
+22
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
formatBlockedDay,
formatConversationTime,
formatDraftSavedTime,
formatPrintDateTime,
formatScheduleShortTime,
@@ -215,3 +216,24 @@ describe('formatRelativeDayTime (11단계 — 설정 › 대용량 첨부의 날
expect(formatRelativeDayTime('나중에', now, SEOUL)).toBe('')
})
})
describe('formatConversationTime (14단계 — 같은 대화 칸의 시각, 디자인 원본 Thread14)', () => {
/** 2026-11-05(목) 오후 3시 (서울) */
const now = new Date('2026-11-05T06:00:00Z')
it('오늘 · 어제는 그 말과 시각 — "오늘 오전 10:42"', () => {
expect(formatConversationTime('2026-11-05T01:42:00Z', now, SEOUL)).toBe('오늘 오전 10:42')
expect(formatConversationTime('2026-11-04T04:05:00Z', now, SEOUL)).toBe('어제 오후 1:05')
})
it('그 밖의 올해는 요일까지 "10월 29일 (목) 오전 9:30", 다른 해는 연도까지 (읽기 화면 머리와 같다)', () => {
expect(formatConversationTime('2026-10-29T00:30:00Z', now, SEOUL)).toBe('10월 29일 (목) 오전 9:30')
expect(formatConversationTime('2025-10-29T00:30:00Z', now, SEOUL)).toBe('2025. 10. 29. (수) 오전 9:30')
})
it('날은 서울 날짜로 가른다 — UTC 로는 어제여도 서울로 오늘이면 "오늘"', () => {
// 2026-11-04T23:30Z = 서울 11월 5일 오전 8:30
expect(formatConversationTime('2026-11-04T23:30:00Z', now, SEOUL)).toBe('오늘 오전 8:30')
expect(formatConversationTime('나중에', now, SEOUL)).toBe('')
})
})
+17
View File
@@ -274,6 +274,23 @@ export function formatScheduleTime(value: string | number, timeZone?: string): s
}).format(date)
}
/**
* 같은 대화 칸의 시각 (14단계 디자인 원본 Thread14: "오늘 오전 10:42" · "10월 29일 (목) 오전 9:30") — 줄마다 날과 시각을 함께 보인다
* - 오늘 · 어제: "오늘 오전 10:42" · "어제 오후 1:05"
* - 올해: "10월 29일 (목) 오전 9:30"
* - 그 이전: "2025. 10. 29. (수) 오전 9:30" (읽기 화면 머리와 같다)
*/
export function formatConversationTime(value: string, now: Date = new Date(), timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const target = calendarDayOf(date, timeZone)
const today = calendarDayOf(now, timeZone)
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
if (isSameDay(target, today)) return `오늘 ${time}`
if (isSameDay(target, previousDay(today))) return `어제 ${time}`
return target.year === today.year ? formatScheduleTime(value, timeZone) : formatMailDateTime(value, timeZone)
}
/** 보낸편지함 날짜 칸의 예약 시각 — 요일 없이 (디자인 원본 MailSendLater: "예약 · 10월 30일 오전 9:00") */
export function formatScheduleShortTime(value: string | number, timeZone?: string): string {
const date = dateOf(value)
@@ -6,6 +6,7 @@ import {
movedMessageText,
notSpamMovedText,
purgedMessageText,
purgeTitleText,
restoredMessageText,
sendInChunks,
} from './mail-bulk'
@@ -72,4 +73,41 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
'메일 2통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
})
it('대화로 묶은 목록에서 줄에 한 일 — "{메일함}에 있는 메일 N통을 …" (줄의 통 수는 대화 전체라서 — 14단계), 휴지통 · 스팸도 같은 꼴', () => {
expect(movedMessageText(2, '휴지통', 0, { from: '받은편지함' })).toBe('받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다.')
expect(movedMessageText(3, '스팸메일함', 0, { from: '프로젝트' })).toBe('프로젝트에 있는 메일 3통을 스팸메일함으로 옮겼습니다.')
expect(movedMessageText(2, '보관함', 1, { from: '받은편지함' })).toBe(
'받은편지함에 있는 메일 2통을 보관함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
expect(notSpamMovedText(1, '받은편지함', 0, { from: '스팸메일함' })).toBe(
'스팸메일함에 있는 메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.',
)
// 묶지 않은 목록(from 없음)은 지금 글 그대로
expect(movedMessageText(2, '휴지통', 0, { from: null })).toBe('메일 2통을 휴지통으로 옮겼습니다.')
})
it('대화로 묶은 휴지통의 영구 삭제 — 확인 창 · 알림이 "휴지통에 있는 메일 N통을 …" (2026-11-07), 뒷말은 그대로', () => {
expect(purgeTitleText(2, { from: '휴지통' })).toBe('휴지통에 있는 메일 2통을 영구 삭제할까요?')
expect(purgeTitleText(1_234, { from: '휴지통' })).toBe('휴지통에 있는 메일 1,234통을 영구 삭제할까요?')
expect(purgedMessageText(2, 0, { from: '휴지통' })).toBe('휴지통에 있는 메일 2통을 영구 삭제했습니다.')
expect(purgedMessageText(2, 1, { from: '휴지통' })).toBe(
'휴지통에 있는 메일 2통을 영구 삭제했습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
// 하나도 지우지 못했으면 뒷말만 (묶지 않은 목록과 같다)
expect(purgedMessageText(0, 2, { from: '휴지통' })).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
// 묶지 않은 목록 · 읽기 화면(from 없음)은 지금 글 그대로
expect(purgeTitleText(2)).toBe('메일 2통을 영구 삭제할까요?')
expect(purgeTitleText(1, { from: null })).toBe('메일 1통을 영구 삭제할까요?')
expect(purgedMessageText(2, 0, { from: null })).toBe('메일 2통을 영구 삭제했습니다.')
})
it('되돌린 알림 — 한 메일함이면 "메일 N통을 {메일함}으로 되돌렸습니다.", 여러 곳 · 모르면 지금 글 그대로', () => {
expect(restoredMessageText(2, 0, '받은편지함')).toBe('메일 2통을 받은편지함으로 되돌렸습니다.')
expect(restoredMessageText(1, 0, '프로젝트')).toBe('메일 1통을 프로젝트로 되돌렸습니다.')
expect(restoredMessageText(2, 1, '받은편지함')).toBe(
'메일 2통을 받은편지함으로 되돌렸습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
expect(restoredMessageText(3, 0, null)).toBe('메일 3통을 되돌렸습니다.')
})
})
+52 -12
View File
@@ -75,22 +75,62 @@ function withSkipped(text: string, done: number, skipped: number): string {
return done > 0 ? `${text} ${suffix}` : suffix
}
/** "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' */
export function movedMessageText(done: number, mailboxName: string, skipped = 0): string {
const place = `${mailboxName}${directionParticle(mailboxName)}`
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
/** "받은편지함으로" — 메일함 이름의 받침에 맞춰 '으로/로' */
function placeOf(mailboxName: string): string {
return `${mailboxName}${directionParticle(mailboxName)}`
}
/** 스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0): string {
const place = `${mailboxName}${directionParticle(mailboxName)}`
return withSkipped(MAIL_SPAM_TEXT.notSpamMoved(formatCount(done), place), done, skipped)
/**
* 옮기기 · 영구 삭제의 알림 · 확인 글의 꾸밈 — from 이 있으면 "{from}에 있는 메일 …"
* (대화로 묶은 목록에서 줄에 한 일 — 14단계. 줄의 통 수는 대화 전체라 그 메일함의 메일임을 밝힌다)
*/
export interface BulkTextOptions {
from?: string | null
}
export function purgedMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
/**
* "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로'.
* 대화로 묶은 목록에서 줄에 한 일이면 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." (줄의 통 수는 대화 전체라서)
*/
export function movedMessageText(done: number, mailboxName: string, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from
? MAIL_BULK_TEXT.movedFrom(options.from, count, placeOf(mailboxName))
: MAIL_BULK_TEXT.moved(count, placeOf(mailboxName))
return withSkipped(text, done, skipped)
}
export function restoredMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.restored(formatCount(done)), done, skipped)
/** 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from
? MAIL_SPAM_TEXT.notSpamMovedFrom(options.from, count, placeOf(mailboxName))
: MAIL_SPAM_TEXT.notSpamMoved(count, placeOf(mailboxName))
return withSkipped(text, done, skipped)
}
/**
* 영구 삭제 확인 창의 제목 — "메일 2통을 영구 삭제할까요?".
* 대화로 묶은 휴지통에서 줄을 골랐으면 "휴지통에 있는 메일 2통을 영구 삭제할까요?" (줄의 통 수는 대화 전체이고 지우는 것은 휴지통의 메일뿐이라서)
*/
export function purgeTitleText(count: number, options: BulkTextOptions = {}): string {
const countText = formatCount(count)
return options.from ? MAIL_BULK_TEXT.purgeTitleFrom(options.from, countText) : MAIL_BULK_TEXT.purgeTitle(countText)
}
/** "메일 2통을 영구 삭제했습니다." — 대화로 묶은 휴지통에서 줄에 한 일이면 "휴지통에 있는 메일 2통을 영구 삭제했습니다." */
export function purgedMessageText(done: number, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from ? MAIL_BULK_TEXT.purgedFrom(options.from, count) : MAIL_BULK_TEXT.purged(count)
return withSkipped(text, done, skipped)
}
/**
* 되돌리기 — 한 메일함으로 되돌렸으면 "메일 2통을 받은편지함으로 되돌렸습니다.", 여러 곳(또는 메일함 이름을 모름)이면 "메일 N통을 되돌렸습니다."
* (2026-11-06 실사용 시험 — 되돌리기 알림 두 장이 같은 글로 쌓였다)
*/
export function restoredMessageText(done: number, skipped = 0, mailboxName: string | null = null): string {
const count = formatCount(done)
const text = mailboxName ? MAIL_BULK_TEXT.restoredTo(count, placeOf(mailboxName)) : MAIL_BULK_TEXT.restored(count)
return withSkipped(text, done, skipped)
}
@@ -0,0 +1,336 @@
import { describe, expect, it } from 'vitest'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import {
appendConversationPage,
conversationActionIds,
conversationActionIdsOf,
conversationCountText,
conversationListPageOf,
conversationNeighborAfterRemoval,
conversationParticipantNames,
conversationRowIndexOf,
conversationWho,
isConversationHeld,
listHeadCountText,
listHeadShortCountText,
mergeRefreshedConversations,
withConversationFlags,
withConversationFlagsOf,
withoutConversationMessages,
} from './mail-conversation-list'
// 대화로 묶어 보기 — 묶은 목록의 줄 (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 원본 ListThreads14)
const ME = 'ara.kim@arasystem.test'
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: DOYUN,
to: [{ name: '김아라', email: ME }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-05T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? `${id}-latest`),
participants: [DOYUN],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
const ids = (rows: readonly { id: string }[]) => rows.map((row) => row.id)
const INFO = (partial: Partial<PageInfo> = {}): PageInfo => ({ hasNext: false, nextCursor: null, total: null, ...partial })
describe('conversationListPageOf — 응답 맞추기', () => {
it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => {
const row = conversation('c1', ['m2', 'm1'], { participants: [DOYUN, { name: null, email: ME }] })
const page = conversationListPageOf({
items: [row],
pageInfo: { hasNext: true, nextCursor: 'next', total: 6 },
basis: 'engine',
})
expect(page.items).toEqual([row])
expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'next', total: 6 })
expect(page.basis).toBe('engine')
})
it('목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다 (id 없음 · 마지막 메일이 요약 모양이 아님)', () => {
expect(conversationListPageOf(null).items).toEqual([])
expect(conversationListPageOf({ items: 'x', pageInfo: {} }).items).toEqual([])
const page = conversationListPageOf({
items: [conversation('ok', ['m1']), { id: '', latest: summary('m2') }, { id: 'c3', latest: { id: 'm3' } }, 'c4'],
pageInfo: { hasNext: false, nextCursor: null, total: 4 },
basis: 'index',
})
expect(ids(page.items)).toEqual(['ok'])
})
it('마지막 메일은 늘 messageIds 에 들고, 틀린 칸은 맞춘다 — 통 수가 정수가 아니면 messageIds 의 수, 불리언이 아니면 마지막 메일의 값', () => {
const [row] = conversationListPageOf({
items: [
{
id: 'c1',
latest: summary('m3', { isRead: false, isFlagged: true, hasAttachment: true }),
participants: [DOYUN, { email: 3 }, { name: undefined, email: ME }],
messageCount: 2.5,
messageIds: ['m1', 7, ''],
pendingSendIds: 'x',
},
],
pageInfo: { hasNext: true, nextCursor: '', total: -1 },
basis: 'thread',
}).items
expect(row?.messageIds).toEqual(['m3', 'm1'])
expect(row?.messageCount).toBe(2)
expect(row?.pendingSendIds).toEqual([])
expect(row?.participants).toEqual([DOYUN, { name: null, email: ME }])
expect([row?.isRead, row?.isFlagged, row?.hasAttachment]).toEqual([false, true, true])
})
it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 total 은 모르는 것, 모르는 basis 는 null', () => {
const page = conversationListPageOf({ items: [], pageInfo: { hasNext: true, nextCursor: '', total: -1 }, basis: 'thread' })
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null })
expect(page.basis).toBeNull()
})
})
describe('줄에 하는 일 — 붙잡힌 메일은 뺀다', () => {
it('pendingSendIds 와 마지막 메일의 pendingSend 를 뺀 메일이 할 일이다', () => {
const row = conversation('c1', ['m3', 'm2', 'm1'], {
latest: summary('m3', { pendingSend: { sendAt: '2026-11-06T01:00:00Z', kind: 'undo' } }),
pendingSendIds: ['m2'],
})
expect(conversationActionIds(row)).toEqual(['m1'])
expect(isConversationHeld(row)).toBe(false)
})
it('줄의 메일이 모두 붙잡힌 메일이면 고를 수 없다', () => {
const row = conversation('c1', ['m2', 'm1'], { pendingSendIds: ['m2', 'm1'] })
expect(conversationActionIds(row)).toEqual([])
expect(isConversationHeld(row)).toBe(true)
})
it('여러 줄의 할 일은 겹치지 않게 모은다 (같은 id 를 두 번 보내면 서버가 거절한다)', () => {
const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m2', 'm1'], { pendingSendIds: ['m1'] })]
expect(conversationActionIdsOf(rows)).toEqual(['m3', 'm2'])
})
it('메일의 줄 — 마지막 메일이거나 줄에 든 메일, 없으면 -1', () => {
const rows = [conversation('c1', ['m3', 'm2']), conversation('c2', ['m1'])]
expect(conversationRowIndexOf(rows, 'm2')).toBe(0)
expect(conversationRowIndexOf(rows, 'm1')).toBe(1)
expect(conversationRowIndexOf(rows, 'zz')).toBe(-1)
})
})
describe('줄의 글', () => {
it('참여자 — 다른 사람은 받은 차례, 내 주소는 "나"로 끝에, 같은 이름은 한 번 (디자인 원본 ListThreads14 "인사팀, 김민수, 나")', () => {
const row = conversation('c1', ['m1'], {
// 내 주소는 대소문자 · 앞뒤 공백을 가리지 않는다
participants: [HR, { name: '김아라', email: ' Ara.Kim@Arasystem.TEST ' }, MINSU, { ...MINSU, email: 'm2@x.test' }],
})
expect(conversationParticipantNames(row, ME)).toEqual(['인사팀', '김민수', '나'])
})
it('이름 자리 — 받은편지함은 참여자를 쉼표로, 이름이 없으면 주소', () => {
const row = conversation('c1', ['m1'], { participants: [{ name: ME, email: ME }, { name: null, email: 'x@ext.test' }] })
expect(conversationWho(row, { myEmail: ME, role: 'inbox', view: 'split' })).toEqual({ text: 'x@ext.test, 나', placeholder: false })
})
it('보낸편지함 · 임시보관함에서 참여자가 나뿐이면 마지막 메일의 받는 사람 ("받는 사람: …")', () => {
const sentRow = conversation('c1', ['m1'], {
participants: [{ name: '김아라', email: ME }],
latest: summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME }, to: [DOYUN, MINSU] }),
})
expect(conversationWho(sentRow, { myEmail: ME, role: 'sent', view: 'table' }).text).toBe('받는 사람: 이도윤 외 1명')
const draftRow = conversation('c2', ['d1'], { participants: [], latest: summary('d1', { to: [] }) })
expect(conversationWho(draftRow, { myEmail: ME, role: 'drafts', view: 'split' })).toEqual({
text: '(받는 사람 없음)',
placeholder: true,
})
// 답장이 와서 다른 사람이 있으면 참여자 그대로
const replied = conversation('c3', ['m2', 'm1'], { participants: [{ name: '김아라', email: ME }, DOYUN] })
expect(conversationWho(replied, { myEmail: ME, role: 'sent', view: 'split' }).text).toBe('이도윤, 나')
})
it('받은편지함에서 참여자가 없으면 마지막 메일의 보낸 사람, 나뿐이면 "나"', () => {
expect(conversationWho(conversation('c1', ['m1'], { participants: [] }), { myEmail: ME, role: 'inbox', view: 'split' }).text).toBe(
'이도윤',
)
expect(
conversationWho(conversation('c2', ['m1'], { participants: [{ name: '김아라', email: ME }] }), {
myEmail: ME,
role: null,
view: 'split',
}).text,
).toBe('나')
})
it('통 수 — 한 통이면 없고, 여러 통이면 "4통" (천 단위 쉼표)', () => {
expect(conversationCountText(conversation('c1', ['m1']))).toBeNull()
expect(conversationCountText(conversation('c2', ['m1'], { messageCount: 4 }))).toBe('4통')
expect(conversationCountText(conversation('c3', ['m1'], { messageCount: 1234 }))).toBe('1,234통')
})
it('목록 머리 글 — 켰을 때 "대화 6개, 안 읽은 메일 2통", 껐을 때 "메일 9통, 안 읽은 메일 2통" (모르는 수 · 0 은 뺀다)', () => {
expect(listHeadCountText({ grouped: true, total: 6, unread: 2 })).toBe('대화 6개, 안 읽은 메일 2통')
expect(listHeadCountText({ grouped: false, total: 9, unread: 2 })).toBe('메일 9통, 안 읽은 메일 2통')
expect(listHeadCountText({ grouped: true, total: null, unread: 2 })).toBe('안 읽은 메일 2통')
expect(listHeadCountText({ grouped: true, total: 1500, unread: 0 })).toBe('대화 1,500개')
expect(listHeadCountText({ grouped: true, total: null, unread: 0 })).toBe('')
})
it('좁은 목록 머리(분할 보기) — 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통" · "대화 6개" (2026-11-06 실사용 시험)', () => {
expect(listHeadShortCountText({ grouped: true, total: 6, unread: 2 })).toBe('안 읽은 메일 2통')
expect(listHeadShortCountText({ grouped: false, total: 9, unread: 1_234 })).toBe('안 읽은 메일 1,234통')
expect(listHeadShortCountText({ grouped: false, total: 9, unread: 0 })).toBe('메일 9통')
expect(listHeadShortCountText({ grouped: true, total: 15, unread: 0 })).toBe('대화 15개')
expect(listHeadShortCountText({ grouped: true, total: null, unread: 0 })).toBe('')
})
})
describe('이 탭이 바꾼 표시', () => {
it('줄의 메일을 모두 바꾸면 줄 전체가 읽음 · 중요 해제 — 마지막 메일의 표시도', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
const read = withConversationFlags(row, new Set(['m2', 'm1']), { isRead: true, isFlagged: false })
expect([read.isRead, read.isFlagged]).toEqual([true, false])
expect([read.latest.isRead, read.latest.isFlagged]).toEqual([true, false])
})
it('일부만 바꾸면 읽음 · 중요 해제는 모른다(그대로) — 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, isFlagged: true })
const partial = withConversationFlags(row, new Set(['m2']), { isRead: true, isFlagged: false })
expect([partial.isRead, partial.isFlagged]).toEqual([false, true])
expect(partial.latest.isRead).toBe(true)
const readRow = conversation('c2', ['m4', 'm3'])
const unread = withConversationFlags(readRow, new Set(['m3']), { isRead: false, isFlagged: true })
expect([unread.isRead, unread.isFlagged]).toEqual([false, true])
// 마지막 메일이 아닌 메일은 마지막 메일의 표시를 바꾸지 않는다
expect(unread.latest).toBe(readRow.latest)
})
it('읽음은 붙잡힌 메일을 빼고 센다 — 나머지를 모두 바꿨으면 줄 전체다. 든 메일이 없으면 같은 줄', () => {
const row = conversation('c1', ['m2', 'm1'], { isRead: false, pendingSendIds: ['m2'] })
expect(withConversationFlags(row, new Set(['m1']), { isRead: true }).isRead).toBe(true)
expect(withConversationFlags(row, new Set(['zz']), { isRead: true })).toBe(row)
})
it('중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만 줄 전체다 — 줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 (리뷰 L1)', () => {
const row = conversation('c1', ['m2', 'm1'], { isFlagged: true, pendingSendIds: ['m2'] })
expect(withConversationFlags(row, new Set(['m1']), { isFlagged: false }).isFlagged).toBe(true)
expect(withConversationFlags(row, new Set(['m2', 'm1']), { isFlagged: false }).isFlagged).toBe(false)
})
it('되돌리기 — 줄과(마지막 메일이 그대로면) 마지막 메일의 표시를 앞 값으로', () => {
const original = conversation('c1', ['m2', 'm1'], { isRead: false, latest: summary('m2', { isRead: false }) })
const changed = withConversationFlags(original, new Set(['m2', 'm1']), { isRead: true })
expect([changed.isRead, changed.latest.isRead]).toEqual([true, true])
const restored = withConversationFlagsOf(changed, original)
expect([restored.isRead, restored.latest.isRead]).toEqual([false, false])
const newLatest = { ...changed, latest: summary('m9', { isRead: true }) }
expect(withConversationFlagsOf(newLatest, original).latest.isRead).toBe(true)
})
})
describe('뺀 메일 · 이어서 열 메일', () => {
it('메일이 모두 빠진 줄은 빼고 그 수를 센다 — 남은 줄은 메일만 줄인다', () => {
const rows = [conversation('c1', ['m3', 'm2'], { pendingSendIds: ['m2'] }), conversation('c2', ['m1']), conversation('c3', ['m0'])]
const result = withoutConversationMessages(rows, new Set(['m3', 'm1']))
expect(ids(result.rows)).toEqual(['c1', 'c3'])
expect(result.rows[0]?.messageIds).toEqual(['m2'])
expect(result.rows[0]?.pendingSendIds).toEqual(['m2'])
expect(result.removedRows).toBe(1)
expect(result.rows[1]).toBe(rows[2])
})
it('이어서 열 메일 — 메일 목록과 같은 결: 그 줄의 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄의 마지막 메일 (리뷰 L3)', () => {
const rows = [conversation('c1', ['m6']), conversation('c2', ['m5', 'm4', 'm3']), conversation('c3', ['m2'])]
// 가운데 메일([이 메일만 열기]로 연 더 오래된 메일) — 다음 칸
expect(conversationNeighborAfterRemoval(rows, 'm4')).toBe('m3')
expect(conversationNeighborAfterRemoval(rows, 'm5')).toBe('m4')
// 줄의 끝 칸 — 앞 칸
expect(conversationNeighborAfterRemoval(rows, 'm3')).toBe('m4')
// 줄에 그 메일뿐 — 다음 줄, 마지막 줄이면 앞 줄의 마지막 메일
expect(conversationNeighborAfterRemoval(rows, 'm6')).toBe('m5')
expect(conversationNeighborAfterRemoval(rows, 'm2')).toBe('m5')
expect(conversationNeighborAfterRemoval([conversation('c1', ['m1'])], 'm1')).toBeNull()
expect(conversationNeighborAfterRemoval(rows, 'zz')).toBeNull()
})
it('이어서 열 메일 — 다음 맞추기 전의 옛 마지막 메일(이미 뺀 메일)이면 남은 메일의 첫 칸', () => {
const stale = conversation('c1', ['m3', 'm2'], { latest: summary('m9') })
expect(conversationNeighborAfterRemoval([stale, conversation('c2', ['m1'])], 'm9')).toBe('m3')
})
})
describe('쪽 붙이기 · 다시 받아 맞추기', () => {
it('붙일 때 이미 있는 줄은 건너뛰고, 다음 쪽의 total 이 없으면 아는 수를 남긴다', () => {
const merged = appendConversationPage([conversation('c1', ['m3'])], INFO({ total: 6 }), {
items: [conversation('c1', ['m3']), conversation('c2', ['m2'])],
pageInfo: INFO({ total: null }),
basis: 'index',
})
expect(ids(merged.rows)).toEqual(['c1', 'c2'])
expect(merged.pageInfo.total).toBe(6)
})
it('다시 받은 범위 밖의 옛 줄은 남기고 옛 커서를 쓴다 — 메일이 겹치는 옛 줄은 같은 대화로 본다(합쳐진 대화)', () => {
const current = [conversation('a', ['m9']), conversation('b', ['m8']), conversation('c', ['m7', 'm6']), conversation('d', ['m5'])]
const fetched = [conversation('x', ['m10']), conversation('a', ['m9']), conversation('b2', ['m8', 'm7'])]
const result = mergeRefreshedConversations({
current,
currentPageInfo: INFO({ hasNext: true, nextCursor: 'old', total: 10 }),
fetched,
fetchedPageInfo: INFO({ hasNext: true, nextCursor: 'new', total: 11 }),
})
// b 는 b2 와 메일이 겹치고(m8) c 는 m7 이 겹쳐 같은 대화로 본다 — 남는 옛 줄은 d
expect(ids(result.rows)).toEqual(['x', 'a', 'b2', 'd'])
expect(result.pageInfo).toEqual({ hasNext: true, nextCursor: 'old', total: 11 })
})
it('"새 메일 N통" — 맨 앞부터 새 줄은 한 통, 새 메일이 든 줄은 새 메일 수를 이어 세고, 바뀌지 않은 줄에서 멈춘다', () => {
const current = [conversation('a', ['m3', 'm2']), conversation('b', ['m1'])]
const fetched = [
conversation('new', ['m9', 'm0']),
conversation('b', ['m8', 'm7', 'm1']),
conversation('a', ['m3', 'm2']),
conversation('late', ['m6']),
]
const result = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched, fetchedPageInfo: INFO() })
expect(result.addedAbove).toBe(3)
const unchanged = mergeRefreshedConversations({ current, currentPageInfo: INFO(), fetched: current, fetchedPageInfo: INFO() })
expect(unchanged.addedAbove).toBe(0)
})
})
@@ -0,0 +1,347 @@
// 대화로 묶어 보기 — 묶은 목록(GET /conversations)의 줄을 다루는 순수 함수만 둔다
// (14단계 — 계약 2절 "대화로 묶어 보기" · 디자인 README 「14단계 — 검색 · 대화 묶기」 › "목록 — 대화로 묶어 보기 (ListThreads14)")
// - 응답 맞추기: 모양이 틀린 줄은 뺀다. 마지막 메일(latest)은 늘 messageIds 에 넣어 둔다 — 줄에 든 메일인지 messageIds 만 보면 되게
// - 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)은 messageIds 에서 붙잡힌 메일(pendingSendIds · 마지막 메일의 pendingSend)을 뺀 메일에 한다 —
// 붙잡힌 메일은 취소로만 보낸편지함을 떠난다. 남는 메일이 없으면 그 줄은 고를 수 없다
// - 참여자: 다른 사람은 받은 차례로, 내 주소는 "나"로 끝에 (디자인 원본 ListThreads14 — "이도윤, 나" · "인사팀, 김민수, 나").
// 보낸편지함 · 임시보관함에서 참여자가 나뿐이면 메일 목록처럼 "받는 사람: …" 이다 (나만 늘어선 줄이 되지 않게 — 보드에 없어 정했다)
// - 이 탭이 바꾼 표시(읽음 · 중요): 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제가 된다 — 일부만이면 나머지를 몰라 그대로 두고
// 다음 맞추기(새 메일 확인 · 실시간 소식)가 서버 값으로 고친다. 안 읽음 · 중요는 한 통만 바꿔도 줄이 그렇다.
// 읽음은 붙잡힌 메일(내가 보내는 메일 — 읽은 메일)을 빼고 세지만, 중요 해제는 붙잡힌 메일의 표시를 몰라 그 메일까지 바꿨을 때만이다
// (줄의 중요 표시가 붙잡힌 메일에만 있을 수 있다 — 14단계 리뷰 L1)
// - 뺀 메일: 줄의 메일이 모두 빠지면 줄도 뺀다. 남으면 줄은 그대로다(마지막 메일은 다음 맞추기까지 옛 것 — 부르는 쪽이 곧 다시 맞춘다).
// 뺀 뒤 열 메일은 메일 목록과 같은 결 — 그 줄의 messageIds 에서 다음 칸(더 오래된 메일), 없으면 앞 칸, 줄이 비면 다음 줄 · 앞 줄
// - 다시 받아 맞추기: 메일 목록(mail-list-merge.ts)과 같은 규칙 — 다시 받은 범위 밖의 옛 줄은 남긴다. 메일이 겹치는 옛 줄은 같은 대화로 본다
// (늦게 온 원본으로 두 대화가 합쳐지면 id 가 바뀐다 — 계약의 알려진 한계)
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_LIST_TEXT, MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type {
Address,
ConversationBasis,
ConversationListPage,
ConversationSummary,
MailboxRole,
MessageSummary,
} from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import { formatCount } from '@/shared/utils/format'
import { isMyAddress, isSummaryLike, participantName } from '@/shared/utils/mail-conversation'
import { rowWho, type RowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
/** 이 탭이 줄의 메일에 바로 적는 표시 — 메일 목록(list.ts)의 LocalChanges 와 같은 칸 */
export type ConversationFlagChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
// ── 응답 맞추기 ─────────────────────────────────────────────
function isAddressLike(value: unknown): value is Address {
if (typeof value !== 'object' || value === null) return false
const candidate = value as { name?: unknown; email?: unknown }
return (
typeof candidate.email === 'string' &&
(candidate.name === null || candidate.name === undefined || typeof candidate.name === 'string')
)
}
function idsOf(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === 'string' && id !== '')
}
function booleanOr(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
/** 줄 하나 — 줄이 읽는 칸의 모양이 틀리면 null (그 줄만 뺀다) */
function conversationOf(value: unknown): ConversationSummary | null {
if (typeof value !== 'object' || value === null) return null
const candidate = value as Partial<Record<keyof ConversationSummary, unknown>>
if (typeof candidate.id !== 'string' || candidate.id === '' || !isSummaryLike(candidate.latest)) return null
const latest = candidate.latest
const listed = idsOf(candidate.messageIds)
const messageIds = listed.includes(latest.id) ? listed : [latest.id, ...listed]
const count = candidate.messageCount
return {
id: candidate.id,
latest,
participants: Array.isArray(candidate.participants)
? candidate.participants.filter(isAddressLike).map((address) => ({ name: address.name ?? null, email: address.email }))
: [],
messageCount: typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : messageIds.length,
messageIds,
pendingSendIds: idsOf(candidate.pendingSendIds),
isRead: booleanOr(candidate.isRead, latest.isRead),
isFlagged: booleanOr(candidate.isFlagged, latest.isFlagged),
hasAttachment: booleanOr(candidate.hasAttachment, latest.hasAttachment),
}
}
/**
* GET /conversations 의 응답 → ConversationListPage. 목록 모양이 틀리면 빈 목록, 줄 모양이 틀리면 그 줄만 뺀다.
* 커서가 없는 hasNext 는 다음 쪽이 없는 것으로, 음수 · 정수가 아닌 total 은 모르는 것(null)으로 본다 (같은 대화의 conversationPageOf 와 같다)
*/
export function conversationListPageOf(value: unknown): ConversationListPage {
const empty: ConversationListPage = { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
if (typeof value !== 'object' || value === null) return empty
const candidate = value as { items?: unknown; pageInfo?: unknown; basis?: unknown }
if (!Array.isArray(candidate.items) || typeof candidate.pageInfo !== 'object' || candidate.pageInfo === null) return empty
const info = candidate.pageInfo as { hasNext?: unknown; nextCursor?: unknown; total?: unknown }
const nextCursor = typeof info.nextCursor === 'string' && info.nextCursor !== '' ? info.nextCursor : null
const total = typeof info.total === 'number' && Number.isInteger(info.total) && info.total >= 0 ? info.total : null
const basis =
typeof candidate.basis === 'string' && CONVERSATION_BASES.includes(candidate.basis)
? (candidate.basis as ConversationBasis)
: null
return {
items: candidate.items.map(conversationOf).filter((row): row is ConversationSummary => row !== null),
pageInfo: { hasNext: info.hasNext === true && nextCursor !== null, nextCursor, total },
basis,
}
}
// ── 줄에 하는 일 ─────────────────────────────────────────────
/** 줄의 붙잡힌 메일 — pendingSendIds 와 마지막 메일의 pendingSend */
function heldIdsOf(row: ConversationSummary): Set<string> {
const held = new Set(row.pendingSendIds)
if (isHeldMessage(row.latest)) held.add(row.latest.id)
return held
}
/** 줄에 하는 일(고르기 · 읽음 · 중요 · 이동 · 삭제)을 보낼 메일 — 붙잡힌 메일은 뺀다 (최신부터) */
export function conversationActionIds(row: ConversationSummary): string[] {
const held = heldIdsOf(row)
return row.messageIds.filter((id) => !held.has(id))
}
/** 줄의 메일이 모두 붙잡힌 메일이다 — 고를 수 없고 별(중요 표시)도 바꿀 수 없다 */
export function isConversationHeld(row: ConversationSummary): boolean {
return conversationActionIds(row).length === 0
}
/** 여러 줄의 할 일 메일 — 겹치지 않게 (같은 id 를 두 번 보내면 서버가 거절한다) */
export function conversationActionIdsOf(rows: readonly ConversationSummary[]): string[] {
return [...new Set(rows.flatMap(conversationActionIds))]
}
/** 그 메일이 든 줄인가 — 마지막 메일은 늘 messageIds 에 있다(conversationListPageOf). 뺀 뒤 남은 옛 마지막 메일은 들지 않는다 */
export function conversationHasMessage(row: ConversationSummary, messageId: string): boolean {
return row.messageIds.includes(messageId)
}
/** 그 메일의 줄 — 줄에 든 메일이거나 그 줄의 마지막 메일(다음 맞추기 전의 옛 것 포함). 없으면 -1 */
export function conversationRowIndexOf(rows: readonly ConversationSummary[], messageId: string): number {
return rows.findIndex((row) => row.latest.id === messageId || conversationHasMessage(row, messageId))
}
// ── 줄의 글 ─────────────────────────────────────────────
/** 참여자 이름 — 다른 사람은 받은 차례(같은 이름은 한 번), 나는 끝에 "나" */
export function conversationParticipantNames(row: ConversationSummary, myEmail: string | null | undefined): string[] {
const others: string[] = []
let mine = false
for (const address of row.participants) {
if (isMyAddress(address, myEmail)) {
mine = true
continue
}
const name = participantName(address, myEmail)
if (name && !others.includes(name)) others.push(name)
}
return mine ? [...others, MAIL_CONVERSATION_TEXT.me] : others
}
export interface ConversationWhoContext {
myEmail: string | null | undefined
/** 목록의 메일함 역할 */
role: MailboxRole | null
view: 'split' | 'table'
}
/**
* 줄의 이름 자리 — 참여자를 쉼표로 ("이도윤, 나"). 보낸편지함 · 임시보관함에서 참여자가 나뿐(또는 없음)이면 마지막 메일의 받는 사람을
* "받는 사람: …" 으로(표 머리 "참여자" 아래에서 참여자가 아니라 받는 사람인 것을 가르려고 앞말을 붙인다), 그 밖에서 참여자가 없으면
* 마지막 메일의 보낸 사람 규칙(mail-list-row.ts)
*/
export function conversationWho(row: ConversationSummary, context: ConversationWhoContext): RowWho {
const names = conversationParticipantNames(row, context.myEmail)
const onlyMe = names.length === 0 || (names.length === 1 && names[0] === MAIL_CONVERSATION_TEXT.me)
if (onlyMe && context.role && OUTGOING_MAILBOX_ROLES.includes(context.role)) {
return rowWho(row.latest, { role: context.role, mixed: true, view: context.view })
}
if (names.length === 0) return rowWho(row.latest, { role: context.role, mixed: false, view: context.view })
return { text: names.join(', '), placeholder: false }
}
/** 줄의 통 수 — "4통"(대화 전체 · 보낸편지함 포함), 한 통이면 null (보이지 않는다) */
export function conversationCountText(row: ConversationSummary): string | null {
return row.messageCount > 1 ? MAIL_CONVERSATION_LIST_TEXT.rowCount(formatCount(row.messageCount)) : null
}
/**
* 목록 머리 글 — 대화로 묶었으면 "대화 6개, 안 읽은 메일 2통", 아니면 "메일 9통, 안 읽은 메일 2통".
* 대화 수는 목록의 total — 모르면(null) 그 부분을 빼고, 안 읽은 메일이 없으면 뒤를 뺀다. 둘 다 없으면 빈 글
*/
export function listHeadCountText(input: { grouped: boolean; total: number | null; unread: number }): string {
const parts: string[] = []
if (input.total !== null) {
const count = formatCount(input.total)
parts.push(
input.grouped ? MAIL_CONVERSATION_LIST_TEXT.conversationCount(count) : MAIL_CONVERSATION_LIST_TEXT.messageCount(count),
)
}
if (input.unread > 0) parts.push(MAIL_CONVERSATION_LIST_TEXT.unreadCount(formatCount(input.unread)))
return parts.join(', ')
}
/**
* 좁은 목록 머리(분할 보기)의 글 — 안 읽은 메일이 있으면 "안 읽은 메일 2통"만, 없으면 "메일 9통"(묶었으면 "대화 6개", 모르면 빈 글).
* 전체 글(listHeadCountText)은 title · 화면 낭독에 둔다 (2026-11-06 실사용 시험 — 1440 분할 보기에서 안 읽은 수가 늘 잘렸다)
*/
export function listHeadShortCountText(input: { grouped: boolean; total: number | null; unread: number }): string {
if (input.unread > 0) return MAIL_CONVERSATION_LIST_TEXT.unreadCount(formatCount(input.unread))
return listHeadCountText({ ...input, unread: 0 })
}
// ── 이 탭이 바꾼 것 ─────────────────────────────────────────────
/**
* 메일들의 표시를 바꾼 뒤의 줄 (머리말) — ids 에 든 메일이 없으면 그 줄 그대로.
* 줄의 메일(붙잡힌 메일은 빼고)을 모두 바꿨으면 읽음 · 중요 해제도 줄 전체에 적는다. 마지막 메일이면 그 메일의 표시 · 메일함도 바꾼다
*/
export function withConversationFlags(
row: ConversationSummary,
ids: ReadonlySet<string>,
changes: ConversationFlagChanges,
): ConversationSummary {
const latestHit = ids.has(row.latest.id)
if (!latestHit && !row.messageIds.some((id) => ids.has(id))) return row
const held = heldIdsOf(row)
// 읽음 — 붙잡힌 메일(내가 보내는 메일 · 읽은 메일)은 빼고 센다. 중요 해제 — 붙잡힌 메일의 표시는 몰라 그 메일까지 바꿨을 때만
const coversRead = row.messageIds.every((id) => ids.has(id) || held.has(id))
const coversAll = row.messageIds.every((id) => ids.has(id))
const next: ConversationSummary = { ...row }
if (latestHit) next.latest = { ...row.latest, ...changes }
if (changes.isRead === false) next.isRead = false
else if (changes.isRead === true && coversRead) next.isRead = true
if (changes.isFlagged === true) next.isFlagged = true
else if (changes.isFlagged === false && coversAll) next.isFlagged = false
return next
}
/** 줄의 읽음 · 중요 표시를 앞 값으로 — 보내지 못한 메일이 든 줄을 되돌린다(여러 통 처리). 마지막 메일이 그대로일 때만 그 메일의 표시도 */
export function withConversationFlagsOf(row: ConversationSummary, original: ConversationSummary): ConversationSummary {
const latest =
row.latest.id === original.latest.id
? { ...row.latest, isRead: original.latest.isRead, isFlagged: original.latest.isFlagged }
: row.latest
return { ...row, isRead: original.isRead, isFlagged: original.isFlagged, latest }
}
/** 메일을 뺀 뒤의 줄 (머리말) — 메일이 모두 빠진 줄은 빼고 그 수를 센다 */
export function withoutConversationMessages(
rows: readonly ConversationSummary[],
ids: ReadonlySet<string>,
): { rows: ConversationSummary[]; removedRows: number } {
let removedRows = 0
const next: ConversationSummary[] = []
for (const row of rows) {
if (!row.messageIds.some((id) => ids.has(id))) {
next.push(row)
continue
}
const messageIds = row.messageIds.filter((id) => !ids.has(id))
if (messageIds.length === 0) {
removedRows += 1
continue
}
next.push({ ...row, messageIds, pendingSendIds: row.pendingSendIds.filter((id) => !ids.has(id)) })
}
return { rows: next, removedRows }
}
/**
* 그 메일을 뺀 뒤 이어서 열 메일 — 메일 목록의 neighborAfterRemoval 과 같은 결이다 (14단계 리뷰 L3).
* 그 줄의 messageIds(최신부터)에서 그 메일의 다음 칸(더 오래된 메일), 없으면 앞 칸. 줄에 그 메일뿐이면 다음 줄의 마지막 메일, 없으면 앞 줄의 마지막 메일.
* 줄의 옛 마지막 메일(다음 맞추기 전 — messageIds 에서 이미 뺐다)이면 남은 메일의 첫 칸. 목록에 없는 메일이면 null
*/
export function conversationNeighborAfterRemoval(rows: readonly ConversationSummary[], messageId: string): string | null {
const index = conversationRowIndexOf(rows, messageId)
if (index === -1) return null
const ids = rows[index]?.messageIds ?? []
const position = ids.indexOf(messageId)
const inRow = position === -1 ? ids[0] : (ids[position + 1] ?? ids[position - 1])
if (inRow !== undefined) return inRow
return rows[index + 1]?.latest.id ?? rows[index - 1]?.latest.id ?? null
}
// ── 쪽 붙이기 · 다시 받아 맞추기 ─────────────────────────────────────────────
/** 받은 쪽을 뒤에 붙인다 — 이미 있는 줄은 건너뛴다. 다음 쪽의 total 이 없으면(색인 방식은 첫 쪽에서만 센다) 아는 수를 남긴다 */
export function appendConversationPage(
rows: readonly ConversationSummary[],
pageInfo: PageInfo | null,
page: ConversationListPage,
): { rows: ConversationSummary[]; pageInfo: PageInfo } {
const known = new Set(rows.map((row) => row.id))
const fresh = page.items.filter((row) => !known.has(row.id))
return {
rows: [...rows, ...fresh],
pageInfo: { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo?.total ?? null },
}
}
export interface ConversationMergeInput {
current: readonly ConversationSummary[]
currentPageInfo: PageInfo | null
fetched: readonly ConversationSummary[]
fetchedPageInfo: PageInfo
}
export interface ConversationMergeResult {
rows: ConversationSummary[]
pageInfo: PageInfo
/** 목록 맨 위의 새 메일 수 — 맨 앞부터 새로 생긴 줄(한 통으로 센다)과 새 메일이 든 줄(새 메일 수)이 이어지는 만큼 ("새 메일 N통") */
addedAbove: number
}
/** 불러온 만큼(최대 100줄)을 다시 받아 맞춘다 (머리말) — 다시 받은 범위는 서버 기준, 그 밖의 옛 줄은 그대로 */
export function mergeRefreshedConversations(input: ConversationMergeInput): ConversationMergeResult {
const { current, fetched } = input
const fetchedIds = new Set(fetched.map((row) => row.id))
const fetchedMessages = new Set(fetched.flatMap((row) => row.messageIds))
const overlaps = (row: ConversationSummary) =>
fetchedIds.has(row.id) || row.messageIds.some((id) => fetchedMessages.has(id))
// 다시 받은 범위와 겹치는 마지막 옛 줄 — 그 뒤의 옛 줄은 다시 받지 않았다
let lastOverlap = -1
current.forEach((row, index) => {
if (overlaps(row)) lastOverlap = index
})
const tail = lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((row) => !overlaps(row))
// 남겨 둔 옛 줄이 있으면 "더 보기"는 그 뒤부터 — 옛 커서를 쓴다. 전체 수는 새 값
const pageInfo =
tail.length > 0 && input.currentPageInfo
? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
: input.fetchedPageInfo
const previous = new Map(current.map((row) => [row.id, row]))
let addedAbove = 0
for (const row of fetched) {
const before = previous.get(row.id)
if (!before) {
addedAbove += 1
continue
}
const known = new Set(before.messageIds)
const fresh = row.messageIds.filter((id) => !known.has(id)).length
if (fresh === 0) break
addedAbove += fresh
}
return { rows: [...fetched, ...tail], pageInfo, addedAbove }
}
@@ -0,0 +1,364 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import {
appendConversationPage,
conversationCountOf,
conversationMessageLocation,
conversationPageOf,
conversationReplyLocation,
conversationRowsOf,
conversationSenderOf,
hasConversation,
nextConversationCursor,
participantName,
previewWithoutOwnQuote,
type ConversationLinkContext,
} from './mail-conversation'
// 같은 대화 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 원본 Thread14) — 응답 맞추기 · 통 수 · 이름 자리 · 누르면 갈 곳
const SEOUL = 'Asia/Seoul'
const ME = { email: 'ara.kim@arasystem.test', name: '김아라' }
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const INBOX = mailbox('inbox', 'inbox', '받은편지함')
const SENT = mailbox('sent', 'sent', '보낸편지함')
const DRAFTS = mailbox('drafts', 'drafts', '임시보관함')
const PROJECT = mailbox('project', null, '프로젝트')
const MAILBOXES = [INBOX, SENT, DRAFTS, PROJECT]
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: ME.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: '네, 확인했습니다.',
receivedAt: '2026-10-29T00:30:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
const EMPTY_LINKS: ConversationLinkContext = {
source: { kind: 'mailbox', mailboxId: 'inbox' },
listedIds: new Set(),
mailboxes: MAILBOXES,
}
describe('conversationPageOf — 응답 모양 맞추기', () => {
it('계약의 모양이면 그대로 — 기준(basis)도 싣는다', () => {
const page = conversationPageOf({
items: [summary('m1'), summary('m2')],
pageInfo: { hasNext: true, nextCursor: 'c2', total: 4 },
basis: 'index',
})
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm2'])
expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: 'c2', total: 4 })
expect(page.basis).toBe('index')
})
it('모양이 틀리면(항목 · 쪽 정보가 없음 · 응답을 바꾼 게이트웨이) 빈 대화 — 칸을 보이지 않는다', () => {
for (const value of [null, 'html', { items: 'x', pageInfo: {} }, { items: [] }, { usedBytes: 0 }]) {
const page = conversationPageOf(value)
expect(page.items).toEqual([])
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 0 })
expect(page.basis).toBeNull()
expect(hasConversation(page.items, page.pageInfo)).toBe(false)
}
})
it('커서 없는 hasNext 는 다음 쪽이 없는 것, 음수 · 소수 total 은 모르는 것, 모르는 basis 는 null, 줄이 읽는 칸이 없는 항목은 뺀다', () => {
const page = conversationPageOf({
items: [summary('m1'), { id: 'broken' }, { ...summary('m3'), preview: undefined }, summary('m4')],
pageInfo: { hasNext: true, nextCursor: '', total: 2.5 },
basis: 'thread',
})
expect(page.items.map((item) => item.id)).toEqual(['m1', 'm4'])
expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: null })
expect(page.basis).toBeNull()
expect(conversationPageOf({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: -1 } }).pageInfo.total).toBeNull()
})
})
describe('appendConversationPage · 통 수 · 칸 보이기', () => {
it('다음 쪽을 붙인다 — 이미 있는 메일은 건너뛰고, total 이 없는 다음 쪽(색인 방식)은 첫 쪽의 수를 남긴다', () => {
const first = { hasNext: true, nextCursor: 'c2', total: 3 }
const merged = appendConversationPage([summary('m1'), summary('m2')], first, {
items: [summary('m2'), summary('m3')],
pageInfo: { hasNext: false, nextCursor: null, total: null },
basis: 'index',
})
expect(merged.items.map((item) => item.id)).toEqual(['m1', 'm2', 'm3'])
expect(merged.added).toBe(1)
expect(merged.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 3 })
})
it('N 은 total — 모르면 다 받았을 때의 받은 수, 그것도 모르면(남은 쪽이 있다) null', () => {
const items = [summary('m1'), summary('m2')]
expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: 8 })).toBe(8)
expect(conversationCountOf(items, { hasNext: false, nextCursor: null, total: null })).toBe(2)
expect(conversationCountOf(items, { hasNext: true, nextCursor: 'c2', total: null })).toBeNull()
expect(conversationCountOf(items, null)).toBeNull()
})
it('한 통뿐이면 칸이 없다 — 수를 모르면 두 통 넘게 받았거나 받을 쪽이 남았을 때 보인다', () => {
expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: 1 })).toBe(false)
expect(hasConversation([summary('m1')], { hasNext: false, nextCursor: null, total: null })).toBe(false)
expect(hasConversation([summary('m1'), summary('m2')], { hasNext: false, nextCursor: null, total: 2 })).toBe(true)
// 엔진 방식의 큰 대화 — 첫 쪽에는 지금 메일뿐이지만 다른 메일이 있다는 수가 왔다
expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: 5 })).toBe(true)
expect(hasConversation([summary('m1')], { hasNext: true, nextCursor: 'c2', total: null })).toBe(true)
expect(nextConversationCursor({ hasNext: true, nextCursor: null, total: null })).toBeNull()
})
})
describe('이름 자리 — 내가 보냈으면 "나" + 회색 메일함 이름 (디자인 원본 Thread14)', () => {
const context = { me: ME, mailboxes: MAILBOXES }
it('보낸편지함의 메일은 "나" · 보낸편지함 — 머리글자는 내 이름의 첫 글자, 회색', () => {
const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } })
expect(conversationSenderOf(sent, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '보낸편지함' })
})
it('임시 저장본은 "나" · 임시보관함, 보낸 사람이 나면(대소문자 무시) 받은편지함에 들어도 "나" — 메일함 이름 없이', () => {
const draft = summary('m1', { mailboxIds: ['drafts'], from: null })
expect(conversationSenderOf(draft, context)).toEqual({ name: '나', initial: '김', mine: true, mailboxName: '임시보관함' })
const toSelf = summary('m2', { from: { name: null, email: 'Ara.Kim@AraSystem.test' } })
expect(conversationSenderOf(toSelf, context)).toMatchObject({ name: '나', mine: true, mailboxName: null })
})
it('받은 메일은 보낸 사람 이름(없으면 주소, 보낸 사람도 없으면 "(보낸 사람 없음)") — 내 메일함이 아닌 곳의 이름은 붙이지 않는다', () => {
expect(conversationSenderOf(summary('m1', { mailboxIds: ['project'] }), context)).toEqual({
name: '이도윤',
initial: '이',
mine: false,
mailboxName: null,
})
expect(conversationSenderOf(summary('m2', { from: { name: null, email: 'seojun.park@arasystem.test' } }), context).name).toBe(
'seojun.park@arasystem.test',
)
expect(conversationSenderOf(summary('m3', { from: null }), context)).toMatchObject({ name: '(보낸 사람 없음)', mine: false })
})
it('메일함을 아직 모르면 보낸 사람 주소로만 가른다', () => {
const sent = summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email } })
expect(conversationSenderOf(sent, { me: ME, mailboxes: [] })).toMatchObject({ name: '나', mailboxName: null })
expect(conversationSenderOf(sent, { me: null, mailboxes: [] })).toMatchObject({ name: '김아라', mine: false })
})
it('participantName — 나면 "나", 아니면 이름 (3차 묶은 목록의 참여자 줄도 쓴다)', () => {
expect(participantName({ name: '김아라', email: ME.email }, ME.email)).toBe('나')
expect(participantName({ name: '이도윤', email: 'doyun.lee@arasystem.test' }, ME.email)).toBe('이도윤')
expect(participantName({ name: null, email: 'x@example.com' }, null)).toBe('x@example.com')
})
})
describe('누르면 갈 곳 — [이 메일만 열기] · [답장]', () => {
it('지금 메일함에 든 메일은 그 메일함 안, 다른 메일함의 메일은 그 메일이 든 메일함 (보낸편지함의 내 메일)', () => {
expect(conversationMessageLocation(summary('m2'), EMPTY_LINKS)).toEqual({
name: 'mail-message',
params: { mailboxId: 'inbox', messageId: 'm2' },
})
expect(conversationMessageLocation(summary('m1', { mailboxIds: ['sent'] }), EMPTY_LINKS)).toEqual({
name: 'mail-message',
params: { mailboxId: 'sent', messageId: 'm1' },
})
})
it('임시 저장본은 쓰기 화면에서 이어 쓴다 (목록에서 누를 때와 같다)', () => {
expect(conversationMessageLocation(summary('d1', { mailboxIds: ['drafts'] }), EMPTY_LINKS)).toEqual({
name: 'mail-compose',
query: { draft: 'd1' },
})
})
it('검색 결과 · 예약됨은 받아 둔 줄에 있는 메일만 그 목록 안에서 연다 — 아니면 그 메일의 메일함', () => {
const search: ConversationLinkContext = {
source: { kind: 'search', q: '계약', scope: 'all' },
listedIds: new Set(['m2']),
mailboxes: MAILBOXES,
}
expect(conversationMessageLocation(summary('m2'), search)).toEqual({
name: 'mail-search-message',
params: { messageId: 'm2' },
query: { q: '계약', in: 'all' },
})
expect(conversationMessageLocation(summary('m3', { mailboxIds: ['project'] }), search)).toEqual({
name: 'mail-message',
params: { mailboxId: 'project', messageId: 'm3' },
})
const scheduled: ConversationLinkContext = { source: { kind: 'scheduled' }, listedIds: new Set(['s1']), mailboxes: MAILBOXES }
expect(conversationMessageLocation(summary('s1', { mailboxIds: ['sent'] }), scheduled)).toEqual({
name: 'mail-scheduled-message',
params: { messageId: 's1' },
})
})
it('메일함을 모르면 그 메일의 첫 메일함 id 로', () => {
expect(
conversationMessageLocation(summary('m1', { mailboxIds: ['x1'] }), { ...EMPTY_LINKS, mailboxes: [] }),
).toEqual({ name: 'mail-message', params: { mailboxId: 'x1', messageId: 'm1' } })
})
it('[답장]은 읽기 화면과 같은 쓰기 화면 — 임시 저장본 · 예약한 메일은 없고, 보내기 취소를 기다리는 메일은 있다', () => {
expect(conversationReplyLocation(summary('m1'), MAILBOXES)).toEqual({ name: 'mail-compose', query: { replyTo: 'm1' } })
expect(conversationReplyLocation(summary('d1', { mailboxIds: ['drafts'] }), MAILBOXES)).toBeNull()
const sendAt = '2026-10-30T00:00:00Z'
expect(
conversationReplyLocation(summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } }), MAILBOXES),
).toBeNull()
expect(
conversationReplyLocation(summary('s2', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } }), MAILBOXES),
).not.toBeNull()
})
})
describe('conversationRowsOf — 칸의 줄', () => {
/** 2026-10-29(목) 오후 3시 (서울) */
const now = new Date('2026-10-29T06:00:00Z')
const context = { ...EMPTY_LINKS, currentId: 'm3', me: ME, now, timeZone: SEOUL }
it('오래된 차례 그대로 — 지금 메일 표시 · 시각("오늘 …" · "10월 27일 (화) …") · 미리 보기가 비면 제목', () => {
const rows = conversationRowsOf(
[
summary('m1', { mailboxIds: ['sent'], from: { name: '김아라', email: ME.email }, receivedAt: '2026-10-27T05:10:00Z' }),
summary('m2', { preview: ' ' }),
summary('m3', { receivedAt: '2026-10-29T01:42:00Z' }),
],
context,
)
expect(rows.map((row) => [row.message.id, row.current, row.sender.name, row.time])).toEqual([
['m1', false, '나', '10월 27일 (화) 오후 2:10'],
['m2', false, '이도윤', '오늘 오전 9:30'],
['m3', true, '이도윤', '오늘 오전 10:42'],
])
expect(rows[1]?.preview).toBe('회신: 유지보수 계약 갱신 건')
expect(rows[0]?.preview).toBe('네, 확인했습니다.')
expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } })
})
it('안 읽음은 목록 줄과 같은 규칙 — 안 읽은 메일이면 지금 메일 · 보낸편지함의 메일도, 임시 저장본은 읽음 여부를 보이지 않는다 (2026-11-07)', () => {
const rows = conversationRowsOf(
[
summary('read'),
summary('unread', { isRead: false }),
summary('sent', { mailboxIds: ['sent'], isRead: false }),
summary('draft', { mailboxIds: ['drafts'], isRead: false }),
summary('m3', { isRead: false }),
],
context,
)
expect(rows.map((row) => [row.message.id, row.unread])).toEqual([
['read', false],
['unread', true],
['sent', true],
['draft', false],
['m3', true],
])
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각, 받은 줄 뒤에 둔다', () => {
const sendAt = '2026-10-30T00:00:00Z'
const rows = conversationRowsOf(
[
summary('s1', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } }),
summary('s2', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } }),
summary('m3'),
],
context,
)
expect(rows.map((row) => [row.message.id, row.time, row.pending, row.timeValue])).toEqual([
['m3', '오늘 오전 9:30', false, '2026-10-29T00:30:00Z'],
['s1', '예약 · 10월 30일 오전 9:00', true, sendAt],
['s2', '보내는 중', true, sendAt],
])
expect(rows[1]?.replyLocation).toBeNull()
})
it('보내기를 기다리는 메일(pendingSend)은 받은 줄의 맨 끝에 보낼 시각 차례로 — 서버 차례(저장한 시각)를 거스르지 않게', () => {
const rows = conversationRowsOf(
[
summary('m1', { receivedAt: '2026-10-29T00:10:00Z' }),
// 10시 38분에 저장한 예약 메일(11시 38분에 보낸다) — 서버는 저장한 시각 차례로 준다
summary('late', {
mailboxIds: ['sent'],
receivedAt: '2026-10-29T01:38:00Z',
pendingSend: { kind: 'scheduled', sendAt: '2026-10-29T02:38:00Z' },
}),
summary('early', {
mailboxIds: ['sent'],
receivedAt: '2026-10-29T01:39:00Z',
pendingSend: { kind: 'scheduled', sendAt: '2026-10-29T02:00:00Z' },
}),
summary('m2', { receivedAt: '2026-10-29T02:00:00Z' }),
summary('m3', { receivedAt: '2026-10-29T02:10:00Z' }),
],
context,
)
expect(rows.map((row) => row.message.id)).toEqual(['m1', 'm2', 'm3', 'early', 'late'])
})
it('줄의 미리 보기 — 우리 웹 · 앱의 원문 인용("> 원본 메일")부터 뒤는 잘라 내고, 남는 글이 없으면 제목', () => {
const rows = conversationRowsOf(
[
summary('m1', { preview: '도윤님, 금요일까지 정하겠습니다. > 원본 메일 > 보낸 사람: 이도윤 <doyun.lee@arasystem.test>' }),
summary('m2', { preview: '> 원본 메일 > 보낸 사람: 이도윤 > 날짜: 2026. 10. 29.' }),
summary('m3', { preview: '첨부를 확인해 주세요. > 원본' }),
],
context,
)
expect(rows.map((row) => row.preview)).toEqual([
'도윤님, 금요일까지 정하겠습니다.',
'회신: 유지보수 계약 갱신 건',
'첨부를 확인해 주세요.',
])
})
})
describe('previewWithoutOwnQuote — 우리 원문 인용 머리부터 잘라 낸다', () => {
it.each([
['네, 확인했습니다. > 원본 메일 > 보낸 사람: 김아라', '네, 확인했습니다.'],
['> 원본 메일 > 보낸 사람: 김아라', ''],
// 답장의 답장 — 처음 나온 머리부터
['좋습니다. > 원본 메일 > 보낸 사람: 이도윤 > > 원본 메일 > 보낸 사람: 김아라', '좋습니다.'],
// 미리 보기 끝에서 잘린 머리도 머리다 (적어도 "> 원")
['내일 뵙겠습니다. > 원본 메', '내일 뵙겠습니다.'],
['내일 뵙겠습니다. > 원', '내일 뵙겠습니다.'],
])('%s → %s', (preview, expected) => {
expect(previewWithoutOwnQuote(preview)).toBe(expected)
})
it.each([
// 다른 메일 프로그램의 인용 꼴은 손대지 않는다
'네. -----Original Message----- From: 이도윤',
'네. 2026년 10월 29일 (목) 오전 9:30, 이도윤 <doyun.lee@arasystem.test>님이 작성: > 견적서 보내드립니다.',
// 줄의 처음이 아닌 곳의 같은 글은 인용 머리가 아니다
'제목이 "A> 원본 메일"인 메일입니다.',
// 끝의 ">" 하나는 머리로 보지 않는다
'값이 a > b >',
'보통 미리 보기',
])('%s — 그대로', (preview) => {
expect(previewWithoutOwnQuote(preview)).toBe(preview)
})
})
@@ -0,0 +1,309 @@
// 같은 대화 (14단계 — 계약 2절 "대화 — 14단계" · 디자인 README "읽기 화면 — 같은 대화 (Thread14)") — 순수 함수만 둔다
// 읽기 화면의 "같은 대화" 칸(MessageConversation)이 쓰고, 3차 "대화로 묶어 보기"도 참여자 이름("나") · 쪽 맞추기를 다시 쓸 수 있게 나눠 둔다.
// - 응답 맞추기: 모양이 틀리면 빈 대화(칸이 없다), 쪽을 붙일 때 이미 있는 메일은 건너뛰고 total 이 없는 다음 쪽은 아는 수를 남긴다
// - 통 수 · 칸 보이기: total → 다 받았으면 받은 수 → 모르면 수 없이. 한 통뿐이면 칸이 없다
// - 이름 자리: 내가 보낸 메일(보낸편지함 · 임시보관함에 들었거나 보낸 사람이 나)은 "나" + 회색 메일함 이름
// - 누르면 갈 곳: 임시 저장본은 쓰기 화면(목록과 같다), 지금 목록에 있는 메일은 그 목록 안, 아니면 그 메일이 든 메일함
// - 줄의 미리 보기: 우리 웹 · 앱이 답장 · 전달에 붙인 원문 인용("> 원본 메일")부터 뒤는 잘라 낸다 — 같은 대화의 줄마다 앞 메일이 되풀이되지 않게.
// 남는 글이 없으면 제목. 다른 메일 프로그램의 인용 꼴은 손대지 않는다 (2026-11-06 실사용 시험)
// - 줄의 차례: 서버 차례(받은 시각 — 내 메일은 저장한 시각) 그대로, 보내기를 기다리는 메일(pendingSend)만 받은 줄 뒤에 보낼 시각 차례로 —
// 예약한 메일이 그 뒤에 온 답장보다 앞에 서지 않게 (2026-11-06 실사용 시험)
// - 안 읽음: 목록 줄과 같은 규칙(mail-list-row.ts 의 isRowUnread — 임시 저장본은 읽음 여부를 보이지 않는다). 지금 보는 메일의 줄도 같다
// (2026-11-07 사용자 관점 점검 — 가운데 메일을 안 읽었으면 어느 것인지 알 수 없었다)
import type { RouteLocationRaw } from 'vue-router'
import { MAIL_NO_SENDER, MAIL_NO_SUBJECT, MAIL_QUOTE_LINE_PREFIX, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
import type { AuthUser } from '@/shared/types/auth'
import type { Address, ConversationBasis, ConversationPage, Mailbox, MessageSummary } from '@/shared/types/mail'
import type { PageInfo } from '@/shared/types/pagination'
import { formatConversationTime, initialOf } from '@/shared/utils/format'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { isRowUnread, mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage, messagePendingSend } from '@/shared/utils/pending-send'
const CONVERSATION_BASES: readonly string[] = ['index', 'engine']
/** 빈 대화 — 모양이 틀린 응답 (칸을 보이지 않는다) */
function emptyConversationPage(): ConversationPage {
return { items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 }, basis: null }
}
/**
* 줄로 쓸 수 있는 항목인가 — 줄이 읽는 칸(id · 메일함 · 받는 사람 · 제목 · 미리 보기 · 받은 시각)의 모양만 본다 (나머지는 서버의 요약을 믿는다).
* 대화로 묶은 목록의 마지막 메일(latest)도 이것으로 본다 (mail-conversation-list.ts)
*/
export function isSummaryLike(value: unknown): value is MessageSummary {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Partial<Record<keyof MessageSummary, unknown>>
return (
typeof candidate.id === 'string' &&
candidate.id !== '' &&
Array.isArray(candidate.mailboxIds) &&
Array.isArray(candidate.to) &&
typeof candidate.subject === 'string' &&
typeof candidate.preview === 'string' &&
typeof candidate.receivedAt === 'string'
)
}
/**
* GET /messages/{id}/conversation 의 응답 → ConversationPage. 모양이 틀리면(응답을 바꾼 게이트웨이 등) 빈 대화다.
* 커서가 없는 hasNext 는 다음 쪽이 없는 것으로, 음수 · 정수가 아닌 total 은 모르는 것(null)으로 본다
*/
export function conversationPageOf(value: unknown): ConversationPage {
if (typeof value !== 'object' || value === null) return emptyConversationPage()
const candidate = value as { items?: unknown; pageInfo?: unknown; basis?: unknown }
if (!Array.isArray(candidate.items) || typeof candidate.pageInfo !== 'object' || candidate.pageInfo === null) {
return emptyConversationPage()
}
const info = candidate.pageInfo as { hasNext?: unknown; nextCursor?: unknown; total?: unknown }
const nextCursor = typeof info.nextCursor === 'string' && info.nextCursor !== '' ? info.nextCursor : null
const total = typeof info.total === 'number' && Number.isInteger(info.total) && info.total >= 0 ? info.total : null
const basis =
typeof candidate.basis === 'string' && CONVERSATION_BASES.includes(candidate.basis)
? (candidate.basis as ConversationBasis)
: null
return {
items: candidate.items.filter(isSummaryLike),
pageInfo: { hasNext: info.hasNext === true && nextCursor !== null, nextCursor, total },
basis,
}
}
/** 다음 쪽 커서 — 더 없으면 null */
export function nextConversationCursor(pageInfo: PageInfo | null): string | null {
return pageInfo?.hasNext === true && pageInfo.nextCursor ? pageInfo.nextCursor : null
}
/**
* 받은 쪽을 뒤에 붙인다 — 이미 있는 메일은 건너뛴다(늦게 온 원본으로 차례가 밀렸을 수 있다). added 는 새로 붙인 수.
* 다음 쪽의 total 이 없으면(색인 방식은 첫 쪽에서만 센다) 아는 수를 남긴다
*/
export function appendConversationPage(
items: readonly MessageSummary[],
pageInfo: PageInfo | null,
page: ConversationPage,
): { items: MessageSummary[]; pageInfo: PageInfo; added: number } {
const known = new Set(items.map((item) => item.id))
const fresh = page.items.filter((item) => !known.has(item.id))
return {
items: [...items, ...fresh],
pageInfo: { ...page.pageInfo, total: page.pageInfo.total ?? pageInfo?.total ?? null },
added: fresh.length,
}
}
/** 칸 제목의 통 수 — total, 모르면 다 받았을 때(다음 쪽 없음)의 받은 수, 그것도 모르면 null("같은 대화" — 수를 뺀다) */
export function conversationCountOf(items: readonly unknown[], pageInfo: PageInfo | null): number | null {
if (!pageInfo) return null
if (pageInfo.total !== null) return pageInfo.total
return pageInfo.hasNext ? null : items.length
}
/** 칸을 보일까 — 다른 메일이 있을 때만(한 통뿐이면 칸이 없다). 수를 모르면 두 통 넘게 받았거나 더 받을 쪽이 남았을 때 */
export function hasConversation(items: readonly unknown[], pageInfo: PageInfo | null): boolean {
const count = conversationCountOf(items, pageInfo)
if (count !== null) return count > 1
return items.length > 1 || nextConversationCursor(pageInfo) !== null
}
// ── 이름 자리 ─────────────────────────────────────────────
/** 내 주소인가 — 대소문자 · 앞뒤 공백을 가리지 않는다 */
export function isMyAddress(address: Address | null | undefined, myEmail: string | null | undefined): boolean {
if (!address || !myEmail) return false
return normalizeEmail(address.email) === normalizeEmail(myEmail)
}
/** 참여자 이름 — 나면 "나", 아니면 이름(없으면 주소). 3차 "대화로 묶어 보기"의 참여자 줄("이도윤, 나")도 이것을 쓴다 */
export function participantName(address: Address | null | undefined, myEmail: string | null | undefined): string {
return isMyAddress(address, myEmail) ? MAIL_CONVERSATION_TEXT.me : displayName(address)
}
/** 그 메일이 든 보낸편지함 · 임시보관함 — 없으면(받은 메일 · 메일함을 아직 모름) null */
export function outgoingMailboxOf(
message: Pick<MessageSummary, 'mailboxIds'>,
mailboxes: readonly Mailbox[],
): Mailbox | null {
for (const id of message.mailboxIds) {
const mailbox = mailboxes.find((entry) => entry.id === id)
if (mailbox?.role && OUTGOING_MAILBOX_ROLES.includes(mailbox.role)) return mailbox
}
return null
}
function isDraftMessage(message: Pick<MessageSummary, 'mailboxIds'>, mailboxes: readonly Mailbox[]): boolean {
return outgoingMailboxOf(message, mailboxes)?.role === 'drafts'
}
export interface ConversationSender {
/** 이름 자리 — 내가 보냈으면 "나" */
name: string
/** 머리글자 — 나도 내 이름의 첫 글자 (디자인 원본 Thread14 "김") */
initial: string
/** 내가 보낸 메일 — 머리글자를 회색으로 */
mine: boolean
/** 이름 뒤 회색 글 — 보낸편지함 · 임시보관함에 든 메일이면 그 메일함 이름 */
mailboxName: string | null
}
/**
* 줄의 이름 자리 — 보낸편지함 · 임시보관함에 들었거나 보낸 사람이 나면 "나"(+ 그 메일함 이름), 아니면 보낸 사람 이름.
* 메일함을 아직 모르면 보낸 사람 주소로만 가른다(메일함 이름 없이)
*/
export function conversationSenderOf(
message: Pick<MessageSummary, 'from' | 'mailboxIds'>,
context: { me: AuthUser | null; mailboxes: readonly Mailbox[] },
): ConversationSender {
const outgoing = outgoingMailboxOf(message, context.mailboxes)
const mine = outgoing !== null || isMyAddress(message.from, context.me?.email)
const initial = initialOf(message.from?.name, message.from?.email ?? '')
return {
name: mine ? MAIL_CONVERSATION_TEXT.me : displayName(message.from) || MAIL_NO_SENDER,
initial: initial || (mine ? initialOf(context.me?.name, context.me?.email ?? '') : ''),
mine,
mailboxName: outgoing?.name ?? null,
}
}
// ── 누르면 갈 곳 ─────────────────────────────────────────────
export interface ConversationLinkContext {
/** 지금 목록 — 그 목록에 드는 메일이면 그 목록 안에서 연다 */
source: MailListSource | null
/** 지금 목록에 받아 둔 메일 — 검색 결과 · 예약됨은 받아 둔 줄에 있을 때만 그 목록 안에서 연다 */
listedIds: ReadonlySet<string>
mailboxes: readonly Mailbox[]
}
/**
* [이 메일만 열기] — 임시 저장본은 쓰기 화면에서 이어 쓴다(목록에서 누를 때와 같다). 지금 목록에 드는 메일은 그 목록 안의 주소(목록 · 검색 결과가 그대로),
* 아니면 그 메일이 든 메일함의 주소(보낸편지함의 내 메일 등 — 목록이 그 메일함으로 바뀐다). 기록에 쌓는다(RouterLink — 목록에서 메일을 여는 것과 같다)
*/
export function conversationMessageLocation(
message: Pick<MessageSummary, 'id' | 'mailboxIds'>,
context: ConversationLinkContext,
): RouteLocationRaw {
if (isDraftMessage(message, context.mailboxes)) return { name: 'mail-compose', query: { draft: message.id } }
const { source } = context
// 메일함 목록(대화로 묶은 목록 포함 — 14단계)은 그 메일함에 든 메일이면 그 목록 안에서 연다
const listMailboxId = mailboxListIdOf(source)
if (source && listMailboxId !== null && message.mailboxIds.includes(listMailboxId)) {
return messageLocationOf(source, message.id)
}
if (source && listMailboxId === null && context.listedIds.has(message.id)) return messageLocationOf(source, message.id)
const mailboxId = mailboxOfMessage(message, context.mailboxes)?.id ?? message.mailboxIds[0]
if (mailboxId) return messageLocationOf({ kind: 'mailbox', mailboxId }, message.id)
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
}
/** [답장] — 읽기 화면의 [답장]과 같은 쓰기 화면. 임시 저장본 · 예약한 메일은 없다(읽기 화면도 예약한 메일에는 답장이 없다) */
export function conversationReplyLocation(
message: Pick<MessageSummary, 'id' | 'mailboxIds' | 'pendingSend'>,
mailboxes: readonly Mailbox[],
): RouteLocationRaw | null {
if (isDraftMessage(message, mailboxes) || isScheduledMessage(message)) return null
return { name: 'mail-compose', query: { replyTo: message.id } }
}
// ── 줄 ─────────────────────────────────────────────
export interface ConversationRow {
message: MessageSummary
/** 지금 읽기 화면에 연 메일 — "지금 보고 있는 메일", 누를 수 없다 */
current: boolean
/** 안 읽은 메일 — 목록 줄처럼 굵게, 화면 낭독은 이름 앞에 "안 읽음, " (임시 저장본은 늘 false) */
unread: boolean
sender: ConversationSender
/** 미리 보기 한 줄 — 비었으면 제목 */
preview: string
/** 시각 자리 — 붙잡힌 메일(보내기 취소 · 예약)은 목록과 같은 "보내는 중" · "예약 · …" */
time: string
/** <time datetime> — 붙잡힌 메일은 보낼 시각 */
timeValue: string
/** 붙잡힌 메일 — 시각 자리를 청록 굵게 (목록과 같다) */
pending: boolean
/** 펼쳤을 때의 [이 메일만 열기] */
location: RouteLocationRaw
/** 펼쳤을 때의 [답장] — 없으면 null */
replyLocation: RouteLocationRaw | null
}
export interface ConversationRowContext extends ConversationLinkContext {
currentId: string
me: AuthUser | null
/** 시각 글의 "오늘 · 어제"를 가르는 지금 */
now: Date
/** 테스트가 시간대를 넘긴다 — 주지 않으면 브라우저 시간대 */
timeZone?: string
}
/**
* 우리 웹 · 앱이 답장 · 전달의 텍스트 본문에 붙이는 원문 인용의 머리 줄 — "> 원본 메일" (mail-serialize.ts 의 인용 줄 머리 + 인용 머리 글).
* 미리 보기는 줄바꿈이 띄어쓰기로 바뀐 글이라 "…정하겠습니다. > 원본 메일 > 보낸 사람: …" 꼴로 온다
*/
const OWN_QUOTE_MARKER = `${MAIL_QUOTE_LINE_PREFIX}${MAIL_QUOTE_HEADING}`
/** 미리 보기 끝에서 잘린 머리("> 원본 메")도 머리로 본다 — 적어도 인용 줄 머리 다음 한 글자("> 원")는 있어야 한다 */
const OWN_QUOTE_TAIL_MIN = MAIL_QUOTE_LINE_PREFIX.length + 1
const WHITESPACE = /\s/
/** 그 자리가 글의 처음이거나 바로 앞이 띄어쓰기(줄바꿈)인가 — 인용 머리는 줄의 처음에만 있다 */
function startsLine(text: string, index: number): boolean {
return index === 0 || WHITESPACE.test(text.charAt(index - 1))
}
/**
* 미리 보기에서 우리 웹 · 앱의 원문 인용("> 원본 메일")부터 뒤를 잘라 낸다 (머리말). 미리 보기 끝에서 잘린 머리도 지운다.
* 다른 메일 프로그램의 인용(“… 작성:” · "-----Original Message-----" 등)은 그대로 둔다
*/
export function previewWithoutOwnQuote(preview: string): string {
let from = 0
for (;;) {
const index = preview.indexOf(OWN_QUOTE_MARKER, from)
if (index === -1) break
if (startsLine(preview, index)) return preview.slice(0, index).trimEnd()
from = index + 1
}
for (let length = OWN_QUOTE_MARKER.length - 1; length >= OWN_QUOTE_TAIL_MIN; length -= 1) {
const start = preview.length - length
if (start >= 0 && preview.endsWith(OWN_QUOTE_MARKER.slice(0, length)) && startsLine(preview, start)) {
return preview.slice(0, start).trimEnd()
}
}
return preview
}
/** 보내기를 기다리는 메일(보내기 취소 · 예약)은 받은 줄 뒤에 보낼 시각 차례로 (머리말) — 나머지는 받은 차례 그대로 */
export function orderHeldLast(items: readonly MessageSummary[]): MessageSummary[] {
const sendAtOf = (message: MessageSummary) => Date.parse(messagePendingSend(message)?.sendAt ?? '')
const held = items.filter((message) => messagePendingSend(message) !== null)
if (held.length === 0) return [...items]
return [
...items.filter((message) => messagePendingSend(message) === null),
// 같은 시각이면 받은 차례 그대로 (sort 는 안정 정렬)
...held.sort((a, b) => sendAtOf(a) - sendAtOf(b)),
]
}
/** 받은 대화 → 칸의 줄 (오래된 차례 — 보내기를 기다리는 메일은 끝에) */
export function conversationRowsOf(items: readonly MessageSummary[], context: ConversationRowContext): ConversationRow[] {
return orderHeldLast(items).map((message) => {
const pendingLabel = pendingSendLabel(message, context.timeZone)
return {
message,
current: message.id === context.currentId,
unread: isRowUnread(message, outgoingMailboxOf(message, context.mailboxes)?.role ?? null),
sender: conversationSenderOf(message, context),
preview: previewWithoutOwnQuote(message.preview).trim() || message.subject || MAIL_NO_SUBJECT,
time: pendingLabel ?? formatConversationTime(message.receivedAt, context.now, context.timeZone),
timeValue: message.pendingSend?.sendAt ?? message.receivedAt,
pending: pendingLabel !== null,
location: conversationMessageLocation(message, context),
replyLocation: conversationReplyLocation(message, context.mailboxes),
}
})
}
@@ -278,3 +278,39 @@ describe('대용량 첨부 덩이 접기 (11단계 — 디자인 원본 MailRead
expect(doc).toContain('details[open] .ara-large-files__hint,details[open] .ara-large-files__open,details:not([open]) .ara-large-files__close{display:none}')
})
})
describe('검색 강조 (14단계 — 계약 2절 "검색 — 14단계" › 읽기 화면)', () => {
it('서버가 서식 본문의 글 조각에 넣은 <mark> 는 한 번 더 정화해도 남는다 (속성은 지운다)', () => {
const html = sanitizeMailHtml('<p>지난번 <mark>계약</mark>서 초안을 <mark onclick="x()" class="a">첨부</mark>했습니다</p>')
expect(html).toBe('<p>지난번 <mark>계약</mark>서 초안을 <mark class="a">첨부</mark>했습니다</p>')
expect(buildMailDocument({ htmlBody: '<p><mark>계약</mark></p>', textBody: null })).toContain('<mark>계약</mark>')
})
it('글 본문은 맞은 곳(textHighlights)을 <mark> 로 — 나머지는 지금처럼 이스케이프한다', () => {
const doc = buildMailDocument({
htmlBody: null,
textBody: '<b>계약</b>서를 보냅니다\n계약 갱신',
textHighlights: [
[3, 5],
[17, 19],
],
})
expect(doc).toContain('<div class="mail-text">&lt;b&gt;<mark>계약</mark>&lt;/b&gt;서를 보냅니다\n<mark>계약</mark> 갱신</div>')
})
it('서식 본문이 있으면 글 본문의 맞은 곳은 쓰지 않는다 · 맞은 곳이 없으면 지금과 같다', () => {
const withHtml = buildMailDocument({ htmlBody: '<p>본문</p>', textBody: '계약', textHighlights: [[0, 2]] })
expect(withHtml).not.toContain('<mark>')
expect(buildMailDocument({ htmlBody: null, textBody: 'a < b', textHighlights: null })).toContain(
'<div class="mail-text">a &lt; b</div>',
)
})
it('맞은 말의 모양은 문서 안 style 이 정한다 — 노랑 · 글자색 그대로 (디자인 원본 Search14)', () => {
expect(buildMailDocument({ htmlBody: null, textBody: '' })).toContain(
'mark{background:#FCE7A4;color:inherit;border-radius:0.125rem;padding:0 0.0625rem}',
)
})
})
+18 -1
View File
@@ -11,6 +11,8 @@ import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { MAIL_IMAGE_PROXY_PATH } from '@/shared/constants/mail'
import type { SearchRange } from '@/shared/types/mail'
import { highlightSegments } from '@/shared/utils/search-highlight'
/**
* iframe 문서 안에만 적용하는 CSP.
@@ -166,6 +168,9 @@ const MAIL_FRAME_STYLE = [
'pre{white-space:pre-wrap}',
'blockquote{margin:0 0 0 0.25rem;padding-left:0.75rem;border-left:0.125rem solid #98A2B3;color:#4A5363}',
'.mail-text{white-space:pre-wrap}',
// 검색에서 맞은 말 (14단계 — 서버가 정화한 HTML 의 글 조각에 넣은 <mark> · 글 본문은 아래 textHighlights).
// 짝: styles/tokens.css 의 --color-search-mark — 본문 틀은 화면의 변수를 읽지 못해 같은 값을 적는다, 한쪽을 고치면 둘 다
'mark{background:#FCE7A4;color:inherit;border-radius:0.125rem;padding:0 0.0625rem}',
// 대용량 첨부 덩이를 접는 줄 (11단계 — 디자인 원본 MailReadLargeFiles: 높이 40 · 12.5px · 바탕 #F6F7F9 · 펼치기 #0A5F67).
// 스크립트 없이 details · summary 로 열고 닫는다 — 접힌 동안은 "(접어 둠) — …" 와 「펼치기」, 펼치면 「접기」
'details.ara-large-files{margin:0.875rem 0}',
@@ -234,6 +239,18 @@ export interface MailBodySource {
* 대용량 첨부 덩이를 접는다 (11단계 — 읽기 화면이 첨부 칸에 그 파일을 보일 때). 인쇄 화면은 접지 않는다(종이에는 주소가 있어야 한다)
*/
foldLargeFiles?: boolean
/**
* 글 본문(textBody)의 검색에서 맞은 곳 (14단계 — 검색 결과에서 연 메일의 searchHighlight.textBody). 글 본문을 보일 때만 <mark> 로 그린다 —
* 서식 본문은 서버가 이미 맞은 글 조각에 <mark> 를 넣었다
*/
textHighlights?: readonly SearchRange[] | null
}
/** 글 본문 → 이스케이프한 HTML — 맞은 곳은 <mark> 로 */
function textBodyHtml(textBody: string, highlights: readonly SearchRange[] | null | undefined): string {
return highlightSegments(textBody, highlights)
.map((segment) => (segment.mark ? `<mark>${escapeHtml(segment.text)}</mark>` : escapeHtml(segment.text)))
.join('')
}
/** 이 화면의 출처 — 브라우저가 아니면 빈 글 (CSP 는 'self' 로 연다 — mailFrameCspImagesShown) */
@@ -265,7 +282,7 @@ export function buildMailDocument(source: MailBodySource): string {
const content =
html !== null
? sanitizeMailHtml(html.trim(), { imagesShown })
: `<div class="mail-text">${escapeHtml(source.textBody ?? '')}</div>`
: `<div class="mail-text">${textBodyHtml(source.textBody ?? '', source.textHighlights)}</div>`
const csp = imagesShown ? mailFrameCspImagesShown(source.origin ?? currentOrigin()) : MAIL_FRAME_CSP
return [
+2 -2
View File
@@ -6,6 +6,7 @@
import {
DRAFT_NO_RECIPIENTS,
MAIL_NO_SENDER,
MAIL_SEARCH_TEXT,
OUTGOING_MAILBOX_ROLES,
} from '@/shared/constants/mail'
@@ -16,7 +17,6 @@ import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/
import { messagePendingSend } from '@/shared/utils/pending-send'
const NO_RECIPIENTS = '받는 사람 없음'
const NO_SENDER = '(보낸 사람 없음)'
export interface RowContext {
/** 행의 메일함 역할 — 메일함 목록 · 한 메일함 안의 검색은 그 메일함, 전체 메일함 검색은 그 메일의 메일함 */
@@ -72,7 +72,7 @@ export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: Ro
const prefixed = view === 'split' || mixed
return { text: prefixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${recipients}` : recipients, placeholder: false }
}
return { text: displayName(message.from) || NO_SENDER, placeholder: false }
return { text: displayName(message.from) || MAIL_NO_SENDER, placeholder: false }
}
/** 표 머리의 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
@@ -4,10 +4,12 @@ import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import {
exceedsSearchWords,
isAllMailboxSearch,
isConversationSource,
isSearchRouteName,
listKeyOf,
listLocationOf,
listQueryOf,
mailboxListIdOf,
messageLocationOf,
normalizeSearchText,
sameListSource,
@@ -93,3 +95,99 @@ describe('mail-list-source (보이는 목록의 출처)', () => {
expect(exceedsSearchWords(' "회의를 잡겠습니다" ')).toBe(false)
})
})
describe('mail-list-source — 조건 검색 (14단계)', () => {
const CONDITIONS = {
from: '이도윤',
to: '',
subject: '',
body: '',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
}
const WITH_CONDITIONS: MailListSource = { kind: 'search', q: '계약', scope: 'inbox', conditions: CONDITIONS }
const CONDITIONS_ONLY: MailListSource = { kind: 'search', q: '', scope: SEARCH_SCOPE_ALL, conditions: CONDITIONS }
it('주소에 조건을 싣는다 — 검색어가 없으면 q 를 뺀다. 결과에서 연 메일의 주소도 같은 쿼리', () => {
expect(listLocationOf(WITH_CONDITIONS)).toEqual({
name: 'mail-search',
query: {
q: '계약',
in: 'inbox',
from: '이도윤',
since: '2026-10-06',
until: '2026-11-06',
attachment: '1',
},
})
expect(listLocationOf(CONDITIONS_ONLY)).toEqual({
name: 'mail-search',
query: { in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' },
})
expect(messageLocationOf(CONDITIONS_ONLY, 'm1')).toEqual({
name: 'mail-search-message',
params: { messageId: 'm1' },
query: { in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' },
})
})
it('주소 → 출처 — 검색어가 없어도 조건이 있으면 검색이다. 조건이 없으면 conditions 를 두지 않는다', () => {
expect(
searchSourceFromQuery({ in: 'all', from: '이도윤', since: '2026-10-06', until: '2026-11-06', attachment: '1' }),
).toEqual(CONDITIONS_ONLY)
expect(searchSourceFromQuery({ q: '회의', in: 'inbox', since: 'oops' })).toEqual({
kind: 'search',
q: '회의',
scope: 'inbox',
})
expect(searchSourceFromQuery({ in: 'all', attachment: '0' })).toBeNull()
})
it('API 쿼리 — 검색어 · 메일함 · 조건(날짜는 지역 0시의 UTC · 끝 날짜는 다음 날 0시)', () => {
expect(listQueryOf(WITH_CONDITIONS)).toEqual({
q: '계약',
mailboxId: 'inbox',
from: '이도윤',
after: new Date(2026, 9, 6).toISOString(),
before: new Date(2026, 10, 7).toISOString(),
hasAttachment: 'true',
})
expect(listQueryOf(CONDITIONS_ONLY)).not.toHaveProperty('q')
})
it('같은 목록인지 · 목록 키 — 조건까지 같아야 같다', () => {
const plain: MailListSource = { kind: 'search', q: '계약', scope: 'inbox' }
expect(sameListSource(WITH_CONDITIONS, plain)).toBe(false)
expect(sameListSource(WITH_CONDITIONS, { ...WITH_CONDITIONS, conditions: { ...CONDITIONS } })).toBe(true)
expect(sameListSource(plain, { ...plain, conditions: { ...CONDITIONS, from: '', since: null, until: null, hasAttachment: false } })).toBe(true)
expect(listKeyOf(WITH_CONDITIONS)).not.toBe(listKeyOf(plain))
expect(listKeyOf(plain)).toBe('search:inbox:계약')
})
})
describe('mail-list-source — 대화로 묶은 메일함 (14단계)', () => {
const THREADS: MailListSource = { kind: 'conversations', mailboxId: 'inbox' }
it('주소는 메일함 목록과 같다 — 줄을 누르면 그 메일함 안의 메일 주소', () => {
expect(listLocationOf(THREADS)).toEqual(listLocationOf(INBOX))
expect(messageLocationOf(THREADS, 'm1')).toEqual(messageLocationOf(INBOX, 'm1'))
})
it('가리키는 메일함은 그 메일함 — 메일함 목록처럼 다룬다(같은 대화 칸의 링크 · 다시 받기)', () => {
expect(scopeMailboxIdOf(THREADS)).toBe('inbox')
expect(mailboxListIdOf(THREADS)).toBe('inbox')
expect(mailboxListIdOf(INBOX)).toBe('inbox')
expect(mailboxListIdOf(SEARCH_INBOX)).toBeNull()
expect(isConversationSource(THREADS)).toBe(true)
expect(isConversationSource(INBOX)).toBe(false)
expect(isAllMailboxSearch(THREADS)).toBe(false)
})
it('같은 메일함이어도 묶은 목록과 메일 목록은 다른 목록이다 — 켜고 끄면 고른 것을 풀고 처음 쪽부터 받는다', () => {
expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'inbox' })).toBe(true)
expect(sameListSource(THREADS, { kind: 'conversations', mailboxId: 'sent' })).toBe(false)
expect(sameListSource(THREADS, INBOX)).toBe(false)
expect(listKeyOf(THREADS)).not.toBe(listKeyOf(INBOX))
})
})
+93 -38
View File
@@ -1,10 +1,26 @@
// 보이는 메일 목록의 출처 — 메일함 하나 · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송").
// 보이는 메일 목록의 출처 — 메일함 하나 · 대화로 묶은 메일함(14단계) · 검색 결과 · 예약됨(9단계) (순수 함수만 둔다)
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색" · "보내기 취소 · 예약 발송" · "대화 — 14단계").
// 대화로 묶은 메일함은 주소가 메일함 목록과 같다 — 묶을지는 사용자 설정(groupByConversation)이 정하고, 줄을 누르면 그 메일함의 마지막 메일을 연다.
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { MessageListQuery } from '@/shared/types/mail'
import {
countSearchWords,
exceedsCombinedSearchWords,
firstQueryValue,
hasSearchConditions,
normalizeSearchText,
sameSearchConditions,
searchConditionsFromRoute,
searchConditionsKey,
searchConditionsParams,
searchConditionsRouteQuery,
type SearchConditions,
} from '@/shared/utils/mail-search'
export { normalizeSearchText } from '@/shared/utils/mail-search'
export interface MailboxListSource {
kind: 'mailbox'
@@ -13,10 +29,12 @@ export interface MailboxListSource {
export interface SearchListSource {
kind: 'search'
/** 앞뒤 공백을 뺀 검색어 */
/** 앞뒤 공백을 뺀 검색어 — 조건만으로 찾으면 빈 글 (14단계) */
q: string
/** 찾을 곳 — 메일함 id 또는 전체(SEARCH_SCOPE_ALL) */
scope: string
/** 조건 (14단계 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부). 없으면 검색어로만 찾는다 (조건이 없으면 두지 않는다 — searchSourceOf) */
conditions?: SearchConditions
}
/** 예약한 메일 — GET /scheduled-messages (보낼 시각 차례). 고르기 · 여러 통 처리는 없다 (디자인 README "9단계") */
@@ -24,10 +42,16 @@ export interface ScheduledListSource {
kind: 'scheduled'
}
/** 대화로 묶은 메일함 — GET /conversations?mailboxId= (14단계). 한 줄이 그 메일함의 대화 하나다 */
export interface ConversationListSource {
kind: 'conversations'
mailboxId: string
}
/** GET /messages 로 받는 목록 — 메일함 · 검색 */
export type MessageListSource = MailboxListSource | SearchListSource
export type MailListSource = MessageListSource | ScheduledListSource
export type MailListSource = MessageListSource | ScheduledListSource | ConversationListSource
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
export const MAIL_SEARCH_ROUTE = 'mail-search'
@@ -50,20 +74,40 @@ export function isScheduledRouteName(name: unknown): boolean {
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
if (a.kind === 'conversations' && b.kind === 'conversations') return a.mailboxId === b.mailboxId
if (a.kind === 'search' && b.kind === 'search') {
return a.q === b.q && a.scope === b.scope && sameSearchConditions(a.conditions, b.conditions)
}
return a.kind === 'scheduled' && b.kind === 'scheduled'
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
export function listQueryOf(source: MessageListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
/** 대화로 묶은 메일함인가 (14단계) */
export function isConversationSource(source: MailListSource | null | undefined): source is ConversationListSource {
return source?.kind === 'conversations'
}
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
/**
* 메일함 하나의 목록(묶었든 아니든)이면 그 메일함 id — 검색 결과 · 예약됨이면 null.
* "그 메일함에 든 메일이면 이 목록 안에서 연다" · "그 메일함을 보고 있으면 다시 받는다"를 가르는 데 쓴다
*/
export function mailboxListIdOf(source: MailListSource | null | undefined): string | null {
return source?.kind === 'mailbox' || source?.kind === 'conversations' ? source.mailboxId : null
}
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) + 조건(14단계 — 빈 칸은 넣지 않는다) */
export function listQueryOf(source: MessageListSource): Omit<MessageListQuery, 'cursor' | 'limit'> {
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
return {
...(source.q ? { q: source.q } : {}),
...(source.scope === SEARCH_SCOPE_ALL ? {} : { mailboxId: source.scope }),
...searchConditionsParams(source.conditions),
}
}
/** 출처가 가리키는 메일함 하나 — 메일함 목록(묶었든 아니든)이거나 한 메일함 안의 검색. 전체 메일함 검색 · 예약됨이면 null */
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
if (!source || source.kind === 'scheduled') return null
if (source.kind === 'mailbox') return source.mailboxId
if (source.kind === 'mailbox' || source.kind === 'conversations') return source.mailboxId
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
}
@@ -80,56 +124,67 @@ export function isScheduledSource(source: MailListSource | null): boolean {
/** 목록마다 다른 키 — 표 위치 기억 등 */
export function listKeyOf(source: MailListSource): string {
if (source.kind === 'scheduled') return 'scheduled'
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
if (source.kind === 'mailbox') return `mailbox:${source.mailboxId}`
if (source.kind === 'conversations') return `conversations:${source.mailboxId}`
const conditions = searchConditionsKey(source.conditions)
return conditions ? `search:${source.scope}:${source.q}:${conditions}` : `search:${source.scope}:${source.q}`
}
/** 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> */
/**
* 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> + 조건(14단계 — mail-search.ts 의 searchConditionsRouteQuery).
* 검색어 없이 조건만으로 찾으면 q 를 뺀다
*/
export function searchRouteQuery(source: SearchListSource): Record<string, string> {
return { q: source.q, in: source.scope }
return {
...(source.q ? { q: source.q } : {}),
in: source.scope,
...searchConditionsRouteQuery(source.conditions),
}
}
/** 목록 화면 주소 */
/** 검색 출처 — 조건이 없으면 conditions 를 두지 않는다 (같은 검색은 늘 같은 모양) */
export function searchSourceOf(q: string, scope: string, conditions?: SearchConditions | null): SearchListSource {
return conditions && hasSearchConditions(conditions)
? { kind: 'search', q, scope, conditions }
: { kind: 'search', q, scope }
}
/** 목록 화면 주소 — 대화로 묶은 메일함도 메일함 주소다 */
export function listLocationOf(source: MailListSource): RouteLocationRaw {
if (source.kind === 'mailbox') return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
if (source.kind === 'mailbox' || source.kind === 'conversations') {
return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
}
if (source.kind === 'scheduled') return { name: MAIL_SCHEDULED_ROUTE }
return { name: MAIL_SEARCH_ROUTE, query: searchRouteQuery(source) }
}
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=…, 예약됨은 /mail/scheduled/<메일 id> */
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
if (source.kind === 'mailbox') {
if (source.kind === 'mailbox' || source.kind === 'conversations') {
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) }
}
function firstValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
const first = Array.isArray(value) ? value[0] : value
return typeof first === 'string' ? first : ''
}
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
/**
* 검색어 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘는 검색인가 (14단계) — 검색 칸 · [조건]은 보내기 전에 막지만 손으로 고친 주소로 들어올 수 있다.
* 목록 스토어는 묻지 않고(400 이 될 것이라), 목록 틀은 까닭을 보이고, 읽기 화면은 강조 쿼리를 싣지 않는다
*/
export function normalizeSearchText(text: string): string {
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
export function isSearchTooLong(source: MailListSource | null | undefined): boolean {
return source?.kind === 'search' && exceedsCombinedSearchWords(source.q, source.conditions)
}
/** 서버가 받는 낱말 수를 넘는가 — 띄어쓰기로 나눈 조각을 센다 (따옴표와 상관없이, 서버와 같다) */
export function exceedsSearchWords(text: string): boolean {
return text.split(/\s+/u).filter((word) => word !== '').length > MAIL_SEARCH_MAX_WORDS
return countSearchWords(text) > MAIL_SEARCH_MAX_WORDS
}
/** 주소의 검색 쿼리 → 출처. 검색어가 없으면 null (범위가 비었으면 전체) */
/** 주소의 검색 쿼리 → 출처. 검색어도 조건도 없으면 null (범위가 비었으면 전체) */
export function searchSourceFromQuery(query: LocationQuery): SearchListSource | null {
const q = normalizeSearchText(firstValue(query.q))
if (!q) return null
const scope = firstValue(query.in).trim()
return { kind: 'search', q, scope: scope || SEARCH_SCOPE_ALL }
const q = normalizeSearchText(firstQueryValue(query.q))
const conditions = searchConditionsFromRoute(query)
if (!q && !hasSearchConditions(conditions)) return null
const scope = firstQueryValue(query.in).trim()
return searchSourceOf(q, scope || SEARCH_SCOPE_ALL, conditions)
}
@@ -0,0 +1,284 @@
import { describe, expect, it } from 'vitest'
import {
combinedSearchWords,
exceedsCombinedSearchWords,
hasSearchConditions,
isReversedPeriod,
isSearchDay,
messageHighlightKey,
messageHighlightQueryOf,
NO_SEARCH_CONDITIONS,
normalizeSearchConditions,
periodOf,
periodRange,
sameSearchConditions,
searchConditionsFromRoute,
searchConditionsKey,
searchConditionsParams,
searchConditionsRouteQuery,
searchConditionsSummary,
skipsBodySearch,
type SearchConditions,
} from './mail-search'
// 조건 검색 (14단계 — 계약 2절 "검색 — 14단계" · 디자인 README 「14단계 › 검색 · 조건 검색 · 강조」)
// 날짜는 브라우저 시간대의 날짜다 — 기대값도 지역 시각(new Date(년, 월, 일))으로 만든다 (TZ=UTC · KST 모두 같은 답)
function conditions(partial: Partial<SearchConditions> = {}): SearchConditions {
return { ...NO_SEARCH_CONDITIONS, ...partial }
}
/** 지역 날짜 0시의 UTC ISO — 화면이 보내는 after · before 와 같은 방법 */
const localMidnightIso = (year: number, month: number, day: number) => new Date(year, month - 1, day).toISOString()
describe('조건 다듬기 · 같은 조건', () => {
it('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만, 첨부는 true 만', () => {
expect(
normalizeSearchConditions({
from: ' 이도윤 ',
subject: '가'.repeat(250),
since: '2026-02-30',
until: '2026-11-05',
hasAttachment: true,
}),
).toEqual({
from: '이도윤',
to: '',
subject: '가'.repeat(200),
body: '',
since: null,
until: '2026-11-05',
hasAttachment: true,
})
expect(normalizeSearchConditions(null)).toEqual(NO_SEARCH_CONDITIONS)
expect(normalizeSearchConditions({ since: '2026-1-5' }).since).toBeNull()
})
it('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () => {
expect(isSearchDay('1970-01-01')).toBe(true)
expect(isSearchDay('9999-12-30')).toBe(true)
expect(isSearchDay('1969-12-31')).toBe(false)
// 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다
expect(isSearchDay('9999-12-31')).toBe(false)
expect(isSearchDay('10000-01-01')).toBe(false)
expect(isSearchDay('2024-02-29')).toBe(true)
expect(isSearchDay('2026-02-29')).toBe(false)
expect(isSearchDay('')).toBe(false)
expect(normalizeSearchConditions({ since: '1969-12-31', until: '2026-11-01' })).toEqual(
conditions({ until: '2026-11-01' }),
)
expect(normalizeSearchConditions({ since: '2026-11-01', until: '9999-12-31' })).toEqual(
conditions({ since: '2026-11-01' }),
)
})
it('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () => {
expect(normalizeSearchConditions({ since: '2026-11-06', until: '2026-10-01', hasAttachment: true })).toEqual(
conditions({ hasAttachment: true }),
)
expect(normalizeSearchConditions({ since: '2026-11-06', until: '2026-11-06' })).toEqual(
conditions({ since: '2026-11-06', until: '2026-11-06' }),
)
})
it('조건이 하나라도 있는가 · 같은 조건인가 — 없는 조건과 빈 조건은 같다', () => {
expect(hasSearchConditions(null)).toBe(false)
expect(hasSearchConditions(NO_SEARCH_CONDITIONS)).toBe(false)
expect(hasSearchConditions(conditions({ hasAttachment: true }))).toBe(true)
expect(hasSearchConditions(conditions({ until: '2026-11-05' }))).toBe(true)
expect(sameSearchConditions(undefined, NO_SEARCH_CONDITIONS)).toBe(true)
expect(sameSearchConditions(conditions({ from: '이도윤' }), conditions({ from: '이도윤' }))).toBe(true)
expect(sameSearchConditions(conditions({ from: '이도윤' }), conditions({ to: '이도윤' }))).toBe(false)
expect(searchConditionsKey(NO_SEARCH_CONDITIONS)).toBe('')
})
})
describe('화면 주소 ↔ 조건', () => {
it('주소에는 걸린 칸만 싣는다 — from · to · subject · body · since · until · attachment=1', () => {
const value = conditions({
from: '이도윤',
body: '계약 갱신',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
})
expect(searchConditionsRouteQuery(value)).toEqual({
from: '이도윤',
body: '계약 갱신',
since: '2026-10-06',
until: '2026-11-06',
attachment: '1',
})
expect(searchConditionsRouteQuery(NO_SEARCH_CONDITIONS)).toEqual({})
expect(searchConditionsFromRoute(searchConditionsRouteQuery(value))).toEqual(value)
})
it('주소의 모르는 값 · 틀린 날짜는 없는 것으로 본다 — 같은 이름이 여럿이면 첫 것', () => {
expect(
searchConditionsFromRoute({
from: [' 이도윤 ', '무시'],
since: 'yesterday',
until: '2026-13-01',
attachment: 'yes',
}),
).toEqual(conditions({ from: '이도윤' }))
expect(searchConditionsFromRoute({ attachment: '1' }).hasAttachment).toBe(true)
})
it('손으로 고친 주소도 패널과 같은 검사를 거친다 — 거꾸로 된 기간 · 고를 수 없는 날짜는 버리고, 글 칸은 200자에서 자른다', () => {
expect(searchConditionsFromRoute({ since: '2026-11-06', until: '2026-10-01', subject: '견적' })).toEqual(
conditions({ subject: '견적' }),
)
expect(searchConditionsFromRoute({ since: '0999-01-01', until: '2026-11-01' })).toEqual(
conditions({ until: '2026-11-01' }),
)
expect(searchConditionsFromRoute({ body: ` ${'가'.repeat(201)}` }).body).toBe('가'.repeat(200))
// 낱말 수는 자르지 않는다 — 넘으면 목록이 찾지 않고 까닭을 보인다(exceedsCombinedSearchWords)
const many = searchConditionsFromRoute({ body: '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉' })
expect(many.body).toBe('하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉')
expect(exceedsCombinedSearchWords('', many)).toBe(true)
})
})
describe('API 쿼리 · 읽기 화면의 강조 쿼리', () => {
it('기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 브라우저 시간대에서 UTC 로 — 첨부는 hasAttachment=true', () => {
expect(
searchConditionsParams(
conditions({ to: 'ara.kim', subject: '견적', since: '2026-10-06', until: '2026-11-06', hasAttachment: true }),
),
).toEqual({
to: 'ara.kim',
subject: '견적',
after: localMidnightIso(2026, 10, 6),
before: localMidnightIso(2026, 11, 7),
hasAttachment: 'true',
})
// 시작만 · 끝만도 된다
expect(searchConditionsParams(conditions({ until: '2026-12-31' }))).toEqual({
before: localMidnightIso(2027, 1, 1),
})
expect(searchConditionsParams(undefined)).toEqual({})
})
it('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () => {
const value = conditions({ from: '이도윤', body: '계약', since: '2026-10-06', hasAttachment: true })
expect(messageHighlightQueryOf('갱신', value)).toEqual({ q: '갱신', from: '이도윤', body: '계약' })
expect(messageHighlightQueryOf('', conditions({ subject: '견적' }))).toEqual({ subject: '견적' })
expect(messageHighlightQueryOf('', conditions({ since: '2026-10-06', hasAttachment: true }))).toBeNull()
expect(messageHighlightKey(null)).toBe('')
expect(messageHighlightKey({ q: '계약' })).not.toBe(messageHighlightKey({ subject: '계약' }))
})
})
describe('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () => {
it('띄어쓰기로 나눈 조각을 칸마다 세어 더한다', () => {
const value = conditions({ from: '이도윤', to: '김아라 박지훈', subject: '"계약 갱신"', body: '단가' })
expect(combinedSearchWords('견적 회의', value)).toBe(8)
expect(exceedsCombinedSearchWords('견적 회의', value)).toBe(false)
expect(exceedsCombinedSearchWords('견적 회의 일정', value)).toBe(true)
// 날짜 · 첨부는 낱말이 아니다
expect(combinedSearchWords('', conditions({ since: '2026-10-06', hasAttachment: true }))).toBe(0)
})
})
describe('기간 고르기', () => {
const today = new Date(2026, 10, 6, 15, 30)
it('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () => {
expect(periodRange('week', today)).toEqual({ since: '2026-10-30', until: '2026-11-06' })
expect(periodRange('month', today)).toEqual({ since: '2026-10-06', until: '2026-11-06' })
expect(periodRange('quarter', today)).toEqual({ since: '2026-08-06', until: '2026-11-06' })
expect(periodRange('year', today)).toEqual({ since: '2025-11-06', until: '2026-11-06' })
expect(periodRange('all', today)).toEqual({ since: null, until: null })
expect(periodRange('custom', today)).toEqual({ since: null, until: null })
})
it('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () => {
expect(periodRange('month', new Date(2026, 2, 31)).since).toBe('2026-02-28')
expect(periodRange('month', new Date(2028, 2, 31)).since).toBe('2028-02-29')
expect(periodRange('quarter', new Date(2026, 4, 31)).since).toBe('2026-02-28')
expect(periodRange('week', new Date(2026, 0, 3)).since).toBe('2025-12-27')
})
it('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () => {
expect(periodOf(null, null, today)).toBe('all')
expect(periodOf('2026-10-06', '2026-11-06', today)).toBe('month')
expect(periodOf('2026-10-30', '2026-11-06', today)).toBe('week')
// 다음 날 보면 같은 날짜도 날짜 고르기다
expect(periodOf('2026-10-06', '2026-11-06', new Date(2026, 10, 7))).toBe('custom')
expect(periodOf('2026-10-01', null, today)).toBe('custom')
})
it('시작이 끝보다 늦으면 거꾸로다 — 한쪽만 있으면 아니다', () => {
expect(isReversedPeriod('2026-11-05', '2026-10-01')).toBe(true)
expect(isReversedPeriod('2026-10-01', '2026-10-01')).toBe(false)
expect(isReversedPeriod('2026-11-05', null)).toBe(false)
})
})
describe('조건 줄 글', () => {
const today = new Date(2026, 10, 6)
it('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () => {
expect(
searchConditionsSummary(
conditions({ from: '이도윤', since: '2026-10-06', until: '2026-11-06', hasAttachment: true }),
today,
),
).toBe('조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음')
})
it('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () => {
expect(
searchConditionsSummary(
conditions({ to: '김아라', subject: '견적', body: '계약서', since: '2026-10-01', until: '2026-11-05' }),
today,
),
).toBe('조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지')
expect(searchConditionsSummary(conditions({ since: '2025-12-01', until: '2026-01-05' }), today)).toBe(
'조건: 2025. 12. 1.부터 2026. 1. 5.까지',
)
expect(searchConditionsSummary(conditions({ since: '2026-10-01' }), today)).toBe('조건: 2026. 10. 1.부터')
expect(searchConditionsSummary(conditions({ until: '2026-10-31' }), today)).toBe('조건: 2026. 10. 31.까지')
expect(searchConditionsSummary(NO_SEARCH_CONDITIONS, today)).toBe('')
})
it('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () => {
expect(searchConditionsSummary(conditions({ since: '2026-11-06', until: '2026-11-06' }), today)).toBe(
'조건: 2026. 11. 6. 하루',
)
expect(searchConditionsSummary(conditions({ since: '2025-12-31', until: '2025-12-31' }), today)).toBe(
'조건: 2025. 12. 31. 하루',
)
})
it('보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다', () => {
const me = { name: '김아라', email: 'ara.kim@arasystem.test' }
expect(searchConditionsSummary(conditions({ from: '김아라', to: ' ARA.KIM@arasystem.test ' }), today, me)).toBe(
'조건: 내가 보낸 메일, 내가 받은 메일',
)
expect(searchConditionsSummary(conditions({ from: 'ara.kim@arasystem.test' }), today, me)).toBe('조건: 내가 보낸 메일')
expect(searchConditionsSummary(conditions({ from: '아라', to: 'ara.kim' }), today, me)).toBe(
'조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일',
)
// 누구인지 모르면 지금 글 그대로
expect(searchConditionsSummary(conditions({ from: '김아라' }), today)).toBe('조건: 김아라 님이 보낸 메일')
})
})
describe('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () => {
it('검색어나 내용 조건이 모두 한 글자면 그렇다', () => {
expect(skipsBodySearch('김', NO_SEARCH_CONDITIONS)).toBe(true)
expect(skipsBodySearch('', conditions({ body: '가' }))).toBe(true)
expect(skipsBodySearch('김', conditions({ from: '이' }))).toBe(true)
})
it('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () => {
expect(skipsBodySearch('김 계약', NO_SEARCH_CONDITIONS)).toBe(false)
expect(skipsBodySearch('김', conditions({ subject: '견적' }))).toBe(false)
// 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
expect(skipsBodySearch('', conditions({ from: '김' }))).toBe(false)
expect(skipsBodySearch('', conditions({ subject: '가', since: '2026-10-01' }))).toBe(false)
})
})
+336
View File
@@ -0,0 +1,336 @@
// 조건 검색 (14단계 — 계약 2절 "검색 — 14단계" · 디자인 README 「14단계 › 검색 · 조건 검색 · 강조」) — 순수 함수만 둔다.
// 조건 다듬기 · 화면 주소 ↔ 조건 · API 쿼리 · 읽기 화면의 강조 쿼리 · 합친 낱말 수 · 기간 고르기 · 조건 줄 글.
// 검색 결과의 출처(검색어 · 범위 · 조건)는 mail-list-source.ts 가 이것으로 만든다.
import type { LocationQuery, LocationQueryValue } from 'vue-router'
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS } from '@/shared/constants/mail'
import {
SEARCH_ATTACHMENT_ROUTE_VALUE,
SEARCH_CONDITION_LINE_TEXT,
SEARCH_DAY_MAX,
SEARCH_DAY_MIN,
SEARCH_HAS_ATTACHMENT_VALUE,
SEARCH_PERIOD_LABELS,
SEARCH_PERIOD_PRESETS,
SEARCH_ROUTE_PARAMS,
type SearchPeriod,
type SearchPeriodPreset,
} from '@/shared/constants/mail-search'
import type { MessageHighlightQuery, MessageListQuery } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { subjectParticle } from '@/shared/utils/korean'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { isSingleCharSearch, parseSearchTerms, SEARCH_WORD_SEPARATOR } from '@/shared/utils/search-match'
/** 검색 칸 밖의 조건 — 글 칸은 비면 없는 것, 기간은 브라우저 시간대의 날짜(YYYY-MM-DD — 두 날짜 모두 든다) */
export interface SearchConditions {
/** 보낸 사람(이름 · 주소) */
from: string
/** 받는 사람과 참조 */
to: string
subject: string
/** 본문 */
body: string
/** 시작 날짜 — 그날 0시부터. 없으면 null */
since: string | null
/** 끝 날짜 — 그날이 끝날 때까지(다음 날 0시 앞). 없으면 null */
until: string | null
hasAttachment: boolean
}
/** 글을 넣는 조건 칸 — q 와 같은 검사(200자 · 낱말 수)를 받는다 */
export const SEARCH_CONDITION_TEXT_FIELDS = ['from', 'to', 'subject', 'body'] as const
export type SearchConditionTextField = (typeof SEARCH_CONDITION_TEXT_FIELDS)[number]
export const NO_SEARCH_CONDITIONS: Readonly<SearchConditions> = Object.freeze({
from: '',
to: '',
subject: '',
body: '',
since: null,
until: null,
hasAttachment: false,
})
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
/** 날짜 칸의 값 — <input type="date"> 와 같은 모양 */
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
/**
* 검색 글 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다. 검색어 · 조건 칸이 함께 쓴다
*/
export function normalizeSearchText(text: string): string {
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
}
/** 낱말 수 — 띄어쓰기로 나눈 조각 (따옴표와 상관없이, 서버의 countSearchWords 와 같다) */
export function countSearchWords(text: string): number {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '').length
}
/**
* 찾을 수 있는 날짜(YYYY-MM-DD)인가 — 2월 30일처럼 넘어간 날짜 · 다른 모양 · SEARCH_DAY_MIN ~ SEARCH_DAY_MAX 밖은 아니다
* (9999-12-31 은 UTC 의 다음 날 0시가 10000년이라 서버가 받지 않는다 — 어느 시간대에서도 보낼 수 있는 범위만)
*/
export function isSearchDay(value: string): boolean {
return DAY_PATTERN.test(value) && value >= SEARCH_DAY_MIN && value <= SEARCH_DAY_MAX && startOfDayIso(value) !== null
}
function dayOrNull(value: string | null | undefined): string | null {
return typeof value === 'string' && isSearchDay(value) ? value : null
}
/**
* 조건 다듬기 — 글 칸은 검색어처럼, 날짜는 찾을 수 있는 날짜만(isSearchDay), 첨부는 true 만.
* 시작이 끝보다 늦은 기간(손으로 고친 주소)은 버린다 — 아무것도 맞지 않는 검색이 되지 않게 (패널은 보내기 전에 칸 아래에 알린다)
*/
export function normalizeSearchConditions(input: Partial<SearchConditions> | null | undefined): SearchConditions {
const since = dayOrNull(input?.since)
const until = dayOrNull(input?.until)
const reversed = since !== null && until !== null && since > until
return {
from: normalizeSearchText(input?.from ?? ''),
to: normalizeSearchText(input?.to ?? ''),
subject: normalizeSearchText(input?.subject ?? ''),
body: normalizeSearchText(input?.body ?? ''),
since: reversed ? null : since,
until: reversed ? null : until,
hasAttachment: input?.hasAttachment === true,
}
}
/** 조건이 하나라도 있는가 */
export function hasSearchConditions(conditions: SearchConditions | null | undefined): boolean {
if (!conditions) return false
return (
SEARCH_CONDITION_TEXT_FIELDS.some((field) => conditions[field] !== '') ||
conditions.since !== null ||
conditions.until !== null ||
conditions.hasAttachment
)
}
/** 조건의 열쇠 — 같은 조건이면 같다. 조건이 없으면 빈 글 (목록 키 · 같은 검색인지 · 읽기 화면을 다시 받을지) */
export function searchConditionsKey(conditions: SearchConditions | null | undefined): string {
if (!hasSearchConditions(conditions) || !conditions) return ''
return JSON.stringify([
conditions.from,
conditions.to,
conditions.subject,
conditions.body,
conditions.since,
conditions.until,
conditions.hasAttachment,
])
}
export function sameSearchConditions(
a: SearchConditions | null | undefined,
b: SearchConditions | null | undefined,
): boolean {
return searchConditionsKey(a) === searchConditionsKey(b)
}
/** 화면 주소의 조건 쿼리 — 빈 칸은 넣지 않는다 (/mail/search?q=…&in=…&from=…&since=2026-10-06&attachment=1) */
export function searchConditionsRouteQuery(conditions: SearchConditions | null | undefined): Record<string, string> {
const query: Record<string, string> = {}
if (!conditions) return query
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
if (conditions[field]) query[SEARCH_ROUTE_PARAMS[field]] = conditions[field]
}
if (conditions.since) query[SEARCH_ROUTE_PARAMS.since] = conditions.since
if (conditions.until) query[SEARCH_ROUTE_PARAMS.until] = conditions.until
if (conditions.hasAttachment) query[SEARCH_ROUTE_PARAMS.attachment] = SEARCH_ATTACHMENT_ROUTE_VALUE
return query
}
/** 주소 쿼리의 첫 값 — 같은 이름이 여럿이면 첫 것, 글이 아니면 빈 글 */
export function firstQueryValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
const first = Array.isArray(value) ? value[0] : value
return typeof first === 'string' ? first : ''
}
/** 주소 쿼리 → 조건 (모르는 값 · 틀린 날짜는 없는 것으로) */
export function searchConditionsFromRoute(query: LocationQuery): SearchConditions {
return normalizeSearchConditions({
from: firstQueryValue(query[SEARCH_ROUTE_PARAMS.from]),
to: firstQueryValue(query[SEARCH_ROUTE_PARAMS.to]),
subject: firstQueryValue(query[SEARCH_ROUTE_PARAMS.subject]),
body: firstQueryValue(query[SEARCH_ROUTE_PARAMS.body]),
since: firstQueryValue(query[SEARCH_ROUTE_PARAMS.since]) || null,
until: firstQueryValue(query[SEARCH_ROUTE_PARAMS.until]) || null,
hasAttachment: firstQueryValue(query[SEARCH_ROUTE_PARAMS.attachment]) === SEARCH_ATTACHMENT_ROUTE_VALUE,
})
}
/** API 쿼리의 조건 — 날짜는 브라우저 시간대의 시작 날짜 0시 · 끝 날짜 다음 날 0시를 UTC 로 (부재중과 같은 방식 — mail-vacation.ts) */
export type SearchConditionParams = Pick<
MessageListQuery,
'from' | 'to' | 'subject' | 'body' | 'after' | 'before' | 'hasAttachment'
>
export function searchConditionsParams(conditions: SearchConditions | null | undefined): SearchConditionParams {
const params: SearchConditionParams = {}
if (!conditions) return params
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
if (conditions[field]) params[field] = conditions[field]
}
const after = conditions.since ? startOfDayIso(conditions.since) : null
const before = conditions.until ? endOfDayIso(conditions.until) : null
if (after) params.after = after
if (before) params.before = before
if (conditions.hasAttachment) params.hasAttachment = SEARCH_HAS_ATTACHMENT_VALUE
return params
}
/**
* 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 조건 넷만(기간 · 첨부는 서버가 받지 않는다). 빈 칸은 빼고, 하나도 없으면 null
*/
export function messageHighlightQueryOf(
q: string,
conditions: SearchConditions | null | undefined,
): MessageHighlightQuery | null {
const query: MessageHighlightQuery = {}
if (q) query.q = q
for (const field of SEARCH_CONDITION_TEXT_FIELDS) {
const value = conditions?.[field] ?? ''
if (value) query[field] = value
}
return Object.keys(query).length > 0 ? query : null
}
/** 강조 쿼리의 열쇠 — 같은 쿼리로 받은 본문인지 본다 (없으면 빈 글) */
export function messageHighlightKey(query: MessageHighlightQuery | null | undefined): string {
if (!query) return ''
return JSON.stringify([query.q ?? '', query.from ?? '', query.to ?? '', query.subject ?? '', query.body ?? ''])
}
/** 검색어와 조건 넷을 합친 낱말 수 — 서버 한도(8개)와 같은 기준 */
export function combinedSearchWords(q: string, conditions: SearchConditions | null | undefined): number {
let total = countSearchWords(q)
for (const field of SEARCH_CONDITION_TEXT_FIELDS) total += countSearchWords(conditions?.[field] ?? '')
return total
}
export function exceedsCombinedSearchWords(q: string, conditions: SearchConditions | null | undefined): boolean {
return combinedSearchWords(q, conditions) > MAIL_SEARCH_MAX_WORDS
}
/**
* 한 글자 낱말뿐이라 본문을 찾지 않은 검색인가 (색인 방식 — 서버와 같은 판정, search-match.ts 의 isSingleCharSearch).
* 본문을 찾을 말(검색어나 내용 조건)이 있을 때만 그렇다 — 보낸 사람 · 받는 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
*/
export function skipsBodySearch(q: string, conditions: SearchConditions | null | undefined): boolean {
const body = conditions?.body ?? ''
if (parseSearchTerms(q).length === 0 && parseSearchTerms(body).length === 0) return false
return isSingleCharSearch([q, conditions?.from ?? '', conditions?.to ?? '', conditions?.subject ?? '', body])
}
// ── 기간 고르기 ─────────────────────────────────────────────
/** 오늘에서 그만큼 앞의 같은 날 — 달은 그 달의 마지막 날을 넘지 않는다(3월 31일의 1달 앞은 2월 28 · 29일) */
function shiftBack(today: Date, preset: SearchPeriodPreset): Date {
const year = today.getFullYear()
const month = today.getMonth() - (preset.months ?? 0)
const lastDay = new Date(year, month + 1, 0).getDate()
const day = Math.min(today.getDate(), lastDay) - (preset.days ?? 0)
return new Date(year, month, day)
}
/** 미리 정한 기간의 두 날짜 — 그만큼 앞의 같은 날부터 오늘까지. 전체 기간 · 날짜 고르기는 날짜가 없다 */
export function periodRange(period: SearchPeriod, today: Date): { since: string | null; until: string | null } {
const preset = SEARCH_PERIOD_PRESETS.find((candidate) => candidate.id === period)
if (!preset) return { since: null, until: null }
return { since: formatDate(shiftBack(today, preset)), until: formatDate(today) }
}
/** 두 날짜가 어떤 기간 고르기인가 — 둘 다 없으면 전체 기간, 오늘 기준의 미리 정한 기간과 같으면 그것, 아니면 날짜 고르기 */
export function periodOf(since: string | null, until: string | null, today: Date): SearchPeriod {
if (since === null && until === null) return 'all'
const preset = SEARCH_PERIOD_PRESETS.find((candidate) => {
const range = periodRange(candidate.id, today)
return range.since === since && range.until === until
})
return preset?.id ?? 'custom'
}
/** 시작 날짜가 끝 날짜보다 늦은가 — 그러면 아무것도 맞지 않는다 (찾기 전에 막는다) */
export function isReversedPeriod(since: string | null, until: string | null): boolean {
return since !== null && until !== null && since > until
}
// ── 조건 줄 글 ───────────────────────────────────────────────
/** "2026. 10. 1." — withYear 가 아니면 "10. 1." */
function periodDayText(day: string, withYear: boolean): string {
const match = DAY_PATTERN.exec(day)
if (!match) return day
const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]
return withYear ? `${year}. ${month}. ${date}.` : `${month}. ${date}.`
}
function periodText(conditions: SearchConditions, today: Date): string | null {
const { since, until } = conditions
const period = periodOf(since, until, today)
if (period === 'all') return null
if (period !== 'custom') return SEARCH_PERIOD_LABELS[period]
if (since !== null && until !== null) {
// 하루만 고른 기간 — "2026. 11. 6. 하루" (같은 날을 부터 · 까지로 두 번 쓰지 않는다)
if (since === until) return SEARCH_CONDITION_LINE_TEXT.oneDay(periodDayText(since, true))
const sameYear = since.slice(0, 4) === until.slice(0, 4)
return SEARCH_CONDITION_LINE_TEXT.range(periodDayText(since, true), periodDayText(until, !sameYear))
}
if (since !== null) return SEARCH_CONDITION_LINE_TEXT.since(periodDayText(since, true))
return until !== null ? SEARCH_CONDITION_LINE_TEXT.until(periodDayText(until, true)) : null
}
/** 조건 줄의 "나" — 보낸 사람 · 받는 사람 칸의 값이 이 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" */
export interface SearchConditionsMe {
name?: string | null
email?: string | null
}
/** 견줄 모양 — 앞뒤 공백을 빼고 NFC · 소문자 (영문 이름 · 주소는 대소문자를 가리지 않는다) */
function comparableName(value: string | null | undefined): string {
return (value ?? '').trim().normalize('NFC').toLowerCase()
}
/** 칸의 값이 나인가 — 내 이름이나 내 주소와 같을 때만(일부만 같으면 아니다) */
function isMeValue(value: string, me: SearchConditionsMe | null | undefined): boolean {
if (!me) return false
const text = comparableName(value)
if (text === '') return false
return text === comparableName(me.email) || text === comparableName(me.name)
}
/**
* 결과 머리 아래 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다).
* 기간은 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(하루면 "… 하루"). 보낸 사람 · 받는 사람이 나면 "내가 보낸 메일" · "내가 받은 메일".
* 조건이 없으면 빈 글
*/
export function searchConditionsSummary(
conditions: SearchConditions | null | undefined,
today: Date,
me: SearchConditionsMe | null = null,
): string {
if (!conditions || !hasSearchConditions(conditions)) return ''
const parts: string[] = []
if (conditions.from) {
parts.push(isMeValue(conditions.from, me) ? SEARCH_CONDITION_LINE_TEXT.fromMe : SEARCH_CONDITION_LINE_TEXT.from(conditions.from))
}
if (conditions.to) {
parts.push(isMeValue(conditions.to, me) ? SEARCH_CONDITION_LINE_TEXT.toMe : SEARCH_CONDITION_LINE_TEXT.to(conditions.to))
}
if (conditions.subject) {
parts.push(SEARCH_CONDITION_LINE_TEXT.subject(conditions.subject, subjectParticle(conditions.subject)))
}
if (conditions.body) parts.push(SEARCH_CONDITION_LINE_TEXT.body(conditions.body, subjectParticle(conditions.body)))
const period = periodText(conditions, today)
if (period) parts.push(period)
if (conditions.hasAttachment) parts.push(SEARCH_CONDITION_LINE_TEXT.attachment)
return `${SEARCH_CONDITION_LINE_TEXT.prefix}${parts.join(SEARCH_CONDITION_LINE_TEXT.separator)}`
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { highlightSegments } from './search-highlight'
// 검색 강조 조각 (14단계 — 서버가 준 [시작, 끝) UTF-16 위치, NFC 글 기준)
const marked = (text: string, ranges: readonly (readonly [number, number])[] | null) =>
highlightSegments(text, ranges)
.map((segment) => (segment.mark ? `[${segment.text}]` : segment.text))
.join('')
describe('highlightSegments', () => {
it('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () => {
expect(highlightSegments('계약서 초안', null)).toEqual([{ text: '계약서 초안', mark: false }])
expect(highlightSegments('계약서 초안', [])).toEqual([{ text: '계약서 초안', mark: false }])
expect(highlightSegments('', null)).toEqual([])
})
it('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () => {
expect(marked('Re: 유지보수 계약 갱신 건', [[12, 14], [4, 8]])).toBe('Re: [유지보수] 계약 [갱신] 건')
expect(marked('계약서', [[0, 2]])).toBe('[계약]서')
expect(marked('견적서와 계약', [[5, 7]])).toBe('견적서와 [계약]')
expect(marked('계약서', [[0, 3]])).toBe('[계약서]')
})
it('겹치거나 맞닿은 범위는 합친다', () => {
expect(marked('aaaa', [[0, 2], [1, 3]])).toBe('[aaa]a')
expect(marked('계약서', [[0, 2], [2, 3]])).toBe('[계약서]')
})
it('글 밖 · 거꾸로 · 정수가 아닌 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () => {
expect(marked('계약', [[5, 7]])).toBe('계약')
expect(marked('계약', [[1, 1], [2, 1], [-1, 1], [0.5, 1]])).toBe('계약')
expect(marked('계약', [[1, 10]])).toBe('계[약]')
})
it('맞은 곳이 있으면 NFC 로 맞춘 글을 자른다 — 다른 정규형(NFD)의 같은 글이어도 위치가 맞다', () => {
const nfd = '계약서'.normalize('NFD')
expect(nfd.length).toBeGreaterThan(3)
expect(marked(nfd, [[0, 2]])).toBe('[계약]서')
// 맞은 곳이 없으면 글 그대로
expect(highlightSegments(nfd, null)[0]?.text).toBe(nfd)
})
it('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () => {
expect(marked('😀계약', [[2, 4]])).toBe('😀[계약]')
})
it('위치가 서로게이트 쌍(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () => {
// 😀 는 UTF-16 두 칸(0 · 1)
expect(marked('😀계약', [[1, 4]])).toBe('[😀계약]')
expect(marked('😀계약', [[0, 1]])).toBe('[😀]계약')
expect(marked('계😀약', [[2, 3]])).toBe('계[😀]약')
expect(marked('계😀약', [[0, 2]])).toBe('[계😀]약')
// 가른 자리를 넓힌 뒤 맞닿으면 합친다
expect(marked('😀😀', [[0, 1], [3, 4]])).toBe('[😀😀]')
// 반쪽 글자(짝 없는 서로게이트)가 있으면 encodeURIComponent 가 URIError 를 던진다
const segments = highlightSegments('a😀b😀c', [[2, 5]])
expect(segments.map((segment) => segment.text)).toEqual(['a', '😀b😀', 'c'])
for (const segment of segments) expect(() => encodeURIComponent(segment.text)).not.toThrow()
})
})
@@ -0,0 +1,60 @@
// 검색 강조 — 서버가 준 맞은 곳(위치)으로 글을 조각낸다 (순수 함수만 둔다 — 계약 2절 "검색 — 14단계").
// 화면은 조각을 v-html 없이 <mark> 로 그린다(HighlightedText.vue). 본문 틀(iframe)의 글 본문도 같은 조각을 이스케이프해 넣는다(mail-html.ts).
import type { SearchRange } from '@/shared/types/mail'
/** 그릴 조각 — mark 면 맞은 말 */
export interface HighlightSegment {
text: string
mark: boolean
}
/** 그 자리가 서로게이트 쌍(이모지 등 BMP 밖 글자)의 가운데다 */
function splitsPair(text: string, at: number): boolean {
if (at <= 0 || at >= text.length) return false
const before = text.charCodeAt(at - 1)
const after = text.charCodeAt(at)
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
}
/**
* 쓸 수 있는 위치만 — 정수 · 0 이상 · 시작 < 끝, 끝은 글 길이에서 자른다. 시작 차례로 놓고 겹치거나 맞닿은 것은 합친다.
* 위치가 서로게이트 쌍을 가르면 그 글자를 통째로 넣는다 — 반쪽 글자가 깨져 보이지 않게
*/
function usableRanges(ranges: readonly SearchRange[], text: string): [number, number][] {
const length = text.length
const valid: [number, number][] = []
for (const [start, end] of ranges) {
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || start >= length || end <= start) continue
const from = splitsPair(text, start) ? start - 1 : start
const to = Math.min(end, length)
valid.push([from, splitsPair(text, to) ? to + 1 : to])
}
valid.sort((a, b) => a[0] - b[0] || a[1] - b[1])
const merged: [number, number][] = []
for (const range of valid) {
const last = merged[merged.length - 1]
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1])
else merged.push(range)
}
return merged
}
/**
* 글 → 조각 — 맞은 곳이 없으면 글 한 조각(빈 글이면 조각 없음).
* 서버는 NFC 로 맞춘 글의 위치를 준다 — 맞은 곳이 있으면 글을 NFC 로 맞춘 뒤 자른다(목록 맞추기가 다른 정규형의 같은 글로 덮어도 어긋나지 않게).
* 글 밖 · 거꾸로 된 위치는 버린다
*/
export function highlightSegments(text: string, ranges: readonly SearchRange[] | null | undefined): HighlightSegment[] {
if (!ranges || ranges.length === 0) return text ? [{ text, mark: false }] : []
const source = text.normalize('NFC')
const segments: HighlightSegment[] = []
let cursor = 0
for (const [start, end] of usableRanges(ranges, source)) {
if (start > cursor) segments.push({ text: source.slice(cursor, start), mark: false })
segments.push({ text: source.slice(start, end), mark: true })
cursor = end
}
if (cursor < source.length) segments.push({ text: source.slice(cursor), mark: false })
return segments
}
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { FOLD_SAMPLES, GRAM_SAMPLES, MATCH_SAMPLES, SUBJECT_BASE_SAMPLES } from '@/test-utils/search-text-samples'
import {
MAX_MATCH_RANGES,
foldText,
gramsOf,
isSingleCharSearch,
isSpaceCode,
matchRanges,
parseSearchTerms,
subjectBase,
} from './search-match'
// 서버와 같은 표본 표(backend test/search-text-samples.ts — 행과 순서까지 그대로)로 본다 — 계약 2절 "검색 — 14단계"
describe('foldText (검색의 접기 — 짝: backend search-text.ts)', () => {
it.each(FOLD_SAMPLES)('%j → %j', (input, folded) => {
expect(foldText(input)).toBe(folded)
})
it('접은 글은 NFC 원문과 UTF-16 길이가 늘 같다 — 서버가 준 위치를 원문에 그대로 쓴다', () => {
for (const [input] of FOLD_SAMPLES) {
expect(foldText(input).length).toBe(input.normalize('NFC').length)
}
const changedLength: number[] = []
for (let code = 0; code <= 0xffff; code += 1) {
if (code >= 0xd800 && code <= 0xdfff) continue
const char = String.fromCharCode(code).normalize('NFC')
if (foldText(char).length !== char.length) changedLength.push(code)
}
expect(changedLength).toEqual([])
})
it('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () => {
for (const [input, folded] of FOLD_SAMPLES) {
expect(foldText(folded)).toBe(folded)
expect(foldText(foldText(input))).toBe(folded)
}
expect(foldText('a\ud800B')).toBe('a\ud800b')
expect(foldText('\udc00A')).toBe('\udc00a')
})
})
describe('gramsOf (글자 두 개 묶음)', () => {
it.each(GRAM_SAMPLES)('%j → %j', (fold, grams) => {
expect([...gramsOf(fold)]).toEqual(grams)
})
it('공백은 정규식 \\s 와 같은 글자다 (낱말 나누기와 같다)', () => {
const differ: number[] = []
for (let code = 0; code <= 0xffff; code += 1) {
if (isSpaceCode(code) !== /\s/u.test(String.fromCharCode(code))) differ.push(code)
}
expect(differ).toEqual([])
})
})
describe('matchRanges (맞은 곳)', () => {
it.each(MATCH_SAMPLES)('%j 에서 %j → %j', (original, terms, ranges) => {
expect(matchRanges(original, terms)).toEqual(ranges)
})
it('맞은 곳은 limit 개까지만 모은다', () => {
expect(matchRanges('가'.repeat(5_000), ['가'])).toEqual([[0, MAX_MATCH_RANGES]])
expect(matchRanges('가 '.repeat(10), ['가'], 3)).toEqual([
[0, 1],
[2, 3],
[4, 5],
])
})
})
describe('subjectBase (말머리를 뗀 제목 — 짝: backend subject-base.ts)', () => {
it.each(SUBJECT_BASE_SAMPLES)('%j → %j', (subject, base) => {
expect(subjectBase(subject)).toBe(base)
})
})
describe('parseSearchTerms (검색어 나누기 — 짝: backend search-query.ts)', () => {
it('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () => {
expect(parseSearchTerms(` 서버 이전\u3000일정\n공유 `)).toEqual([
{ kind: 'word', text: '서버' },
{ kind: 'word', text: '이전' },
{ kind: 'word', text: '일정' },
{ kind: 'word', text: '공유' },
])
})
it('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () => {
expect(parseSearchTerms('회의 "일정 공유" 서버')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'phrase', text: '일정 공유' },
{ kind: 'word', text: '서버' },
])
expect(parseSearchTerms('회의 "일정 공유').map((term) => term.text)).toEqual(['회의', '일정', '공유'])
expect(parseSearchTerms('"a b" "c')).toEqual([
{ kind: 'phrase', text: 'a b' },
{ kind: 'word', text: 'c' },
])
})
it('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () => {
for (const query of ['-', '…', '- … !!', '"- …"', '😀', '" "', '""']) {
expect(parseSearchTerms(query)).toEqual([])
}
expect(parseSearchTerms('회의, (회의) 회의. “회의” [공지] 2026. 회의!').map((term) => term.text)).toEqual([
'회의',
'공지',
'2026',
])
expect(parseSearchTerms('"회의, 일정."')).toEqual([{ kind: 'phrase', text: '회의, 일정.' }])
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정')).toEqual([
{ kind: 'word', text: '회의' },
{ kind: 'phrase', text: '일정' },
{ kind: 'word', text: '일정' },
])
})
it('NFC 로 모으고 NUL 은 U+FFFD 로 바꾼다 (서버의 색인과 같다)', () => {
expect(parseSearchTerms('\u1112\u1161\u11ab\u1100\u1173\u11af')).toEqual([{ kind: 'word', text: '한글' }])
expect(parseSearchTerms(`계\u0000약`)).toEqual([{ kind: 'word', text: '계\ufffd약' }])
})
})
describe('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () => {
it('낱말이 모두 한 글자면 그렇다 — 검색어 · 조건 칸을 모두 합쳐 본다', () => {
expect(isSingleCharSearch(['김'])).toBe(true)
expect(isSingleCharSearch(['김 a 3'])).toBe(true)
expect(isSingleCharSearch(['김', '', '', '', '박'])).toBe(true)
// 한 글자 낱말로만 된 구절도 묶음이 없다
expect(isSingleCharSearch(['"가 나"'])).toBe(true)
})
it('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () => {
expect(isSingleCharSearch(['계약'])).toBe(false)
expect(isSingleCharSearch(['김 계약'])).toBe(false)
expect(isSingleCharSearch(['김', '', '', '견적', ''])).toBe(false)
expect(isSingleCharSearch(['"가나"'])).toBe(false)
})
it('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다', () => {
expect(isSingleCharSearch([])).toBe(false)
expect(isSingleCharSearch(['', ' '])).toBe(false)
expect(isSingleCharSearch(['- …'])).toBe(false)
// 앞뒤 문장부호를 떼면 한 글자다 — 서버와 같게 뗀 뒤 센다
expect(isSingleCharSearch(['(김)'])).toBe(true)
})
})
+328
View File
@@ -0,0 +1,328 @@
// 메일 검색의 낱말 나누기 · 접기(정규화) · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 (순수 함수만 둔다 — 계약 2절 "검색 — 14단계")
//
// ⚠️ backend 와 **짝**이다 — `src/shared/utils/search-query.ts`(낱말 나누기) · `search-text.ts`(접기 · 묶음 · 맞은 곳) ·
// `subject-base.ts`(말머리 떼기). 같은 표본 표(frontend `src/test-utils/search-text-samples.ts` · backend `test/search-text-samples.ts` —
// 행과 순서까지 그대로)를 두 쪽 테스트에 둔다. 한쪽을 고치면 둘 다 고친다.
// 화면은 이것으로 "한 글자 낱말뿐이라 본문을 찾지 않은 검색"을 알아본다(isSingleCharSearch — 결과 머리의 한 줄).
// 서버는 그 사실을 응답에 싣지 않는다 — 서버와 같은 낱말 나누기 · 묶음으로 같은 답을 낸다. 강조할 곳은 서버가 준다(searchHighlight).
//
// 접기(foldText)는 먼저 NFC 로 맞춘 뒤 **코드 포인트 하나씩** 바꾼다 — 전각 ASCII(! ~ ~) → 반각, 소문자(결과가 코드 포인트 하나이고
// UTF-16 길이가 같을 때만 — 'İ' 처럼 늘어나면 그대로), 공백류(줄바꿈 · 탭 · NBSP · U+3000 등 정규식 `\s` 의 글자) → 띄어쓰기(U+0020) 하나.
// 그래서 NFC 원문과 접은 글은 코드 포인트 · UTF-16 위치가 같다. NFKC 는 쓰지 않는다(㈜ → (주) 처럼 길이를 바꿔 위치를 잃는다).
// 모든 함수는 입력을 한 번(맞은 곳은 낱말마다 한 번) 훑는다 — 길이에 비례한다.
// ── 낱말 나누기 (짝: backend search-query.ts) ─────────────────────────────
/** 검색어의 낱말을 나누는 기준 — 공백 문자(전각 공백 · 줄바꿈 포함). 낱말 수 · 서버의 엔진 조건 · 색인 찾기가 같은 기준이다 */
export const SEARCH_WORD_SEPARATOR = /\s+/u
/** 큰따옴표 — 감싼 부분은 구절로 찾는다 */
const QUOTE = '"'
/** NUL(U+0000) — 서버는 색인과 같게 U+FFFD 로 1:1 바꿔 찾는다 */
const NUL = String.fromCharCode(0)
const REPLACEMENT_CHARACTER = String.fromCharCode(0xfffd)
/** 찾을 수 있는 글자 — 문자(\p{L})나 숫자(\p{N}). 하나도 없는 낱말 · 구절(`-` · `…` · 그림 문자뿐)은 버린다 */
const SEARCHABLE_CHARACTER = /[\p{L}\p{N}]/u
/**
* 낱말 앞뒤에 붙은, 문자 · 숫자가 아닌 글자 — 떼고 가운데 낱말로 찾는다('회의,' · '(회의)' → '회의').
* 뒤쪽은 "문자 · 숫자 · 결합 문자가 아닌 글자 + 그 뒤의 결합 문자"를 뗀다 — 글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다
*/
const WORD_EDGE_PUNCTUATION = /^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$/gu
/** 검색어를 나눈 조각 — 낱말 · 구절(큰따옴표 안) */
export interface SearchTerm {
readonly kind: 'word' | 'phrase'
readonly text: string
}
function coreWord(word: string): string {
return word.replace(WORD_EDGE_PUNCTUATION, '')
}
function splitWords(text: string): string[] {
return text.split(SEARCH_WORD_SEPARATOR).filter((word) => word !== '')
}
/**
* 검색어를 낱말 · 구절로 나눈다 — 나온 순서대로, 같은 낱말 · 같은 구절은 한 번만 (서버와 같다).
* 큰따옴표 한 쌍 안은 구절(안의 여러 칸 공백은 한 칸으로), 짝이 없는 마지막 큰따옴표는 없는 것으로 본다.
* 낱말 앞뒤의 문장부호는 떼고, 문자 · 숫자가 하나도 없는 낱말 · 구절은 버린다. NFC 로 맞추고 NUL 은 U+FFFD 로 바꾼다
*/
export function parseSearchTerms(query: string): SearchTerm[] {
const segments = query.normalize('NFC').replaceAll(NUL, REPLACEMENT_CHARACTER).split(QUOTE)
const terms: SearchTerm[] = []
const seen = new Set<string>()
const add = (kind: SearchTerm['kind'], text: string) => {
const key = `${kind}\n${text}`
if (!SEARCHABLE_CHARACTER.test(text) || seen.has(key)) return
seen.add(key)
terms.push({ kind, text })
}
segments.forEach((segment, index) => {
// 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만 (마지막 조각이면 짝이 없다)
const isPhrase = index % 2 === 1 && index < segments.length - 1
if (isPhrase) {
add('phrase', splitWords(segment).join(' '))
return
}
for (const word of splitWords(segment)) add('word', coreWord(word))
})
return terms
}
// ── 접기 · 묶음 · 맞은 곳 (짝: backend search-text.ts) ────────────────────
/** 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) */
const FULLWIDTH_FIRST = 0xff01
const FULLWIDTH_LAST = 0xff5e
const FULLWIDTH_OFFSET = 0xfee0
const ASCII_UPPER_A = 0x41
const ASCII_UPPER_Z = 0x5a
const ASCII_CASE_OFFSET = 0x20
const ASCII_LIMIT = 0x80
/** 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호 (toLowerCase 를 부르지 않고 건너뛴다) */
const HANGUL_JAMO_FIRST = 0x1100
const HANGUL_JAMO_LAST = 0x11ff
const CJK_FIRST = 0x2e80
const CJK_LAST = 0x9fff
const HANGUL_SYLLABLE_FIRST = 0xac00
const HANGUL_SYLLABLE_LAST = 0xd7a3
const HIGH_SURROGATE_FIRST = 0xd800
const HIGH_SURROGATE_LAST = 0xdbff
const LOW_SURROGATE_FIRST = 0xdc00
const LOW_SURROGATE_LAST = 0xdfff
const BMP_LAST = 0xffff
/** 맞은 곳을 이만큼까지만 모은다 — 글이 길고 낱말이 흔하면(한 글자) 끝없이 늘지 않게 */
export const MAX_MATCH_RANGES = 500
/** 맞은 곳 — [시작, 끝) UTF-16 위치 */
export type MatchRange = readonly [start: number, end: number]
/** 공백류를 바꾼 글자 — 띄어쓰기 하나 */
const SPACE = ' '
const SPACE_CODE = 0x20
function isHighSurrogate(code: number): boolean {
return code >= HIGH_SURROGATE_FIRST && code <= HIGH_SURROGATE_LAST
}
function isLowSurrogate(code: number): boolean {
return code >= LOW_SURROGATE_FIRST && code <= LOW_SURROGATE_LAST
}
/** 소문자가 따로 없는 글자인가 (빠른 길 — 한글 · 한자) */
function hasNoCase(code: number): boolean {
return (
(code >= HANGUL_SYLLABLE_FIRST && code <= HANGUL_SYLLABLE_LAST) ||
(code >= CJK_FIRST && code <= CJK_LAST) ||
(code >= HANGUL_JAMO_FIRST && code <= HANGUL_JAMO_LAST)
)
}
/** ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 */
function foldAsciiCode(code: number): number {
return code >= ASCII_UPPER_A && code <= ASCII_UPPER_Z ? code + ASCII_CASE_OFFSET : code
}
/** ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null. `char` 는 그 코드 포인트의 글(UTF-16 한두 단위) */
function foldCodePoint(code: number, char: string): string | null {
if (code >= FULLWIDTH_FIRST && code <= FULLWIDTH_LAST) {
return String.fromCharCode(foldAsciiCode(code - FULLWIDTH_OFFSET))
}
if (hasNoCase(code)) return null
const lower = char.toLowerCase()
if (lower === char || lower.length !== char.length) return null
// 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만
const first = lower.codePointAt(0) ?? 0
const firstWidth = first > BMP_LAST ? 2 : 1
return firstWidth === lower.length ? lower : null
}
/**
* 공백 글자인가 — 낱말 나누기 · 묶음의 경계 (JS 정규식 `\s` 와 같은 글자들: 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등)
*/
export function isSpaceCode(code: number): boolean {
if (code <= 0x20) {
return code === 0x20 || (code >= 0x09 && code <= 0x0d)
}
if (code < 0xa0) return false
return (
code === 0xa0 ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) ||
code === 0x2028 ||
code === 0x2029 ||
code === 0x202f ||
code === 0x205f ||
code === 0x3000 ||
code === 0xfeff
)
}
/**
* 찾기 · 색인의 접기 글 — NFC 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기.
* 결과는 `text.normalize('NFC')` 와 UTF-16 길이 · 위치가 같다
*/
export function foldText(text: string): string {
const source = text.normalize('NFC')
let folded = ''
let runStart = 0
for (let index = 0; index < source.length; ) {
const code = source.charCodeAt(index)
let width = 1
let point = code
if (isHighSurrogate(code) && index + 1 < source.length && isLowSurrogate(source.charCodeAt(index + 1))) {
width = 2
point = source.codePointAt(index) ?? code
}
let replacement: string | null
if (isSpaceCode(code)) {
// 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1
replacement = code === SPACE_CODE ? null : SPACE
} else if (code < ASCII_LIMIT) {
const lowered = foldAsciiCode(code)
replacement = lowered === code ? null : String.fromCharCode(lowered)
} else {
replacement = foldCodePoint(point, source.slice(index, index + width))
}
if (replacement !== null) {
folded += source.slice(runStart, index) + replacement
runStart = index + width
}
index += width
}
return runStart === 0 ? source : folded + source.slice(runStart)
}
/**
* 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번).
* 접은 글(foldText)에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다
*/
export function gramsOf(fold: string, into: Set<string> = new Set()): Set<string> {
let previous: string | null = null
for (let index = 0; index < fold.length; ) {
const code = fold.charCodeAt(index)
const width = isHighSurrogate(code) && index + 1 < fold.length && isLowSurrogate(fold.charCodeAt(index + 1)) ? 2 : 1
if (isSpaceCode(code)) {
previous = null
} else {
const char = width === 1 ? fold.charAt(index) : fold.slice(index, index + 2)
if (previous !== null) into.add(previous + char)
previous = char
}
index += width
}
return into
}
/** 맞은 곳들을 시작 차례로 놓고 겹치거나 맞닿은 것을 합친다 */
function mergeRanges(ranges: MatchRange[]): MatchRange[] {
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1])
const merged: [number, number][] = []
for (const [start, end] of ranges) {
const last = merged[merged.length - 1]
if (last && start <= last[1]) {
if (end > last[1]) last[1] = end
} else {
merged.push([start, end])
}
}
return merged
}
/** 찾는 말들을 접는다 — 같은 말은 한 번, 빈 말은 뺀다 */
export function foldTerms(terms: readonly string[]): string[] {
return [...new Set(terms.map(foldText))].filter((term) => term !== '')
}
/** matchRanges 의 접은 글 판 — `haystack` 은 foldText 한 글, `terms` 는 foldTerms 한 말 */
export function matchFoldedRanges(
haystack: string,
terms: readonly string[],
limit: number = MAX_MATCH_RANGES,
): MatchRange[] {
const found: MatchRange[] = []
for (const term of terms) {
if (term === '') continue
let count = 0
for (let at = haystack.indexOf(term); at >= 0 && count < limit; at = haystack.indexOf(term, at + 1)) {
found.push([at, at + term.length])
count += 1
}
}
return mergeRanges(found).slice(0, limit)
}
/**
* 맞은 곳 — 원문(`original` — NFC 여야 위치가 맞는다)에서 찾는 말들(`terms` — 접기 전 글)이 나오는 자리를 모두 찾아
* 겹치거나 맞닿은 것을 합친 [시작, 끝) 목록(UTF-16, 시작 차례). 같은 접기 · 부분 일치다('aa' 는 'aaa' 의 [0, 3))
*/
export function matchRanges(original: string, terms: readonly string[], limit: number = MAX_MATCH_RANGES): MatchRange[] {
return matchFoldedRanges(foldText(original), foldTerms(terms), limit)
}
// ── 말머리 떼기 (짝: backend subject-base.ts) ────────────────────────────
/** 앞의 말머리 하나 — 답장 · 전달 말머리 또는 대괄호 꼬리표, 뒤의 공백까지 (접은 글 — 소문자 · 반각) */
const LEADING_PREFIX = /(?:(?:re|fwd|fw|aw|wg|회신|답장|전달) ?(?:\[\d{1,4}\]|\(\d{1,4}\))? ?:|\[[^\]]*\]) ?/uy
/** 공백 글자의 이어짐을 띄어쓰기 하나로 · 앞뒤 공백 빼기 */
function collapseSpaces(text: string): string {
let collapsed = ''
let pendingSpace = false
for (let index = 0; index < text.length; index += 1) {
if (isSpaceCode(text.charCodeAt(index))) {
pendingSpace = collapsed !== ''
continue
}
if (pendingSpace) collapsed += ' '
pendingSpace = false
collapsed += text.charAt(index)
}
return collapsed
}
/**
* 말머리를 뗀 제목의 접기 글 — 같은 대화인지 볼 때 제목을 견주는 글이다(대화 묶기 — 서버가 묶는다).
* 접기 → 공백을 하나로 · 앞뒤 공백 빼기 → 앞의 말머리(Re · Fw · Fwd · AW · WG · 회신 · 답장 · 전달 · 대괄호 꼬리표)를 여러 겹 뗀다
*/
export function subjectBase(subject: string): string {
const text = collapseSpaces(foldText(subject))
// 붙은 자리(sticky)에서만 맞춘다 — 뗄 때마다 글을 자르지 않고 자리만 옮긴다
let position = 0
for (;;) {
LEADING_PREFIX.lastIndex = position
const prefix = LEADING_PREFIX.exec(text)
if (!prefix || prefix[0] === '') break
position += prefix[0].length
}
return text.slice(position).trim()
}
// ── 화면이 쓰는 판정 ───────────────────────────────────────────────────
/**
* 한 글자 낱말뿐인 검색인가 — 검색어 · 조건 칸의 낱말 · 구절을 모두 접어도 글자 두 개 묶음이 하나도 없으면 그렇다(서버의 search-plan 과 같은 판정).
* 그때 서버(색인 방식)는 본문을 찾지 않고 제목 · 사람 · 첨부 이름에서만 찾는다 (계약 2절 "검색 — 14단계" › 한 글자 낱말).
* `texts` 는 검색어 q 와 조건 넷(보낸 사람 · 받는 사람 · 제목 · 내용)의 글 — 빈 글은 없는 것이다
*/
export function isSingleCharSearch(texts: readonly string[]): boolean {
let terms = 0
const grams = new Set<string>()
for (const text of texts) {
for (const term of parseSearchTerms(text)) {
terms += 1
gramsOf(foldText(term.text), grams)
if (grams.size > 0) return false
}
}
return terms > 0
}
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } 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 { groupByConversationHintKey, useMailSettingsStore } from './mail-settings.store'
// 보내기 · 읽기 설정의 묶어 보기 (14단계) — 설정을 받기 전의 짐작(사용자마다 · localStorage), 로그아웃이 보이는 값을 바꾸지 않음(리뷰 M1),
// 받지 못했을 때도 고르기(리뷰 L4)
const ARA = { email: 'ara.kim@arasystem.test', name: '김아라' }
const DOYUN = { email: 'doyun.lee@arasystem.test', name: '이도윤' }
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
describe('mail-settings.store — 묶어 보기 짐작 · 받지 못했을 때', () => {
let requests: MockRequest[]
let load: () => MockResult | Promise<MockResult>
let patch: (request: MockRequest) => MockResult | Promise<MockResult>
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
load = () => ok(SETTINGS)
patch = (request) => ok({ ...SETTINGS, ...(request.data as Partial<MailSettings>) })
requests = installMockApi((request) => (request.method === 'GET' ? load() : patch(request)))
})
afterEach(() => {
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
})
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
it('짐작은 사용자마다다 — 김아라가 켰어도 이도윤은 묶지 않는다(처음 값)', async () => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
const auth = useAuthStore()
const store = useMailSettingsStore()
auth.markAuthenticated(ARA)
await flushPromises()
expect(store.groupByConversation).toBe(true)
auth.clearAuth()
await flushPromises()
auth.markAuthenticated(DOYUN)
await flushPromises()
expect(store.groupByConversation).toBe(false)
})
it('받으면 서버의 값이 이기고 그 사용자의 짐작으로 기억한다', async () => {
useAuthStore().markAuthenticated(DOYUN)
const store = useMailSettingsStore()
expect(store.groupByConversation).toBe(false)
await store.load()
expect(store.groupByConversation).toBe(true)
expect(window.localStorage.getItem(groupByConversationHintKey(DOYUN.email))).toBe('true')
})
it('로그아웃 · 세션 만료는 보이는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게 (리뷰 M1)', async () => {
const auth = useAuthStore()
auth.markAuthenticated(ARA)
const store = useMailSettingsStore()
await store.load()
expect(store.groupByConversation).toBe(true)
auth.clearAuth()
await flushPromises()
expect(store.settings).toBeNull()
expect(store.groupByConversation).toBe(true)
auth.markAuthenticated(ARA)
await store.load()
auth.expireSession()
await flushPromises()
expect(store.groupByConversation).toBe(true)
})
it('localStorage 를 읽지 · 쓰지 못해도 던지지 않는다 — 짐작은 처음 값(묶지 않음), 받은 값은 그대로 쓴다', async () => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
useAuthStore().markAuthenticated(ARA)
const store = useMailSettingsStore()
expect(store.groupByConversation).toBe(false)
await store.load()
expect(store.groupByConversation).toBe(true)
})
describe('받지 못했으면 짐작대로 두고 [대화로 묶기]는 고를 수 있다 (리뷰 L4)', () => {
beforeEach(() => {
window.localStorage.setItem(groupByConversationHintKey(ARA.email), 'true')
useAuthStore().markAuthenticated(ARA)
load = () => fail(503, 'SYS_001')
})
it('그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다', async () => {
const store = useMailSettingsStore()
await store.load()
expect([store.status, store.settings, store.groupByConversation]).toEqual(['error', null, true])
const saving = store.update('groupByConversation', false)
expect(store.groupByConversation).toBe(false)
expect(await saving).toBe(true)
expect(patches()).toEqual([{ groupByConversation: false }])
expect(store.status).toBe('ready')
expect(store.settings).toEqual({ ...SETTINGS, groupByConversation: false })
expect(store.groupByConversation).toBe(false)
expect(window.localStorage.getItem(groupByConversationHintKey(ARA.email))).toBe('false')
expect(store.saved).toBe(true)
})
it('저장도 실패하면 고르기 전의 짐작으로 되돌린다 — 알림은 인터셉터', async () => {
patch = () => fail(400, 'VAL_001')
const store = useMailSettingsStore()
await store.load()
expect(await store.update('groupByConversation', false)).toBe(false)
expect(store.groupByConversation).toBe(true)
expect(store.status).toBe('error')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
})
it('받은 값이 없으면 다른 칸(보내기 취소)은 고르지 않는다 — 받는 중에도 고르지 않는다', async () => {
const store = useMailSettingsStore()
await store.load()
expect(await store.update('undoSendSeconds', 5)).toBe(false)
load = () => ok(SETTINGS)
const loading = store.load()
expect(await store.update('groupByConversation', false)).toBe(false)
await loading
expect(patches()).toEqual([])
})
})
describe('조용히 다시 받기 refresh — 다른 탭 · PC 에서 바꾼 값 (2026-11-06 실사용 시험)', () => {
beforeEach(() => {
useAuthStore().markAuthenticated(ARA)
})
it('받은 값으로 바꾼다 — 받는 동안에도 상태는 ready 그대로(목록 머리의 [대화로 묶기]가 숨지 않게), 알리지 않는다', async () => {
const store = useMailSettingsStore()
await store.load()
const fetched = store.fetchSeq
load = () => ok({ undoSendSeconds: 30, externalImages: 'always', groupByConversation: false })
const refreshing = store.refresh()
expect(store.status).toBe('ready')
await refreshing
expect(store.settings).toEqual({ undoSendSeconds: 30, externalImages: 'always', groupByConversation: false })
expect(store.groupByConversation).toBe(false)
expect(store.knownUndoSendSeconds).toBe(30)
expect(store.fetchSeq).toBe(fetched + 1)
expect(window.localStorage.getItem(groupByConversationHintKey(ARA.email))).toBe('false')
expect(requests.filter((request) => request.method === 'GET').at(-1)?.config.silent).toBe(true)
})
it('받기 전 · 받는 중 · 저장하는 중에는 하지 않는다', async () => {
const store = useMailSettingsStore()
await store.refresh()
expect(requests).toHaveLength(0)
const loading = store.load()
await store.refresh()
await loading
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(1)
let release: (result: MockResult) => void = () => undefined
patch = () => new Promise<MockResult>((resolve) => (release = resolve))
const saving = store.update('undoSendSeconds', 5)
await store.refresh()
expect(requests.filter((request) => request.method === 'GET')).toHaveLength(1)
release(ok({ ...SETTINGS, undoSendSeconds: 5 }))
await saving
})
it('받는 사이 고른 값이 있으면 받은 값을 버린다 — 늦게 온 옛 값이 방금 고른 것을 덮지 않게', async () => {
const store = useMailSettingsStore()
await store.load()
let release: (result: MockResult) => void = () => undefined
load = () => new Promise<MockResult>((resolve) => (release = resolve))
const refreshing = store.refresh()
await store.update('groupByConversation', false)
release(ok(SETTINGS))
await refreshing
expect(store.groupByConversation).toBe(false)
expect(store.settings?.groupByConversation).toBe(false)
})
it('받지 못하면 지금 값을 둔다 — 알리지 않는다', async () => {
const store = useMailSettingsStore()
await store.load()
load = () => fail(503, 'SYS_001')
await store.refresh()
expect(store.status).toBe('ready')
expect(store.settings).toEqual(SETTINGS)
expect(useToastStore().toasts).toEqual([])
})
it('세션이 바뀌면 받은 값을 버린다', async () => {
const store = useMailSettingsStore()
await store.load()
let release: (result: MockResult) => void = () => undefined
load = () => new Promise<MockResult>((resolve) => (release = resolve))
const refreshing = store.refresh()
useAuthStore().clearAuth()
await flushPromises()
release(ok({ ...SETTINGS, groupByConversation: false }))
await refreshing
expect(store.settings).toBeNull()
expect(store.groupByConversation).toBe(true)
})
})
})
+164 -10
View File
@@ -9,9 +9,40 @@ import type { LoadStatus } from '@/stores/mail.store'
type SettingKey = keyof MailSettings
const GROUP_HINT_KEY_PREFIX = 'arasystem.mail.groupByConversation:'
/** 묶어 보기의 짐작을 기억하는 localStorage 키 — 사용자 주소마다 따로 (보기 방식 mail-view.store 와 같은 결) */
export function groupByConversationHintKey(email: string): string {
return `${GROUP_HINT_KEY_PREFIX}${email.trim().toLowerCase()}`
}
// 사생활 보호 모드 · 저장소 차단 등으로 localStorage 가 실패해도 짐작 없이(묶지 않음) 동작한다
function readGroupHint(email: string | null): boolean {
if (!email) return false
try {
return window.localStorage.getItem(groupByConversationHintKey(email)) === 'true'
} catch {
return false
}
}
function writeGroupHint(email: string, grouped: boolean) {
try {
window.localStorage.setItem(groupByConversationHintKey(email), String(grouped))
} catch {
// 기억하지 못해도 이번 화면은 서버의 값으로 동작한다
}
}
// 보내기 · 읽기 설정 스토어 — 계약 2절 "보내기 · 읽기 설정" (9단계 — 직원마다 서버 DB 에 하나, 어느 PC 에서나 같다)
// - 설정 › 메일 › 보내기 · 읽기는 들어올 때마다 새로 받는다(load) — 다른 PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다
// 메일 화면은 들어올 때 한 번 받아 둔다(ensureLoaded — 알리지 않는다): 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 세고,
// 메일함 화면이 대화로 묶어 볼지(groupByConversation — 14단계) 정한다. 목록 머리의 [대화로 묶기]도 이 스토어의 update 로 저장한다.
// 받기 전에는 이 브라우저가 마지막으로 안 값(localStorage — 사용자 주소마다)으로 짐작한다: 새로고침마다 메일 목록을 받았다가 버리고
// 묶은 목록을 다시 받지 않게(엔진 조회 한 번을 아낀다). 받으면 서버의 값이 이기고 짐작을 고친다.
// 받지 못했으면(error) 짐작대로 두고, 그때도 [대화로 묶기]는 고를 수 있다 — 그 칸만 PATCH 하고, 응답(설정 전체)을 받은 설정으로 삼는다.
// 실패하면 고르기 전의 짐작으로 되돌린다. 로그아웃 · 세션 만료(reset)는 화면이 보는 값을 바꾸지 않는다 — 떠나는 메일 화면이 목록을 다시 부르지 않게
// (2026-11-06 리뷰 M1). 다른 사람이 로그인하면 그 사람의 짐작으로 바뀐다
// - 고르는 대로 그 칸만 PATCH 한다(저장 단추 없음). 화면은 늘 마지막으로 고른 값을 보인다.
// **칸마다 저장은 한 번에 하나**(6단계 필터의 saving 과 같은 결) — 보내는 동안 다시 고르면 마지막으로 고른 것만 기억해 두고,
// 앞 저장이 끝난 뒤 그것이 서버의 답과 다르면 보낸다. 두 저장을 함께 보내면 서버가 나중에 처리한 쪽이 남는데, 응답 차례로는 그것을 알 수 없어
@@ -19,6 +50,11 @@ type SettingKey = keyof MailSettings
// 마지막으로 고른 것의 저장이 실패하면 그 칸을 서버가 마지막으로 확인해 준 값으로 되돌린다(오류 알림은 인터셉터)
// - 기다리는 저장이 모두 끝났고 마지막으로 끝난 저장이 성공이면 saved — 화면이 "저장했습니다"를 보인다. 다시 고르면 내린다
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) · 다시 받은 뒤(load) 늦게 온 응답은 쓰지 않는다 (authStore.generation · 순번)
// - 조용히 다시 받기(refresh — 메일 화면이 탭으로 돌아올 때, 마지막으로 받은 지 60초가 지났으면 useMailSettingsRefresh 가 부른다):
// 다른 탭 · PC 에서 바꾼 값(묶기 · 보내기 취소 초 · 외부 이미지)이 열어 둔 탭에 남지 않게. 받는 동안에도 화면은 지금 값 그대로다(loading 으로 바꾸지 않는다).
// 저장하는 중이면 하지 않고, 받는 사이 고르거나 다시 받거나 세션이 바뀌었으면 받은 값을 버린다 — 늦게 온 옛 값이 방금 고른 것을 덮지 않게.
// 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 연다(MailboxPage 의 watcher). 받지 못하면 알리지 않는다
// (2026-11-06 실사용 시험 — 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다). 받을 때마다 fetchSeq 가 오른다(마지막으로 받은 때를 세는 쪽이 본다)
export const useMailSettingsStore = defineStore('mail-settings', () => {
const settingsApi = useMailSettings()
const authStore = useAuthStore()
@@ -31,6 +67,16 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const saved = ref<boolean>(false)
/** 저장하는 중인 칸 수 */
const pending = ref<number>(0)
/** 설정을 받기 전의 묶어 보기 짐작 — 이 브라우저에서 이 사용자가 마지막으로 쓴 값 (위 머리말) */
const groupHint = ref<boolean>(false)
/** 받지 못한 채(error) 고른 묶어 보기의 고르기 전 짐작 — 저장이 실패하면 이 값으로 되돌린다. 고른 저장이 없으면 null */
let hintBeforeChoice: boolean | null = null
/** 설정을 받은 차례 — load · refresh 가 받을 때마다 오른다 (마지막으로 받은 때를 세는 useMailSettingsRefresh 가 본다) */
const fetchSeq = ref<number>(0)
/** 고른 차례 — 고를 때마다 오른다. 조용히 다시 받는 사이 고른 것이 있으면 받은 값을 버린다 */
let choiceSeq = 0
/** 조용히 다시 받는 중 — 한 번에 하나 */
let refreshing = false
/**
* 알고 있는 보내기 취소 시간(초) — 받아 둔 설정이 있을 때만, 모르면 null.
@@ -40,6 +86,14 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
status.value === 'ready' && settings.value ? settings.value.undoSendSeconds : null,
)
/**
* 메일함 목록을 대화로 묶어 보기 (14단계) — 받아 둔 설정의 값, 받기 전 · 받지 못했으면 짐작(이 브라우저가 이 사용자에게서 마지막으로 안 값,
* 처음이면 묶지 않음). 고르면 저장이 끝나기 전에 먼저 바뀐다(위 settings 와 같다) — 메일함 화면이 이 값으로 목록 출처를 정한다 (MailboxPage)
*/
const groupByConversation = computed<boolean>(() =>
settings.value ? settings.value.groupByConversation === true : groupHint.value,
)
/** 서버가 마지막으로 확인해 준 값 — 마지막으로 고른 것의 저장이 실패하면 그 칸을 이 값으로 되돌린다 */
let confirmed: MailSettings | null = null
/** 칸마다 마지막으로 고른 값 — 보내는 저장이 끝나면 이것을 본다 (보낼 것이 남았는지) */
@@ -67,12 +121,16 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
saved.value = false
// 저장하던 중에 떠났다 돌아왔으면 그 저장은 이 화면의 것이 아니다 — 세던 수가 남으면 "저장했습니다"가 다시 뜨지 않는다 (리뷰 #7)
forgetSaves()
// 받지 못한 채 고른 묶어 보기는 저장됐는지 모른다 — 짐작을 고르기 전으로 (받으면 서버의 값이 이긴다)
if (hintBeforeChoice !== null) groupHint.value = hintBeforeChoice
hintBeforeChoice = null
try {
const result = await settingsApi.getSettings({ silent: true })
if (current !== loadSeq) return settings.value
settings.value = { ...result }
confirmed = { ...result }
status.value = 'ready'
fetchSeq.value += 1
return result
} catch (caught) {
if (current !== loadSeq) return settings.value
@@ -87,8 +145,57 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (status.value === 'idle' || status.value === 'error') void load()
}
/**
* 조용히 다시 받는다 (머리말) — 받아 둔 값이 있거나 받지 못했을 때만(받기 전 · 받는 중이면 하지 않는다), 저장하는 중이 아닐 때만.
* 받는 사이 고르거나(choiceSeq) 다시 받거나(loadSeq) 세션이 바뀌었으면 받은 값을 버리고, 받지 못하면 지금 값을 둔다 (알리지 않는다)
*/
async function refresh(): Promise<void> {
if (status.value !== 'ready' && status.value !== 'error') return
if (pending.value > 0 || refreshing) return
const session = authStore.generation
const sessionLoad = loadSeq
const choice = choiceSeq
refreshing = true
try {
const result = await settingsApi.getSettings({ silent: true })
const stale =
authStore.generation !== session || loadSeq !== sessionLoad || choiceSeq !== choice || pending.value > 0
if (stale) return
settings.value = { ...result }
confirmed = { ...result }
hintBeforeChoice = null
status.value = 'ready'
error.value = null
fetchSeq.value += 1
} catch {
// 알리지 않는다 — 지금 값을 두고 다음에 다시 받는다
} finally {
refreshing = false
}
}
/** 고를 수 있는가 — 받는 중 · 받기 전에는 못 한다. 받지 못했으면(error) 받아 둔 값이 있을 때, 없으면 짐작으로 보이는 묶어 보기만 */
function canChoose(key: SettingKey): boolean {
if (status.value === 'ready') return settings.value !== null
if (status.value !== 'error') return false
return settings.value !== null || key === 'groupByConversation'
}
/** 화면이 보이는 값 — 받아 둔 설정, 없으면(받지 못함) 묶어 보기는 짐작 */
function shownOf<K extends SettingKey>(key: K): MailSettings[K] | undefined {
if (settings.value) return settings.value[key]
return key === 'groupByConversation' ? (groupHint.value as MailSettings[K]) : undefined
}
function setKey<K extends SettingKey>(key: K, value: MailSettings[K]) {
if (settings.value) settings.value = { ...settings.value, [key]: value }
else if (key === 'groupByConversation') groupHint.value = value as boolean
}
/** 저장이 실패하면 되돌릴 값 — 서버가 마지막으로 확인해 준 값, 받지 못한 채 고른 묶어 보기는 고르기 전의 짐작 */
function fallbackOf<K extends SettingKey>(key: K): MailSettings[K] | undefined {
if (confirmed) return confirmed[key]
return key === 'groupByConversation' && hintBeforeChoice !== null ? (hintBeforeChoice as MailSettings[K]) : undefined
}
/** 그 칸의 저장을 보낸다 — 끝나면 마지막으로 고른 값이 서버의 답과 다를 때만 그것을 이어 보낸다. 마지막 요청이 성공했으면 true */
@@ -106,8 +213,15 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
const result = await settingsApi.updateSettings({ [key]: value } as Partial<MailSettings>)
if (!stillCurrent()) return false
succeeded = true
confirmed = { ...(confirmed ?? result), [key]: result[key] }
const latest = wanted.get(key) as MailSettings[K]
// 받지 못한 채 고른 것이었으면 응답(저장한 뒤의 설정 전체)을 받은 설정으로 삼는다 — 고른 값은 그대로 보인다
if (!settings.value) {
settings.value = { ...result, [key]: latest }
status.value = 'ready'
error.value = null
}
hintBeforeChoice = null
confirmed = { ...(confirmed ?? result), [key]: result[key] }
// 그사이 다른 값을 골랐고 그것이 서버의 답과 다르면 이어 보낸다 — 아니면 서버의 답으로 맞추고 끝낸다
if (latest !== value && latest !== result[key]) next = latest
else setKey(key, result[key])
@@ -115,9 +229,10 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
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])
const fallback = fallbackOf(key)
// 그사이 다른 값을 골랐으면(서버가 확인한 값이 아니면) 그것을 보내 본다 — 아니면 서버가 확인한 값(받지 못했으면 고르기 전 짐작)으로 되돌린다
if (latest !== value && latest !== fallback) next = latest
else if (fallback !== undefined) setKey(key, fallback)
}
if (next === undefined) break
value = next
@@ -127,6 +242,7 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
if (stillCurrent()) {
wanted.delete(key)
chains.delete(key)
if (key === 'groupByConversation') hintBeforeChoice = null
lastSucceeded = succeeded
pending.value = Math.max(0, pending.value - 1)
if (pending.value === 0) saved.value = lastSucceeded
@@ -136,14 +252,16 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
/**
* 한 칸을 고른다 — 화면을 먼저 바꾸고 PATCH { key: value }. 그 칸의 저장이 이미 돌고 있으면 보내지 않고 고른 값만 기억한다
* (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받은 설정이 없으면 보내지 않는다(false).
* 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
* (앞 저장이 끝나면 이어 보낸다 — 위 머리말). 받는 중 · 받기 전이면 보내지 않는다(false). 받지 못했으면(error) 받아 둔 값이 없어도
* 묶어 보기는 짐작 위에서 고른다(canChoose). 돌려주는 약속은 그 칸의 저장이 다 끝났을 때 — 마지막 요청이 성공했으면 true
*/
function update<K extends SettingKey>(key: K, value: MailSettings[K]): Promise<boolean> {
if (!settings.value || status.value !== 'ready') return Promise.resolve(false)
if (!canChoose(key)) return Promise.resolve(false)
const running = chains.get(key)
// 지금 값을 다시 눌렀다 — 보낼 것이 없다
if (!running && settings.value[key] === value) return Promise.resolve(true)
if (!running && shownOf(key) === value) return Promise.resolve(true)
if (!running && !settings.value && key === 'groupByConversation') hintBeforeChoice = groupHint.value
choiceSeq += 1
setKey(key, value)
wanted.set(key, value)
saved.value = false
@@ -155,6 +273,9 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
function reset() {
loadSeq += 1
// 화면이 보는 묶어 보기는 그대로 둔다 — 로그아웃 · 세션 만료 직후 떠나는 메일 화면이 다른 목록을 부르지 않게 (리뷰 M1)
groupHint.value = groupByConversation.value
hintBeforeChoice = null
settings.value = null
confirmed = null
forgetSaves()
@@ -169,5 +290,38 @@ export const useMailSettingsStore = defineStore('mail-settings', () => {
() => reset(),
)
return { settings, status, error, saved, pending, knownUndoSendSeconds, load, ensureLoaded, update, reset }
// 묶어 보기의 짐작 — 로그인한 사용자의 것을 읽고(로그아웃은 그대로 둔다 — reset), 받거나 고른 값이 생기면 기억해 둔다
watch(
() => authStore.user?.email ?? null,
(email) => {
if (email) groupHint.value = readGroupHint(email)
},
{ immediate: true },
)
watch(
() => settings.value?.groupByConversation,
(grouped) => {
const email = authStore.user?.email
if (email && grouped !== undefined) {
groupHint.value = grouped
writeGroupHint(email, grouped)
}
},
)
return {
settings,
status,
error,
saved,
pending,
fetchSeq,
knownUndoSendSeconds,
groupByConversation,
load,
ensureLoaded,
refresh,
update,
reset,
}
})
@@ -0,0 +1,879 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import {
MAIL_CONVERSATION_DETAIL_CACHE_MAX,
MAIL_CONVERSATION_FILL_MAX_REQUESTS,
MAIL_CONVERSATION_PAGE_SIZE,
} from '@/shared/constants/mail-conversation'
import type { ConversationSummary, Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailStore } from './mail.store'
// 14단계 같은 대화 — 받기 · 이어 받기 · 늦은 응답 · 다시 받기 · [더 보기] · 펼친 줄의 읽음 (계약 2절 "대화 — 14단계")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: USER.email }],
subject: '회신: 유지보수 계약 갱신 건',
preview: `미리 보기 ${id}`,
receivedAt: '2026-10-29T00:30:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
function detail(id: string, partial: Partial<MessageDetail> = {}): MessageDetail {
return {
...summary(id),
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: '<p>본문</p>',
textBody: '본문',
hasBlockedImages: false,
attachments: [],
...partial,
}
}
function conversation(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
}
function deferred() {
let resolve: (value: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((done) => {
resolve = done
})
return { promise, resolve }
}
const THREAD = [summary('m1', { mailboxIds: ['sent'] }), summary('m2'), summary('m3')]
const ids = (list: readonly { id: string }[]) => list.map((entry) => entry.id)
/** 한 쪽(100통)이 찬 첫 쪽 — 처음 받을 때 이어 묻지 않고 [더 보기]가 남는다 (색인 방식의 큰 대화) */
function fullPage(...head: MessageSummary[]): MessageSummary[] {
const rest = Array.from({ length: MAIL_CONVERSATION_PAGE_SIZE - head.length }, (_, index) => summary(`p${index + 1}`))
return [...head, ...rest]
}
const conversationRequests = (requests: MockRequest[]) => requests.filter((request) => request.url.endsWith('/conversation'))
describe('mail.store — 14단계 같은 대화', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
function serve(handler: MockHandler) {
requests = installMockApi(handler)
}
describe('받기', () => {
it('그 메일의 대화를 알리지 않고 받는다 — 오래된 차례 그대로, N 은 total', async () => {
serve(() => conversation(THREAD))
const store = useMailStore()
await store.loadConversation('m3')
const [request] = conversationRequests(requests)
expect(request?.url).toBe('/messages/m3/conversation')
expect(request?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
expect(request?.config.silent).toBe(true)
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3'])
expect(store.conversationCount).toBe(3)
expect(store.conversationVisible).toBe(true)
expect(store.hasMoreConversation).toBe(false)
})
it('한 통뿐이면 칸이 없다', async () => {
serve(() => conversation([summary('m3')]))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.conversationStatus).toBe('ready')
expect(store.conversationVisible).toBe(false)
})
it('못 받으면 알리지 않고 칸이 안내를 그린다 — 없는 메일(404 RES_001 · 이 경로가 없는 옛 서버)은 칸 없이', async () => {
serve(() => fail(503, 'MAIL_001'))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.conversationStatus).toBe('error')
expect(store.conversationFailed).toBe(true)
expect(store.conversationError?.code).toBe('MAIL_001')
serve(() => fail(404, 'RES_001'))
await store.retryConversation()
expect(store.conversationStatus).toBe('error')
expect(store.conversationFailed).toBe(false)
expect(store.conversationVisible).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('[다시 시도]는 처음부터 다시 받는다 — 받으면 칸이 보인다', async () => {
let healthy = false
serve(() => (healthy ? conversation(THREAD) : fail(503, 'MAIL_001')))
const store = useMailStore()
await store.loadConversation('m3')
healthy = true
await store.retryConversation()
expect(store.conversationFailed).toBe(false)
expect(store.conversationVisible).toBe(true)
expect(conversationRequests(requests)).toHaveLength(2)
})
it('같은 메일을 이미 받았거나 받는 중이면 다시 묻지 않는다 (다시 받기는 refreshConversation)', async () => {
const first = deferred()
serve(() => first.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
void store.loadConversation('m3')
first.resolve(conversation(THREAD))
await loading
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(1)
})
})
describe('이어 받기 — hasNext 인데 항목이 적거나 0개인 쪽 (엔진 방식)', () => {
it('지금 메일 한 통만 받고 이어 묻는 동안은 칸을 그리지 않고, 두 통 넘게 모이면 보이며 뒤에서 잇는다', async () => {
const third = deferred()
serve(({ params }) => {
if (!params.cursor) return conversation([summary('m3')], 'c2', null)
if (params.cursor === 'c2') return conversation([summary('m1'), summary('m2')], 'c3', null)
return third.promise
})
const store = useMailStore()
const loading = store.loadConversation('m3')
await flushPromises()
expect(conversationRequests(requests).map((request) => request.params.cursor)).toEqual([undefined, 'c2', 'c3'])
expect(store.conversationVisible).toBe(true)
expect(store.conversationLoadingMore).toBe(true)
// 서버의 차례대로 붙인다 — 다시 줄 세우지 않는다
expect(ids(store.conversationItems)).toEqual(['m3', 'm1', 'm2'])
// 수를 모르고 남은 쪽이 있다 — "같은 대화" (수 없이)
expect(store.conversationCount).toBeNull()
third.resolve(conversation([summary('m4')], null, null))
await loading
expect(store.conversationLoadingMore).toBe(false)
expect(store.conversationCount).toBe(4)
})
it(`빈 쪽이 이어지면 ${MAIL_CONVERSATION_FILL_MAX_REQUESTS}번까지만 묻고 멈춘다 — 남은 쪽은 [더 보기]`, async () => {
let next = 0
serve(({ params }) => {
next += 1
return params.cursor ? conversation([], `c${next + 1}`, null) : conversation([summary('m3'), summary('m2')], 'c2', null)
})
const store = useMailStore()
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(MAIL_CONVERSATION_FILL_MAX_REQUESTS)
expect(store.hasMoreConversation).toBe(true)
expect(store.conversationVisible).toBe(true)
expect(store.conversationLoadingMore).toBe(false)
})
it('한 쪽이 찼으면 더 묻지 않는다 — [더 보기]는 다음 쪽부터 같은 결로 잇고, 사용자가 누른 일이라 실패는 알린다(받은 데까지 남는다)', async () => {
let failing = false
serve(({ params }) => {
if (!params.cursor) return conversation(fullPage(summary('m1')), 'c2', 102)
if (failing) return fail(503, 'MAIL_001')
return conversation([summary('m2'), summary('m3')], null, null)
})
const store = useMailStore()
await store.loadConversation('m3')
expect(conversationRequests(requests)).toHaveLength(1)
expect(store.hasMoreConversation).toBe(true)
failing = true
await store.loadMoreConversation()
expect(conversationRequests(requests).at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE, cursor: 'c2' })
expect(conversationRequests(requests).at(-1)?.config.silent).toBeUndefined()
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
expect(store.conversationItems).toHaveLength(MAIL_CONVERSATION_PAGE_SIZE)
expect(store.hasMoreConversation).toBe(true)
failing = false
await store.loadMoreConversation()
expect(ids(store.conversationItems).slice(-2)).toEqual(['m2', 'm3'])
expect(store.hasMoreConversation).toBe(false)
// 다음 쪽의 total 은 null — 첫 쪽의 수를 남긴다
expect(store.conversationCount).toBe(102)
})
})
describe('늦게 온 응답 · 같은 대화의 다른 메일', () => {
it('메일을 바꾸면 앞 메일의 늦은 응답은 버린다', async () => {
const late = deferred()
serve(({ url }) => (url === '/messages/m3/conversation' ? late.promise : conversation([summary('x1'), summary('x2')])))
const store = useMailStore()
const first = store.loadConversation('m3')
await store.loadConversation('x2')
late.resolve(conversation(THREAD))
await first
expect(store.conversationMessageId).toBe('x2')
expect(ids(store.conversationItems)).toEqual(['x1', 'x2'])
})
it('같은 대화의 다른 메일로 옮기면 줄을 둔 채 그 메일로 새로 받는다 — 옛 커서는 쓰지 않는다([더 보기]를 내린다)', async () => {
const second = deferred()
const first = fullPage(...THREAD)
serve(({ url }) => (url === '/messages/m3/conversation' ? conversation(first, 'old', null) : second.promise))
const store = useMailStore()
await store.loadConversation('m3')
expect(store.hasMoreConversation).toBe(true)
const moving = store.loadConversation('m2')
expect(store.conversationMessageId).toBe('m2')
expect(store.conversationVisible).toBe(true)
expect(ids(store.conversationItems)).toEqual(ids(first))
expect(store.hasMoreConversation).toBe(false)
// 남은 쪽이 있다는 것은 그대로 — 받은 수(100)를 통 수로 짐작하지 않는다
expect(store.conversationCount).toBeNull()
second.resolve(conversation([...THREAD, summary('m4')], null, 4))
await moving
expect(conversationRequests(requests).at(-1)?.url).toBe('/messages/m2/conversation')
expect(conversationRequests(requests).at(-1)?.params).toEqual({ limit: MAIL_CONVERSATION_PAGE_SIZE })
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
})
it('열린 메일을 닫으면(주소에 메일이 없다) 칸을 비운다 — 늦은 응답도 버린다', async () => {
const late = deferred()
serve(() => late.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
store.closeMessage()
late.resolve(conversation(THREAD))
await loading
expect(store.conversationMessageId).toBeNull()
expect(store.conversationItems).toEqual([])
expect(store.conversationStatus).toBe('idle')
})
it('세션이 바뀌면(로그아웃 · 다른 계정) 비우고 늦은 응답을 버린다', async () => {
const late = deferred()
serve(() => late.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
useAuthStore().clearAuth()
useAuthStore().startSession({ email: 'seojun.park@arasystem.test', name: '박서준' })
await flushPromises()
late.resolve(conversation(THREAD))
await loading
expect(store.conversationMessageId).toBeNull()
expect(store.conversationItems).toEqual([])
expect(store.conversationStatus).toBe('idle')
})
})
describe('다시 받기 (바뀜 소식 · 쓰기 화면에서 돌아옴)', () => {
it('보이는 채로 처음 쪽부터 다시 받아 한 번에 바꾼다 — 실패는 알리지 않고 그대로 둔다', async () => {
let thread = THREAD
let failing = false
serve(() => (failing ? fail(503, 'MAIL_001') : conversation(thread)))
const store = useMailStore()
await store.loadConversation('m3')
thread = [...THREAD, summary('m4', { mailboxIds: ['sent'] })]
await store.refreshConversation()
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
expect(conversationRequests(requests).at(-1)?.config.silent).toBe(true)
failing = true
await store.refreshConversation()
expect(ids(store.conversationItems)).toEqual(['m1', 'm2', 'm3', 'm4'])
expect(useToastStore().toasts).toEqual([])
})
it('한 번에 하나 — 받는 사이 온 요청은 여럿이어도 끝난 뒤 한 번만 더 받는다', async () => {
const replies: ReturnType<typeof deferred>[] = []
serve(() => {
if (replies.length === 0) return conversation(THREAD)
const reply = deferred()
replies.push(reply)
return reply.promise
})
const store = useMailStore()
await store.loadConversation('m3')
replies.push(deferred())
const refreshing = store.refreshConversation()
void store.refreshConversation()
void store.refreshConversation()
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(2)
replies[1]?.resolve(conversation(THREAD))
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(3)
replies[2]?.resolve(conversation(THREAD))
await refreshing
expect(conversationRequests(requests)).toHaveLength(3)
})
it('[더 보기]로 더 받은 대화는 저절로 다시 받지 않는다 — 받아 둔 줄이 처음 쪽으로 줄지 않게', async () => {
serve(({ params }) =>
params.cursor ? conversation([summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 101),
)
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sent = conversationRequests(requests).length
expect(sent).toBe(2)
await store.refreshConversation()
expect(conversationRequests(requests)).toHaveLength(sent)
expect(store.conversationItems).toHaveLength(MAIL_CONVERSATION_PAGE_SIZE + 1)
})
it('붙잡힌 메일(보내기 취소 · 예약)이 든 대화만 엔진이 풀 때 다시 받는다 (refreshPendingSends)', async () => {
const held = summary('m4', {
mailboxIds: ['sent'],
pendingSend: { kind: 'undo', sendAt: '2026-10-29T01:00:10Z' },
})
let thread = THREAD
serve(({ url }) => {
if (url.endsWith('/conversation')) return conversation(thread)
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
})
const store = useMailStore()
await store.loadConversation('m3')
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(1)
thread = [...THREAD, held]
await store.refreshConversation()
thread = [...THREAD, { ...held, pendingSend: null }]
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(3)
expect(store.conversationItems.at(-1)?.pendingSend).toBeNull()
})
})
describe('펼친 줄 — 검색 쿼리 없이 받고, 안 읽은 메일이면 읽음으로', () => {
function serveThread(items: MessageSummary[], options: { detailReply?: () => MockResult | Promise<MockResult>; patch?: () => MockResult } = {}) {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation(items)
if (method === 'PATCH') return options.patch?.() ?? ok({ ...summary('m2'), isRead: true })
return options.detailReply?.() ?? ok(detail(url.split('/').pop() ?? 'm2', { isRead: false }))
})
}
const patches = () => requests.filter((request) => request.method === 'PATCH')
const details = () =>
requests.filter(
(request) => request.method === 'GET' && request.url.startsWith('/messages/') && !request.url.endsWith('/conversation'),
)
it('펴면 그 메일을 쿼리 없이(강조 없이) 알리지 않고 받고, 안 읽은 메일이면 한 번만 읽음으로 — 접었다 다시 펴도 다시 받지 않는다', async () => {
serveThread([summary('m1'), summary('m2', { isRead: false }), summary('m3')])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(true)
expect(store.conversationDetails.get('m2')?.status).toBe('loading')
await flushPromises()
expect(details().map((request) => [request.url, request.params, request.config.silent])).toEqual([
['/messages/m2', {}, true],
])
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
expect(patches().map((request) => [request.url, request.data])).toEqual([['/messages/m2', { isRead: true }]])
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(true)
store.toggleConversationMessage('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(false)
store.toggleConversationMessage('m2')
await flushPromises()
expect(details()).toHaveLength(1)
expect(patches()).toHaveLength(1)
})
it('지금 메일의 줄은 펴지 않는다', async () => {
serveThread(THREAD)
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m3')
expect(store.conversationExpandedIds.size).toBe(0)
expect(details()).toEqual([])
})
it('본문이 오기 전에 접었으면 읽음으로 바꾸지 않는다 (읽지 않았다)', async () => {
const reply = deferred()
serveThread([summary('m2', { isRead: false }), summary('m3')], { detailReply: () => reply.promise })
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
store.toggleConversationMessage('m2')
reply.resolve(ok(detail('m2', { isRead: false })))
await flushPromises()
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
expect(patches()).toEqual([])
})
it('받아 둔 대화가 옛 값이어도 이 탭이 읽음으로 아는 메일(보이는 목록 · 열린 메일)은 다시 읽음으로 바꾸지 않는다', async () => {
serveThread([summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')])
const store = useMailStore()
store.messages = [summary('m1', { isRead: true })]
store.selected = detail('m2', { isRead: true })
await store.loadConversation('m3')
store.toggleConversationMessage('m1')
store.toggleConversationMessage('m2')
await flushPromises()
expect(patches()).toEqual([])
expect(store.conversationItems.filter((item) => !item.isRead)).toEqual([])
})
it('읽음으로 바꾸지 못하면 이 칸의 표시도 되돌린다 — 다시 펴면 다시 해 본다', async () => {
let rejecting = true
serveThread([summary('m2', { isRead: false }), summary('m3')], {
patch: () => (rejecting ? fail(503, 'MAIL_001') : ok({ ...summary('m2'), isRead: true })),
})
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(false)
rejecting = false
store.toggleConversationMessage('m2')
store.toggleConversationMessage('m2')
await flushPromises()
expect(patches()).toHaveLength(2)
expect(store.conversationItems.find((item) => item.id === 'm2')?.isRead).toBe(true)
})
it('본문을 못 받으면 줄 안에 오류 — [다시 시도]로 다시 받는다', async () => {
let failing = true
serveThread([summary('m2'), summary('m3')], {
detailReply: () => (failing ? fail(503, 'MAIL_001') : ok(detail('m2'))),
})
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
const state = store.conversationDetails.get('m2')
expect(state?.status === 'error' ? state.error.code : null).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
failing = false
await store.retryConversationDetail('m2')
expect(store.conversationDetails.get('m2')?.status).toBe('ready')
})
it('같은 대화의 다른 메일로 옮기면 그 줄(이제 지금 메일)의 펼침을 접고, 읽기 화면이 본 앞 메일은 읽음으로 본다', async () => {
serveThread([summary('m2'), summary('m3', { isRead: false })])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
await flushPromises()
await store.loadConversation('m2')
expect(store.conversationExpandedIds.has('m2')).toBe(false)
store.toggleConversationMessage('m3')
await flushPromises()
expect(patches()).toEqual([])
})
it('본문을 받는 사이 세션이 바뀌면(로그아웃 뒤 다시 로그인) 버리고 읽음으로 바꾸지 않는다', async () => {
const reply = deferred()
serveThread([summary('m2', { isRead: false }), summary('m3')], { detailReply: () => reply.promise })
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('m2')
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
reply.resolve(ok(detail('m2', { isRead: false })))
await flushPromises()
expect(patches()).toEqual([])
expect(store.conversationDetails.get('m2')?.status).not.toBe('ready')
})
it(`접힌 줄의 받아 둔 본문은 최근 ${MAIL_CONVERSATION_DETAIL_CACHE_MAX}개만 둔다 — 펼친 줄의 본문은 세지 않고 늘 두며, 버린 본문은 다시 펴면 다시 받는다`, async () => {
const others = Array.from({ length: MAIL_CONVERSATION_DETAIL_CACHE_MAX + 3 }, (_, index) => summary(`r${index}`))
serveThread([...others, summary('m3')])
const store = useMailStore()
await store.loadConversation('m3')
store.toggleConversationMessage('r0')
await flushPromises()
for (let index = 1; index < others.length; index += 1) {
store.toggleConversationMessage(`r${index}`)
await flushPromises()
store.toggleConversationMessage(`r${index}`)
}
const cached = [...store.conversationDetails.keys()]
expect(cached[0]).toBe('r0')
expect(cached.slice(1)).toEqual(
Array.from({ length: MAIL_CONVERSATION_DETAIL_CACHE_MAX }, (_, index) => `r${index + 3}`),
)
expect(store.conversationExpandedIds.has('r0')).toBe(true)
const fetched = details().length
store.toggleConversationMessage(`r${others.length - 1}`)
await flushPromises()
expect(details()).toHaveLength(fetched)
store.toggleConversationMessage('r1')
await flushPromises()
expect(details()).toHaveLength(fetched + 1)
})
})
describe('다시 받아 달라는 소식 — 메일함들의 통 수가 바뀔 때만', () => {
const box = (id: string, totalCount: number, unreadCount: number) => ({
id,
name: id,
role: null,
parentId: null,
sortOrder: 0,
totalCount,
unreadCount,
})
it('처음 받은 메일함 목록 · 안 읽은 수만 바뀜 · 차례만 바뀜에는 오르지 않고, 새 메일 · 옮김으로 통 수가 바뀌면 오른다 — 비울 때도 오르지 않는다', async () => {
serve(() => ok(null))
const store = useMailStore()
const seq = () => store.conversationRefreshSeq
store.mailboxes = [box('inbox', 3, 1), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(0)
store.mailboxes = [box('inbox', 3, 0), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(0)
store.mailboxes = [box('inbox', 4, 0), box('sent', 1, 0)]
await flushPromises()
expect(seq()).toBe(1)
store.mailboxes = [box('inbox', 3, 0), box('sent', 2, 0)]
await flushPromises()
expect(seq()).toBe(2)
store.mailboxes = [box('sent', 2, 0), box('inbox', 3, 0)]
await flushPromises()
expect(seq()).toBe(2)
store.reset()
await flushPromises()
expect(seq()).toBe(2)
})
})
describe('묻는 중 · 붙잡힌 메일 · 그사이 지워진 메일', () => {
it('묻는 동안 conversationBusy — 끝나면 꺼진다 (칸이 그때부터 다시 받기의 간격을 센다)', async () => {
const reply = deferred()
serve(() => reply.promise)
const store = useMailStore()
const loading = store.loadConversation('m3')
expect(store.conversationBusy).toBe(true)
reply.resolve(conversation(THREAD))
await loading
expect(store.conversationBusy).toBe(false)
// 받을 것이 없으면(받은 대화가 없음) 묻지 않는다 — 켜지지 않는다
store.reset()
await store.refreshConversation()
expect(store.conversationBusy).toBe(false)
})
it('[더 보기]로 더 받은 대화는 붙잡힌 메일만 그 메일로 고친다 — 다 보냈으면 표시가 내려가고, 지워졌으면(404) 그 줄을 뺀다', async () => {
const sendAt = '2026-10-29T01:00:10Z'
const sent = summary('h1', { mailboxIds: ['sent'], pendingSend: { kind: 'undo', sendAt } })
const gone = summary('h2', { mailboxIds: ['sent'], pendingSend: { kind: 'scheduled', sendAt } })
serve(({ url, params }) => {
if (url.endsWith('/conversation')) {
return params.cursor ? conversation([sent, gone, summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 103)
}
if (url === '/messages/h1') return ok(detail('h1', { mailboxIds: ['sent'], pendingSend: null, htmlBody: '<p>긴 본문</p>' }))
if (url === '/messages/h2') return fail(404, 'RES_001')
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
})
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sentConversations = conversationRequests(requests).length
await store.refreshPendingSends()
expect(conversationRequests(requests)).toHaveLength(sentConversations)
expect(
requests.filter((request) => request.url === '/messages/h1' || request.url === '/messages/h2').map((request) => [request.url, request.config.silent]),
).toEqual([
['/messages/h1', true],
['/messages/h2', true],
])
const fixed = store.conversationItems.find((item) => item.id === 'h1')
expect(fixed?.pendingSend).toBeNull()
// 줄에는 요약만 — 본문을 싣지 않는다
expect(fixed && 'htmlBody' in fixed).toBe(false)
expect(store.conversationItems.some((item) => item.id === 'h2')).toBe(false)
expect(store.conversationCount).toBe(102)
expect(useToastStore().toasts).toEqual([])
})
it('[더 보기]로 더 받은 대화에서 펼친 본문이 404(그사이 지워짐)면 다시 받지 않고 그 줄만 뺀다', async () => {
serve(({ url, params }) => {
if (url.endsWith('/conversation')) {
return params.cursor ? conversation([summary('x1'), summary('m3')], null, null) : conversation(fullPage(summary('m1')), 'c2', 102)
}
return fail(404, 'RES_001')
})
const store = useMailStore()
await store.loadConversation('m3')
await store.loadMoreConversation()
const sentConversations = conversationRequests(requests).length
store.toggleConversationMessage('x1')
await flushPromises()
expect(conversationRequests(requests)).toHaveLength(sentConversations)
expect(store.conversationItems.some((item) => item.id === 'x1')).toBe(false)
expect(store.conversationExpandedIds.has('x1')).toBe(false)
expect(store.conversationCount).toBe(101)
})
it('같은 메일을 읽음으로 보내는 중이면 다시 보내지 않는다 — 안 읽은 수를 두 번 빼지 않게', async () => {
const patch = deferred()
serve(({ method }) => (method === 'PATCH' ? patch.promise : ok(null)))
const store = useMailStore()
store.mailboxes = [{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 2 }]
store.mailboxesStatus = 'ready'
const message = { id: 'm2', isRead: false, mailboxIds: ['inbox'] }
const first = store.markRead(message)
expect(await store.markRead(message)).toBe(true)
patch.resolve(ok({ ...summary('m2'), isRead: true }))
expect(await first).toBe(true)
expect(requests.filter((request) => request.method === 'PATCH')).toHaveLength(1)
expect(store.mailboxes[0]?.unreadCount).toBe(1)
})
})
describe('이 탭이 바꾼 읽음 — 칸의 줄에 바로 (2026-11-07 사용자 관점 점검)', () => {
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const readOf = (store: ReturnType<typeof useMailStore>) => store.conversationItems.map((item) => [item.id, item.isRead])
function row(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
it('받는 동안 이 탭이 바꾼 읽음은 받은 값보다 앞선다(받은 쪽은 바꾸기 전의 서버 값일 수 있다) — 그 뒤에 시작한 받기는 받은 값을 믿는다', async () => {
const unreadThread = () => [summary('m1'), summary('m2', { isRead: false }), summary('m3')]
let reply: MockResult | Promise<MockResult> = conversation(unreadThread())
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH') return ok({ ...summary('m2'), isRead: true })
return ok(null)
})
const store = useMailStore()
await store.loadConversation('m3')
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
await store.markRead({ id: 'm2', isRead: false, mailboxIds: ['inbox'] })
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
stale.resolve(conversation(unreadThread()))
await refreshing
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
// 바꾼 뒤에 시작한 받기 — 그사이 다른 탭에서 안 읽음으로 했다
reply = conversation(unreadThread())
await store.refreshConversation()
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('받기를 시작할 때 보내던 여러 통 처리의 읽음도 받은 값보다 앞선다 — 받는 사이에 끝나도', async () => {
let reply: MockResult | Promise<MockResult> = conversation([summary('m2', { isRead: false }), summary('m3')])
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
bulk.resolve(ok({ updatedIds: ['m2'], failedIds: [] }))
await marking
stale.resolve(conversation([summary('m2', { isRead: false }), summary('m3')]))
await refreshing
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('대화 목록의 여러 통 처리가 바꾸지 못한 메일은 칸도 바꾸기 전 값으로 — 줄은 메일마다의 앞 값을 몰라 칸이 아는 값으로', async () => {
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'] }), summary('m2', { isRead: false }), summary('m3', { isRead: false })])
}
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'conversations', mailboxId: 'inbox' }
store.conversations = [row('c1', ['m3', 'm2'], { isRead: false })]
await store.loadConversation('m3')
store.toggleCheck('c1')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
// 보내는 동안 — 줄의 메일은 읽은 모양, 보낸편지함의 m1 은 줄에 들지 않아 그대로
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
bulk.resolve(ok({ updatedIds: ['m3'], failedIds: ['m2'] }))
await marking
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('여러 통 처리의 중요 표시가 실패하면 중요 표시만 되돌린다 — 고른 때의 읽음으로 목록 · 칸을 덮지 않는다', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation([summary('m2'), summary('m3')])
if (method === 'PATCH' && url === '/messages') return ok({ updatedIds: [], failedIds: ['m2'] })
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
// 목록은 옛 값(안 읽음)이고 칸은 받은 값(읽음)이다 — 검색 결과처럼 저절로 맞추지 않는 목록
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
await store.patchChecked({ isFlagged: true })
expect(store.messages.find((message) => message.id === 'm2')).toMatchObject({ isRead: false, isFlagged: false })
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('메일함 ⋯ › 모두 읽음이 끝나면 칸에 든 그 메일함의 메일도 읽음 — 다른 메일함의 메일은 그대로', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'], isRead: false }), summary('m2', { isRead: false }), summary('m3')])
}
if (method === 'PATCH' && url === '/mailboxes/inbox/messages') return ok({ processed: 1, remaining: 0 })
if (url === '/mailboxes') return ok({ items: [INBOX, SENT], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = [INBOX, SENT]
await store.loadConversation('m3')
const outcome = await store.runMailboxOperation('read', INBOX, () => undefined)
expect(outcome.status).toBe('done')
expect(readOf(store)).toEqual([['m1', false], ['m2', true], ['m3', true]])
})
it('다른 탭 · 기기에서 바꾼 읽음(목록 맞추기로 받은 값)은 칸에 옮기지 않는다 — 다음에 칸을 받을 때 보인다', async () => {
let thread = [summary('m2', { isRead: false }), summary('m3')]
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation(thread)
if (url === '/messages' && method === 'GET') {
return ok({ items: [summary('m2'), summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
}
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
await store.loadConversation('m3')
await store.syncVisibleList()
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
expect(readOf(store)).toEqual([['m2', false], ['m3', true]])
thread = [summary('m2'), summary('m3')]
await store.refreshConversation()
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
})
})
+16 -10
View File
@@ -55,6 +55,8 @@ function page(items: MessageSummary[], nextCursor: string | null = null, total:
}
const ids = (list: readonly { id: string }[]) => list.map((item) => item.id)
/** 목록 요청만 — 검색을 시작할 때 묻는 색인 상태(GET /search-index — 14단계)는 뺀다 */
const listRequests = (requests: MockRequest[]) => requests.filter((request) => request.url === '/messages')
const patches = (requests: MockRequest[]) =>
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
@@ -94,12 +96,12 @@ describe('mail.store — 3단계 목록', () => {
const store = useMailStore()
await store.openSearch('회의', 'inbox')
expect(requests[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
expect(listRequests(requests)[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
expect(store.search).toEqual({ kind: 'search', q: '회의', scope: 'inbox' })
expect(store.currentMailboxId).toBe('inbox')
await store.openSearch('회의', SEARCH_SCOPE_ALL)
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', limit: 20 })
expect(store.currentMailboxId).toBeNull()
expect(ids(store.messages)).toEqual(['r1'])
})
@@ -110,24 +112,28 @@ describe('mail.store — 3단계 목록', () => {
await store.openSearch('회의', SEARCH_SCOPE_ALL)
await store.openSearch('회의', SEARCH_SCOPE_ALL)
expect(requests).toHaveLength(1)
expect(listRequests(requests)).toHaveLength(1)
await store.openSearch('회의', SEARCH_SCOPE_ALL, { force: true })
expect(requests).toHaveLength(2)
expect(listRequests(requests)).toHaveLength(2)
expect(store.listStatus).toBe('ready')
})
it('더 보기는 검색어 · 범위와 커서를 함께 보낸다', async () => {
const first = Array.from({ length: 20 }, (_, index) => summary(`r${index + 1}`))
const requests = installMockApi(({ params }) =>
params.cursor ? page([summary('r2')]) : page([summary('r1')], 'c2', 2),
params.cursor ? page([summary('r21')]) : page(first, 'c2', 21),
)
const store = useMailStore()
await store.openSearch('회의', 'sent')
// 첫 쪽이 다 찼으면 이어 묻지 않는다
expect(listRequests(requests)).toHaveLength(1)
await store.loadMore()
expect(requests[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
expect(ids(store.messages)).toEqual(['r1', 'r2'])
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
expect(store.messages).toHaveLength(21)
expect(store.messages.at(-1)?.id).toBe('r21')
})
it('메일함으로 돌아가면 보이던 결과를 버리고 그 메일함을 새로 연다', async () => {
@@ -186,11 +192,11 @@ describe('mail.store — 3단계 목록', () => {
await store.openSearch('회의', SEARCH_SCOPE_ALL)
await store.syncVisibleList()
expect(requests).toHaveLength(1)
expect(listRequests(requests)).toHaveLength(1)
await store.syncVisibleList({ includeSearch: true })
expect(requests).toHaveLength(2)
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
expect(listRequests(requests)).toHaveLength(2)
expect(listRequests(requests)[1]?.params).toEqual({ q: '회의', limit: 20 })
})
it('실패해도 알리지 않고 목록을 그대로 둔다', async () => {

Some files were not shown because too many files have changed in this diff Show More