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
+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 }
}