generated from ttipo/project-base
feat(frontend): 보내기 취소 · 예약 발송 · 외부 이미지 · 인쇄 · 보낸 사람 차단 · 보내기 · 읽기 설정 화면
쓰기 화면의 [보내기 ▾](예약 발송 창), 보내기 취소 알림(남은 시간 막대), 메일함 패널 「예약됨」 · 예약 목록, 읽기 화면의 안내 줄([이미지 보기] · [고치기] · [예약 취소] · [차단 풀기]), 인쇄 화면(새 탭 — A4 폭으로 다시 재서 빈 쪽 없이), 보낸 사람 차단 창, 설정 › 보내기 · 읽기 · 차단한 주소. 디자인 README "9단계", 계약 2절. 리뷰로 고친 것: - 붙잡힌 메일을 여러 통 처리로 지워도 예약이 그대로 나갔다 — 고르지 못하게 했다(고르기 칸 aria-disabled + 까닭, 하나 · 범위 · 모두 고르기가 건너뛴다). 읽기 화면 [삭제]는 확인(처음 초점 「취소」) 뒤 발송을 취소하고 휴지통으로 — 서버도 MAIL_015 로 막는다. - 엔진이 발송을 풀어도 상태가 바뀌지 않아 "보내는 중" · 「예약됨」이 남았다 — 보낼 시각 + 2초에 목록 · 열린 메일 · 예약 수를 다시 맞춘다(MAIL_014 때도). - 설정을 연달아 고르면 늦게 온 앞 응답이 화면과 서버를 갈랐다 — 칸마다 저장은 한 번에 하나, 끝나면 마지막으로 고른 값으로 맞춘다. - 응답을 기다리는 사이 다른 목록으로 간 사용자를 옛 목록의 다음 메일로 끌고 갔다 — 같은 목록 · 같은 메일을 보고 있을 때만 옮긴다(3 · 4단계 단추도). - 이미지를 보일 때 본문 틀의 CSP 를 우리 출처의 중계 경로로만 연다 — 'self' 면 정화를 빠져나간 CSS url() 이 다른 API 를 부를 수 있었다. - 알림 단추는 한 번만 돈다, 보내기 취소 알림의 마감은 설정한 초로 묶는다, [보내기 ▾] 메뉴는 화면 안에서 연다(쓰기 화면이 움직이지 않게), 차단 목록 · 설정 저장의 상태가 멈추던 것, 초점을 잃던 것과 알리지 않던 라이브 영역. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,11 +43,89 @@ describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약
|
||||
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
|
||||
id: 'sent-1',
|
||||
rejectedRecipients,
|
||||
pendingSend: null,
|
||||
})
|
||||
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail.sendMessage — 붙잡아 둔 발송 pendingSend 는 늘 값 또는 null (계약 2절 "보내기 취소 · 예약 발송" — 9단계)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
const UNDO = { kind: 'undo', sendAt: '2026-10-29T06:00:10.000Z' }
|
||||
const SCHEDULED = { kind: 'scheduled', sendAt: '2026-10-30T00:00:00.000Z' }
|
||||
|
||||
it.each([
|
||||
['보내기 취소를 기다리는 중이면 그대로', UNDO, UNDO],
|
||||
['예약이면 그대로', SCHEDULED, SCHEDULED],
|
||||
['바로 보냈으면(null) null', null, null],
|
||||
['필드가 없는 옛 서버 응답은 null', undefined, null],
|
||||
['모르는 종류는 null — 보내기 취소 단추를 띄우지 않는다', { kind: 'later', sendAt: SCHEDULED.sendAt }, null],
|
||||
['시각으로 읽을 수 없으면 null', { kind: 'undo', sendAt: '곧' }, null],
|
||||
])('%s', async (_label, pendingSend, expected) => {
|
||||
installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], ...(pendingSend === undefined ? {} : { pendingSend }) }, 201))
|
||||
|
||||
await expect(useMail().sendMessage(PAYLOAD, { idempotencyKey: 'key-1' })).resolves.toEqual({
|
||||
id: 'sent-1',
|
||||
rejectedRecipients: [],
|
||||
pendingSend: expected,
|
||||
})
|
||||
})
|
||||
|
||||
it('예약 발송은 본문의 sendAt(UTC ISO)을 그대로 싣는다', async () => {
|
||||
const requests = installMockApi(() => ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: SCHEDULED }, 201))
|
||||
|
||||
await useMail().sendMessage({ ...PAYLOAD, sendAt: SCHEDULED.sendAt }, { idempotencyKey: 'key-1' })
|
||||
|
||||
expect(requests[0]?.data).toMatchObject({ sendAt: SCHEDULED.sendAt })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail 예약됨 · 보내기 취소 · 외부 이미지 (9단계)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('예약한 메일 목록은 GET /scheduled-messages 에 cursor · limit 을 싣는다', async () => {
|
||||
const requests = installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
|
||||
await useMail().listScheduledMessages({ limit: 1 })
|
||||
await useMail().listScheduledMessages({ cursor: 'c2', limit: 20 })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url, request.params])).toEqual([
|
||||
['GET', '/scheduled-messages', { limit: 1 }],
|
||||
['GET', '/scheduled-messages', { cursor: 'c2', limit: 20 }],
|
||||
])
|
||||
})
|
||||
|
||||
it('붙잡은 발송 취소는 DELETE /scheduled-messages/{id} — id 는 인코딩하고 { draftId } 를 돌려준다', async () => {
|
||||
const requests = installMockApi(() => ok({ draftId: 'a/b' }))
|
||||
|
||||
await expect(useMail().cancelScheduledMessage('a/b')).resolves.toEqual({ draftId: 'a/b' })
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['DELETE /scheduled-messages/a%2Fb'])
|
||||
})
|
||||
|
||||
it('외부 이미지 보기는 GET /messages/{id}?images=show — 주지 않으면 쿼리 없이 (서버가 설정을 따른다)', async () => {
|
||||
const requests = installMockApi(() => ok({ id: 'm1' }))
|
||||
|
||||
await useMail().getMessage('m1', { showImages: true, silent: true })
|
||||
await useMail().getMessage('m1')
|
||||
|
||||
expect(requests.map((request) => request.params)).toEqual([{ images: 'show' }, {}])
|
||||
expect(requests[0]?.config.silent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
|
||||
const SET: MailFilterSet = {
|
||||
rules: [
|
||||
|
||||
@@ -4,6 +4,8 @@ import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_FILTER_RUN_TIMEOUT_MS,
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_IMAGES_PARAM,
|
||||
MAIL_IMAGES_SHOW,
|
||||
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
@@ -37,16 +39,23 @@ import type {
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
ScheduledMessageCancel,
|
||||
SendMessagePayload,
|
||||
SentMessage,
|
||||
SignatureImage,
|
||||
UpdateMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import type { CursorPaginationQuery, PaginatedResult } from '@/shared/types/pagination'
|
||||
import { pendingSendOf } from '@/shared/utils/pending-send'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type MailRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
/** 메일 한 통 읽기의 옵션 — showImages 면 외부 이미지를 보이는 본문(`images=show` — 9단계 "외부 이미지 보기") */
|
||||
export interface GetMessageOptions extends MailRequestOptions {
|
||||
showImages?: boolean
|
||||
}
|
||||
|
||||
export interface SendMessageOptions extends MailRequestOptions {
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
|
||||
@@ -55,8 +64,14 @@ export interface SendMessageOptions extends MailRequestOptions {
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
|
||||
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
|
||||
/**
|
||||
* POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients · pendingSend 가 없다
|
||||
* (sendMessage 가 배열 · PendingSend 또는 null 로 맞춘다)
|
||||
*/
|
||||
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'> & {
|
||||
rejectedRecipients?: unknown
|
||||
pendingSend?: unknown
|
||||
}
|
||||
|
||||
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
|
||||
type MailVacationReply = MailVacation & { isActive?: unknown }
|
||||
@@ -221,9 +236,17 @@ export function useMail() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다 */
|
||||
function getMessage(id: string, options?: MailRequestOptions): Promise<MessageDetail> {
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
|
||||
/**
|
||||
* 조회만으로는 읽음 처리되지 않는다 — 읽음은 updateMessage 로 따로 보낸다.
|
||||
* showImages 면 `?images=show` — 외부 이미지를 중계 주소로 바꿔 넣은 본문(imagesShown: true). 주지 않으면 서버가 설정을 따른다
|
||||
* (늘 묻기 · 주소록과 사내 직원은 늘 보기 · 늘 보기 — 스팸메일함의 메일은 늘 지운다, 계약 2절 "외부 이미지 보기")
|
||||
*/
|
||||
function getMessage(id: string, options: GetMessageOptions = {}): Promise<MessageDetail> {
|
||||
const { showImages, ...requestOptions } = options
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, {
|
||||
...requestOptions,
|
||||
...(showImages ? { params: { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
@@ -330,7 +353,33 @@ export function useMail() {
|
||||
...requestOptions,
|
||||
headers: { [MAIL_IDEMPOTENCY_HEADER]: idempotencyKey },
|
||||
})
|
||||
return { ...sent, rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [] }
|
||||
return {
|
||||
...sent,
|
||||
rejectedRecipients: Array.isArray(sent.rejectedRecipients) ? sent.rejectedRecipients : [],
|
||||
// 엔진이 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계) — 없거나 모양이 틀리면 바로 보낸 것으로 본다
|
||||
pendingSend: pendingSendOf(sent.pendingSend),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 보내기 취소 · 예약 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) ─────────────
|
||||
|
||||
/** 예약한 메일 — 보낼 시각 차례, total 이 있다. 메일함 패널의 「예약됨」은 limit=1 로 total 만 쓴다 */
|
||||
function listScheduledMessages(
|
||||
query: CursorPaginationQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<MessageSummary>> {
|
||||
return api.get<PaginatedResult<MessageSummary>, PaginatedResult<MessageSummary>>('/scheduled-messages', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡은 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id. 엔진에서 취소한 뒤 그 메일을 임시보관함으로 옮긴다
|
||||
* → { draftId }(같은 id — 쓰기 화면이 이어 쓴다). 이미 보냈으면 409 MAIL_014 (아무것도 바꾸지 않는다), 없는 메일은 404 RES_001
|
||||
*/
|
||||
function cancelScheduledMessage(id: string, options?: MailRequestOptions): Promise<ScheduledMessageCancel> {
|
||||
return api.delete<ScheduledMessageCancel, ScheduledMessageCancel>(`/scheduled-messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,6 +457,8 @@ export function useMail() {
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
sendMessage,
|
||||
listScheduledMessages,
|
||||
cancelScheduledMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
downloadAttachment,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { MAIL_IMAGES_PARAM, MAIL_IMAGES_SHOW } from '@/shared/constants/mail'
|
||||
import { MAIL_PRINT_ROUTE, MAIL_PRINT_SHORTCUT_KEYS } from '@/shared/constants/mail-print'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { currentPlatform, formatShortcut, isApplePlatform, isShortcutEvent } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
/**
|
||||
* 인쇄 — 읽기 화면 ⋯ › 인쇄 · Ctrl+P(맥 ⌘P) (디자인 README "9단계 › 인쇄 화면" · 계약 2절 "인쇄").
|
||||
* 새 탭(noopener — 새 탭이 이 창을 건드리지 못하게)에 인쇄 화면(/mail/print/<id> — 앱 틀 없음)을 연다. 인쇄 화면이 본문을 인쇄 폭에 맞춰 잰 뒤
|
||||
* 브라우저의 인쇄 창을 띄운다(MailPrintPage). 읽기 화면에서 외부 이미지를 보이고 있었으면 ?images=show — 인쇄에도 그대로 보인다
|
||||
*/
|
||||
export function useMailPrint() {
|
||||
const router = useRouter()
|
||||
const apple = isApplePlatform(currentPlatform())
|
||||
|
||||
/** 메뉴에 보이는 단축키 — "Ctrl+P" · "⌘P" */
|
||||
const shortcut = formatShortcut(MAIL_PRINT_SHORTCUT_KEYS, apple)
|
||||
|
||||
function printLocation(message: Pick<MessageDetail, 'id' | 'imagesShown'>): RouteLocationRaw {
|
||||
return {
|
||||
name: MAIL_PRINT_ROUTE,
|
||||
params: { messageId: message.id },
|
||||
query: message.imagesShown ? { [MAIL_IMAGES_PARAM]: MAIL_IMAGES_SHOW } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function openPrint(message: Pick<MessageDetail, 'id' | 'imagesShown'>): void {
|
||||
window.open(router.resolve(printLocation(message)).href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** 인쇄 단축키인가 — 한글 조합 중 키 · 이미 처리한 키는 아니다 */
|
||||
function isPrintShortcut(event: KeyboardEvent): boolean {
|
||||
return !event.defaultPrevented && !event.isComposing && isShortcutEvent(event, MAIL_PRINT_SHORTCUT_KEYS, apple)
|
||||
}
|
||||
|
||||
return { shortcut, printLocation, openPrint, isPrintShortcut }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useApi } from './useApi'
|
||||
import type { BlockedSender, MailBlockList, MailSettings } from '@/shared/types/mail'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type MailSettingsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
/** 경로에 넣는 값은 항상 인코딩한다 — 도메인 차단 "@spam.example" 은 "%40spam.example" */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
// 보내기 · 읽기 설정 · 보낸 사람 차단 API Composable — docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" · "보낸 사람 차단" (9단계, 모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 컴포넌트는 이것을 직접 부르지 않고 스토어(mail-settings.store · mail-block.store)를 거친다
|
||||
export function useMailSettings() {
|
||||
const api = useApi()
|
||||
|
||||
/** 직원의 설정 — 저장한 적 없으면 처음 값(10초 · 늘 묻기) */
|
||||
function getSettings(options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.get<MailSettings, MailSettings>('/mail-settings', options)
|
||||
}
|
||||
|
||||
/** 고른 칸만 보낸다(하나 이상) — 응답은 저장한 뒤의 설정 전체 */
|
||||
function updateSettings(patch: Partial<MailSettings>, options?: MailSettingsRequestOptions): Promise<MailSettings> {
|
||||
return api.patch<MailSettings, MailSettings>('/mail-settings', patch, options)
|
||||
}
|
||||
|
||||
/** 차단한 주소 · 도메인 — 최근에 차단한 것부터 500개까지 */
|
||||
function getBlocks(options?: MailSettingsRequestOptions): Promise<MailBlockList> {
|
||||
return api.get<MailBlockList, MailBlockList>('/mail-blocks', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 — 주소 또는 "@도메인". 201(이미 있으면 200) BlockedSender. 형식 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 ·
|
||||
* 500개 초과는 400 VAL_001, 엔진 규칙 한도는 400 MAIL_013
|
||||
*/
|
||||
function addBlock(address: string, options?: MailSettingsRequestOptions): Promise<BlockedSender> {
|
||||
return api.post<BlockedSender, BlockedSender>('/mail-blocks', { address }, options)
|
||||
}
|
||||
|
||||
/** 차단 풀기 — 없으면 404 RES_001 */
|
||||
function removeBlock(address: string, options?: MailSettingsRequestOptions): Promise<{ address: string }> {
|
||||
return api.delete<{ address: string }, { address: string }>(`/mail-blocks/${segment(address)}`, options)
|
||||
}
|
||||
|
||||
return { getSettings, updateSettings, getBlocks, addBlock, removeBlock }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { listLocationOf, messageLocationOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
|
||||
@@ -94,13 +94,25 @@ export function useMessageRemoval() {
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
|
||||
/**
|
||||
* 그 메일을 아직 그 목록에서 보고 있는가 — 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 아니다
|
||||
* (그때 옛 목록의 다음 메일로 바꿔 끼우면 사용자가 연 화면을 빼앗는다 — 2026-10-29 리뷰)
|
||||
*/
|
||||
function stillShowing(source: MailListSource | null, messageId: string): boolean {
|
||||
return sameListSource(mailStore.listSource, source) && mailStore.selectedId === messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록, messageId 는 뺀 메일.
|
||||
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면 움직이지 않는다 (stillShowing)
|
||||
*/
|
||||
async function leaveRemoved(
|
||||
outcome: MessageRemovalOutcome,
|
||||
source: MailListSource | null,
|
||||
layout: 'split' | 'list',
|
||||
messageId: string,
|
||||
): Promise<void> {
|
||||
if (!outcome.removed || !source) return
|
||||
if (!outcome.removed || !source || !stillShowing(source, messageId)) return
|
||||
if (layout === 'list') {
|
||||
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
@@ -109,5 +121,5 @@ export function useMessageRemoval() {
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
}
|
||||
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, stillShowing, leaveRemoved }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { MessageSummary, PendingSend } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { usePendingSendRefresh } from './usePendingSendRefresh'
|
||||
|
||||
// 엔진이 붙잡은 발송을 풀 때(보낼 시각) 다시 보기 (계약 2절 "보내기 취소 · 예약 발송" · 2026-10-29 리뷰 #2)
|
||||
// 새 메일 확인 · 실시간 알림의 state 는 엔진이 풀어도 바뀌지 않는다 — 보낼 시각 + 2초에 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다.
|
||||
// 손으로 돌리는 시계로 시간을 넘긴다 (진짜 타이머 · 고정 대기 없이)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const NOW = Date.UTC(2026, 9, 29, 6, 0, 0)
|
||||
|
||||
function summary(id: string, pendingSend: PendingSend | null): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['sent'],
|
||||
from: { name: '김아라', email: USER.email },
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-29T05:59:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend,
|
||||
}
|
||||
}
|
||||
|
||||
const at = (ms: number) => new Date(NOW + ms).toISOString()
|
||||
const page = (items: MessageSummary[]) => ok({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
|
||||
|
||||
describe('usePendingSendRefresh — 보낼 시각이 지나면 다시 본다', () => {
|
||||
let clock: ManualClock
|
||||
let scope: EffectScope
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(NOW)
|
||||
scope = effectScope()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
restoreApiAdapter()
|
||||
useToastStore().clear()
|
||||
})
|
||||
|
||||
/** 보낸편지함 목록에 붙잡은 발송 하나를 두고 시작한다 — 서버는 sent() 가 주는 목록을 돌려준다 */
|
||||
function start(initial: MessageSummary[], sent: () => MessageSummary[] = () => initial) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/scheduled-messages') return page([])
|
||||
if (request.url === '/messages') return page(sent())
|
||||
return ok({})
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'sent' }
|
||||
store.messages = initial
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: initial.length }
|
||||
store.listStatus = 'ready'
|
||||
scope.run(() => usePendingSendRefresh({ clock }))
|
||||
return store
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
const countRequests = () => requests.filter((request) => request.url === '/scheduled-messages')
|
||||
|
||||
async function advance(ms: number) {
|
||||
clock.advance(ms)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('보낼 시각 + 2초에 한 번 — 보이는 목록 · 예약됨 수를 알리지 않고 다시 보고, 풀렸으면 "보내는 중"이 내려간다', async () => {
|
||||
const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
|
||||
const store = start([held], () => [summary('u1', null)])
|
||||
|
||||
await advance(11_999)
|
||||
expect(listRequests()).toEqual([])
|
||||
|
||||
await advance(1)
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
expect(listRequests()[0]?.config.silent).toBe(true)
|
||||
expect(countRequests()).toHaveLength(1)
|
||||
expect(store.messages[0]?.pendingSend).toBeNull()
|
||||
// 지켜볼 것이 없다 — 더 걸지 않는다
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('다시 본 뒤에도 시각이 지난 채 남아 있으면 5초마다 세 번 더 보고, 그래도 남으면 멈춘다', async () => {
|
||||
const held = summary('u1', { kind: 'undo', sendAt: at(10_000) })
|
||||
start([held])
|
||||
|
||||
await advance(12_000)
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
for (let recheck = 1; recheck <= 3; recheck += 1) {
|
||||
await advance(4_999)
|
||||
expect(listRequests()).toHaveLength(recheck)
|
||||
await advance(1)
|
||||
expect(listRequests()).toHaveLength(recheck + 1)
|
||||
}
|
||||
|
||||
await advance(60_000)
|
||||
expect(listRequests()).toHaveLength(4)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('처음부터 시각이 지나 보이는 발송(이 PC 시계가 빠름)은 바로 묻지 않고 5초 뒤부터 다시 본다', async () => {
|
||||
start([summary('u1', { kind: 'undo', sendAt: at(-60_000) })], () => [summary('u1', null)])
|
||||
|
||||
await advance(4_999)
|
||||
expect(listRequests()).toEqual([])
|
||||
await advance(1)
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('가장 이른 시각부터 차례로 — 앞 것이 풀리면 다음 것의 시각에 다시 잡는다', async () => {
|
||||
let released = new Set<string>()
|
||||
const items = [summary('u1', { kind: 'undo', sendAt: at(5_000) }), summary('s1', { kind: 'scheduled', sendAt: at(60_000) })]
|
||||
start(items, () => items.map((item) => (released.has(item.id) ? { ...item, pendingSend: null } : item)))
|
||||
|
||||
released = new Set(['u1'])
|
||||
await advance(7_000)
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
|
||||
released = new Set(['u1', 's1'])
|
||||
await advance(54_999)
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
await advance(1)
|
||||
expect(listRequests()).toHaveLength(2)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('목록 밖의 메일도 — 열린 메일 · 가장 이른 예약(예약됨 수의 첫 줄)의 시각도 지켜본다', async () => {
|
||||
const store = start([])
|
||||
store.nextScheduled = summary('s9', { kind: 'scheduled', sendAt: at(30_000) })
|
||||
await flushPromises()
|
||||
|
||||
await advance(32_000)
|
||||
expect(countRequests()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('메일 화면이 사라지면 멈춘다', async () => {
|
||||
start([summary('u1', { kind: 'undo', sendAt: at(10_000) })])
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
scope.stop()
|
||||
await advance(20_000)
|
||||
|
||||
expect(listRequests()).toEqual([])
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed, onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import {
|
||||
PENDING_RELEASE_GRACE_MS,
|
||||
PENDING_RELEASE_MAX_RECHECKS,
|
||||
PENDING_RELEASE_RECHECK_MS,
|
||||
} from '@/shared/constants/mail-send-later'
|
||||
import {
|
||||
nextPendingCheckAt,
|
||||
overduePendingWatches,
|
||||
pendingWatchesOf,
|
||||
type PendingWatch,
|
||||
} from '@/shared/utils/pending-send'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 엔진이 붙잡아 둔 발송을 풀 때(보냄) 화면을 다시 맞춘다 (계약 2절 "보내기 취소 · 예약 발송" — 9단계, 메일 화면 MailLayout 이 시작)
|
||||
// 엔진이 풀어도 새 메일 확인(GET /mail-state) · 실시간 알림의 state 는 바뀌지 않는다 — 그대로 두면 보낸편지함의 "보내는 중" · "예약 · 시각",
|
||||
// 패널의 「예약됨」 수, 읽기 화면의 청록 줄이 계속 남고, 그 메일의 [고치기] · [예약 취소]가 매번 "이미 보낸 메일"(MAIL_014)이 된다 (2026-10-29 리뷰).
|
||||
// - 지켜보는 것: 보이는 목록의 줄 · 열린 메일 · 가장 이른 예약(예약됨 수를 물은 응답의 첫 줄)의 pendingSend.sendAt — 같은 메일은 하나로 본다
|
||||
// - 그 가운데 가장 이른 시각 + 2초에 한 번 다시 본다(스토어의 refreshPendingSends — 보이는 목록 · 열린 메일 · 예약됨 수, 알리지 않는다)
|
||||
// - 다시 본 뒤에도 시각이 지난 채 남아 있으면(서버가 늦음 · 이 PC 시계가 빠름) 5초마다 세 번까지 더 보고, 그래도 남으면 그 발송은 그만 본다
|
||||
// (다음 새 메일 확인 · 화면 이동이 맞춘다 — 같은 요청을 끝없이 보내지 않게). 처음부터 시각이 지나 보이는 것(시계가 어긋남)도 5초 뒤부터 같다
|
||||
// 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다 (test-utils/manual-clock.ts)
|
||||
|
||||
export interface PendingSendRefreshOptions {
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export function usePendingSendRefresh(options: PendingSendRefreshOptions = {}) {
|
||||
const mailStore = useMailStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
let timer: ClockTimer | null = null
|
||||
let refreshing = false
|
||||
let stopped = false
|
||||
/** 다시 본 뒤에도 시각이 지난 채 남은 차례 (연달아) — 넘으면 그때 남은 발송은 그만 본다 */
|
||||
let overdueStreak = 0
|
||||
/** 그만 보는 발송 (PendingWatch.key) — 지켜볼 것에서 사라지면 잊는다 */
|
||||
const givenUp = new Set<string>()
|
||||
|
||||
const watches = computed<PendingWatch[]>(() =>
|
||||
pendingWatchesOf([...mailStore.messages, mailStore.selected, mailStore.nextScheduled]),
|
||||
)
|
||||
|
||||
/** 지금 지켜볼 것 — 그만 보기로 한 것은 빼고, 사라진 것은 잊는다 */
|
||||
function activeWatches(): PendingWatch[] {
|
||||
const current = watches.value
|
||||
const keys = new Set(current.map((watch) => watch.key))
|
||||
for (const key of givenUp) if (!keys.has(key)) givenUp.delete(key)
|
||||
return current.filter((watch) => !givenUp.has(watch.key))
|
||||
}
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 다음에 다시 볼 때를 잡는다 — 다시 보는 중이면 끝난 뒤에 잡는다 */
|
||||
function plan() {
|
||||
cancelTimer()
|
||||
if (stopped || refreshing) return
|
||||
const now = clock.now()
|
||||
const at = nextPendingCheckAt(activeWatches(), now, {
|
||||
graceMs: PENDING_RELEASE_GRACE_MS,
|
||||
recheckMs: PENDING_RELEASE_RECHECK_MS,
|
||||
})
|
||||
if (at === null) return
|
||||
timer = clock.setTimeout(
|
||||
() => {
|
||||
timer = null
|
||||
void refresh()
|
||||
},
|
||||
Math.max(0, at - now),
|
||||
)
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
refreshing = true
|
||||
try {
|
||||
await mailStore.refreshPendingSends()
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
if (stopped) return
|
||||
const overdue = overduePendingWatches(activeWatches(), clock.now(), PENDING_RELEASE_GRACE_MS)
|
||||
if (overdue.length === 0) {
|
||||
overdueStreak = 0
|
||||
} else {
|
||||
overdueStreak += 1
|
||||
if (overdueStreak > PENDING_RELEASE_MAX_RECHECKS) {
|
||||
for (const watch of overdue) givenUp.add(watch.key)
|
||||
overdueStreak = 0
|
||||
}
|
||||
}
|
||||
plan()
|
||||
}
|
||||
|
||||
// 지켜볼 것이 바뀌면(새로 보낸 메일 · 다른 목록 · 다른 메일 · 다시 받은 예약됨 수) 다시 잡는다 — 같은 것이면 그대로 둔다
|
||||
watch(
|
||||
() => watches.value.map((watch) => watch.key).join(' '),
|
||||
() => plan(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
|
||||
// 펼침 메뉴를 아래로 열지 위로 열지 — 메뉴를 그린 직후(초점을 주기 전) 아래로 그린 메뉴를 재어, 보이는 영역 아래가 모자라면 위로 연다
|
||||
// (판단은 순수 함수 popup-placement.ts 의 opensUpward, 여기서는 재기만 한다).
|
||||
// 쓰는 곳: 메일함 · 주소록 그룹 줄의 ⋯ (MailboxMoreButton — 스크롤되는 패널 안), 쓰기 화면의 [보내기 ▾] (ComposeFooter — 화면 맨 아래 단추 줄.
|
||||
// 아래로 열면 메뉴가 화면 밖에 생기고 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
|
||||
// 메뉴는 EditorPopup 이 연 뒤 다음 틱에 초점을 준다 — 그 전에 위아래가 정해진다 (같은 그리기 차례 안의 post 감시)
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
/** 요소가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
}
|
||||
return { top, bottom }
|
||||
}
|
||||
|
||||
export interface PopupPlacementOptions {
|
||||
/** 메뉴가 열려 있다 */
|
||||
open: Ref<boolean>
|
||||
/** 연 단추 */
|
||||
trigger: () => HTMLElement | null
|
||||
/** 그린 메뉴 (열린 동안) */
|
||||
popup: () => HTMLElement | null
|
||||
}
|
||||
|
||||
/** upward — 위로 연다 (쓰는 쪽이 메뉴에 위로 여는 class 를 붙인다). 닫으면 다음에 열 때 다시 아래로 재기 시작한다 */
|
||||
export function usePopupPlacement(options: PopupPlacementOptions) {
|
||||
const upward = ref<boolean>(false)
|
||||
|
||||
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
|
||||
function place() {
|
||||
const button = options.trigger()
|
||||
const menu = options.popup()
|
||||
if (!button || !menu) return
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const area = visibleArea(button)
|
||||
upward.value = opensUpward({
|
||||
triggerTop: buttonRect.top,
|
||||
triggerBottom: buttonRect.bottom,
|
||||
popupHeight: menuRect.height,
|
||||
areaTop: area.top,
|
||||
areaBottom: area.bottom,
|
||||
gap: Math.max(0, menuRect.top - buttonRect.bottom),
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
options.open,
|
||||
(isOpen) => {
|
||||
upward.value = false
|
||||
if (isOpen) place()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
return { upward }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { isNavigationFailure, useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMessageRemoval } from './useMessageRemoval'
|
||||
import { useToast } from './useToast'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 붙잡아 둔 발송을 다루는 일 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계") — 모두 DELETE /scheduled-messages/{id} 한 길이다.
|
||||
* 취소한 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id — draftId). 실패(이미 보냄 409 MAIL_014 — "이미 보낸 메일이라 취소할 수 없습니다." ·
|
||||
* 없음 404)는 인터셉터가 알린다 — 여기서는 아무것도 바꾸지 않는다(MAIL_014 면 스토어가 보이는 목록 · 열린 메일 · 예약됨 수를 다시 맞춘다).
|
||||
* - [보내기 취소](보낸 뒤 알림): 쓰기 화면을 ?draft=<draftId> 로 다시 연다(새 발송 키 — 새 양식) + "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다."
|
||||
* - [고치기](예약 안내 줄): 예약을 취소하고 쓰기 화면으로 + "예약을 취소했습니다. 고친 뒤 다시 보내 주세요."
|
||||
* 두 경우 모두 쓰기 화면으로 가지 못했으면(지금 화면의 나가기 확인에서 머물기로 함 등) 그 자리에 두고
|
||||
* "…취소하고 임시보관함으로 옮겼습니다." + [고치기](그 메일로 쓰기 화면) — 취소는 이미 됐다
|
||||
* - [예약 취소](예약 안내 줄): 임시보관함으로 + "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] — 삭제처럼 목록에서 빠진 뒤의 이동
|
||||
* - [삭제](아직 보내지 않은 메일 — 보내는 중 · 예약, 확인 창 뒤): 발송을 취소한 뒤 휴지통으로 + "보내지 않고 휴지통으로 옮겼습니다."
|
||||
* 같은 메일의 취소가 이미 진행 중이면(빠르게 두 번 누름 · 두 곳에서 누름) 두 번째는 아무것도 하지 않는다 (스토어도 요청을 하나만 보낸다).
|
||||
* 세션은 일을 시작할 때 잡는다 — 기다리는 사이 바뀌었으면(로그아웃 · 다른 계정) 이동 · 알림을 하지 않는다(앞 사용자의 메일을 새 세션에 열지 않게).
|
||||
* 목록에서 빠진 뒤의 이동은 그 메일을 아직 그 목록에서 보고 있을 때만 한다 (useMessageRemoval 의 leaveRemoved — 그사이 다른 곳으로 간 사용자를 끌고 가지 않게)
|
||||
*/
|
||||
export function useScheduledMessageActions() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
function draftLocation(draftId: string): RouteLocationRaw {
|
||||
return { name: 'mail-compose', query: { draft: draftId } }
|
||||
}
|
||||
|
||||
/** 쓰기 화면으로 그 메일을 연다 — 가지 못했으면(나가기 확인에서 머묾 · 다른 이동에 밀림) false */
|
||||
async function openDraft(draftId: string): Promise<boolean> {
|
||||
return !isNavigationFailure(await router.push(draftLocation(draftId)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 — 성공하면 draftId, 실패(인터셉터가 알렸다) · 그사이 세션이 바뀌었으면 · 같은 메일의 취소가 이미 진행 중이면 null.
|
||||
* session 은 부르는 쪽이 일을 시작할 때 잡은 세대다
|
||||
*/
|
||||
async function cancel(messageId: string, session: number): Promise<string | null> {
|
||||
if (mailStore.isCancellingPendingSend(messageId)) return null
|
||||
try {
|
||||
const { draftId } = await mailStore.cancelPendingSend(messageId)
|
||||
return authStore.generation === session ? draftId : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** [보내기 취소] — 보낸 뒤 알림의 단추 */
|
||||
async function undoSend(messageId: string): Promise<boolean> {
|
||||
const session = authStore.generation
|
||||
const draftId = await cancel(messageId, session)
|
||||
if (!draftId) return false
|
||||
if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.undone)
|
||||
else toast.success(SEND_LATER_TEXT.undoneStayed, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
|
||||
return true
|
||||
}
|
||||
|
||||
/** [고치기] — 예약을 취소하고 쓰기 화면으로 */
|
||||
async function editScheduled(messageId: string): Promise<boolean> {
|
||||
const session = authStore.generation
|
||||
const draftId = await cancel(messageId, session)
|
||||
if (!draftId) return false
|
||||
if (await openDraft(draftId)) toast.success(SEND_LATER_TEXT.editing)
|
||||
else toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
|
||||
return true
|
||||
}
|
||||
|
||||
/** 목록에서 빠졌는가 — 빠졌으면 삭제처럼 다음 메일(분할 보기) · 목록(목록 보기)으로 */
|
||||
function removedFromList(messageId: string): boolean {
|
||||
return !mailStore.messages.some((message) => message.id === messageId)
|
||||
}
|
||||
|
||||
/** [예약 취소] — 보내지 않고 임시보관함으로 */
|
||||
async function cancelScheduled(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
|
||||
const session = authStore.generation
|
||||
const source = mailStore.listSource
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const draftId = await cancel(message.id, session)
|
||||
if (!draftId) return false
|
||||
toast.success(SEND_LATER_TEXT.canceled, { label: SEND_LATER_TEXT.edit, to: draftLocation(draftId) })
|
||||
const removed = removedFromList(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* [삭제] — 아직 보내지 않은 메일(보내는 중 · 예약)의 확인 창에서 「삭제」를 누른 뒤에만 부른다.
|
||||
* 발송을 취소한 뒤 휴지통으로 옮긴다 (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다)
|
||||
*/
|
||||
async function deletePendingSend(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
|
||||
const session = authStore.generation
|
||||
const source = mailStore.listSource
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const draftId = await cancel(message.id, session)
|
||||
if (!draftId) return false
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if (authStore.generation !== session) return false
|
||||
if ('error' in trash) {
|
||||
// 발송은 취소했다 — 메일은 임시보관함에 있다 (휴지통을 몰라 옮기지 못했다)
|
||||
notifyApiError(trash.error)
|
||||
return false
|
||||
}
|
||||
if (!(await mailStore.moveMessage(draftId, trash.mailbox.id)) || authStore.generation !== session) return false
|
||||
toast.success(SEND_LATER_TEXT.deleted)
|
||||
const removed = removedFromList(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
|
||||
return true
|
||||
}
|
||||
|
||||
return { undoSend, editScheduled, cancelScheduled, deletePendingSend }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { objectParticle } from '@/shared/utils/korean'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 차단의 결과 — 메일을 스팸메일함으로 옮겼으면 moved (읽기 화면이 삭제처럼 다음 메일로 넘어간다) */
|
||||
export interface SenderBlockOutcome {
|
||||
blocked: boolean
|
||||
moved: boolean
|
||||
/**
|
||||
* 옮기기가 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있었다(여러 통 처리의 stale) — 옛 목록에 기대어 다음 메일로 넘어가지 않는다
|
||||
* (옮긴 것은 맞다 — 알림 · 되돌리기는 그대로)
|
||||
*/
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낸 사람 차단 · 풀기와 그 알림 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면 · 설정 › 차단한 주소").
|
||||
* - 차단(읽기 화면의 확인 창): POST /mail-blocks → 「이 메일도 스팸메일함으로 옮기기」를 켰으면 그 메일을 스팸메일함으로(isJunk: true — 스팸 신고와 같은 길) →
|
||||
* "‘주소’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 받은편지함에서 막았으면 받은편지함)
|
||||
* - 풀기(⋯ 「보낸 사람 차단 풀기」 · 스팸메일함의 [차단 풀기] · 설정의 [차단 풀기]): DELETE → "‘주소’ 차단을 풀었습니다." + [되돌리기](다시 차단).
|
||||
* 이미 받은 메일은 옮기지 않는다. 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 (스토어가 가른다)
|
||||
* 요청 실패는 인터셉터가 알린다 — 여기서는 알리지 않는다. 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast).
|
||||
* 기다리는 사이 세션이 바뀌었으면(로그아웃 · 다른 계정) 그 뒤의 일(메일 옮기기 · 알림)을 하지 않는다 — 앞 사용자의 주소가 새 세션에 보이지 않게
|
||||
*/
|
||||
export function useSenderBlock() {
|
||||
const authStore = useAuthStore()
|
||||
const blockStore = useMailBlockStore()
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 지금 세션에서 시작한 일이 그대로인지 — 시작할 때 잡아 둔 세대와 견준다 */
|
||||
function sessionGuard(): () => boolean {
|
||||
const session = authStore.generation
|
||||
return () => authStore.generation === session
|
||||
}
|
||||
|
||||
async function undoBlock(address: string, origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const sameSession = sessionGuard()
|
||||
try {
|
||||
await blockStore.remove(address)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!sameSession()) return
|
||||
if (origins.length > 0) await mailStore.restoreMoved(origins, { isJunk: false })
|
||||
if (!sameSession()) return
|
||||
toast.success(origins.length > 0 ? MAIL_BLOCK_TEXT.blockRestored(address) : MAIL_BLOCK_TEXT.unblocked(address))
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다)
|
||||
*/
|
||||
async function block(message: MessageSummary, target: string, moveToJunk: boolean): Promise<SenderBlockOutcome> {
|
||||
const sameSession = sessionGuard()
|
||||
let address: string
|
||||
try {
|
||||
address = (await blockStore.add(target)).address
|
||||
} catch {
|
||||
return { blocked: false, moved: false, stale: false }
|
||||
}
|
||||
if (!sameSession()) return { blocked: true, moved: false, stale: false }
|
||||
let origins: BulkOrigin[] = []
|
||||
let moved = false
|
||||
let stale = false
|
||||
if (moveToJunk) {
|
||||
const junk = await mailStore.resolveMailboxByRole('junk')
|
||||
if ('error' in junk) {
|
||||
notifyApiError(junk.error)
|
||||
} else if (!message.mailboxIds.includes(junk.mailbox.id)) {
|
||||
const result = await mailStore.moveMessages([message], junk.mailbox.id, { isJunk: true })
|
||||
moved = result.doneIds.includes(message.id)
|
||||
stale = result.stale
|
||||
origins = result.origins
|
||||
}
|
||||
}
|
||||
if (!sameSession()) return { blocked: true, moved: false, stale }
|
||||
const particle = objectParticle(address)
|
||||
toast.success(
|
||||
moved ? MAIL_BLOCK_TEXT.blockedAndMoved(address, particle) : MAIL_BLOCK_TEXT.blocked(address, particle),
|
||||
{ label: MAIL_BLOCK_TEXT.undo, run: () => void undoBlock(address, origins) },
|
||||
)
|
||||
return { blocked: true, moved, stale }
|
||||
}
|
||||
|
||||
async function reblock(address: string): Promise<void> {
|
||||
const sameSession = sessionGuard()
|
||||
try {
|
||||
await blockStore.add(address)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (sameSession()) toast.success(MAIL_BLOCK_TEXT.unblockRestored(address, objectParticle(address)))
|
||||
}
|
||||
|
||||
/** 차단 풀기 — 풀었으면 true (다른 곳에서 이미 풀었어도 — 404. 그 밖의 실패는 인터셉터가 알렸다) */
|
||||
async function unblock(address: string): Promise<boolean> {
|
||||
const sameSession = sessionGuard()
|
||||
try {
|
||||
await blockStore.remove(address)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!sameSession()) return false
|
||||
toast.success(MAIL_BLOCK_TEXT.unblocked(address), { label: MAIL_BLOCK_TEXT.undo, run: () => void reblock(address) })
|
||||
return true
|
||||
}
|
||||
|
||||
return { block, unblock }
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export interface ProgressToast {
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
/** 알림 옵션 — expiresAt(ms — Date.now)에 닫는다. 붙잡아도 멈추지 않고 남은 시간 막대가 보인다 (9단계 [보내기 취소]) */
|
||||
export interface ToastOptions {
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
@@ -38,17 +43,27 @@ export function useToast() {
|
||||
}
|
||||
}
|
||||
|
||||
function success(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
function success(message: string, action?: ToastAction, options: ToastOptions = {}): number {
|
||||
return store.show({
|
||||
kind: 'success',
|
||||
message,
|
||||
action: action ? sessionBound(action) : undefined,
|
||||
expiresAt: options.expiresAt ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면이 직접 띄우는 오류 알림 — API 오류가 아닌 실패를 알릴 때만 쓴다: 브라우저 상태(예: 바탕화면 알림을 막아 둠 + [설정 열기]),
|
||||
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함]).
|
||||
* 성공 응답에 담긴 일부 실패(예: 보냈지만 받는 사람 일부가 받지 못함 + [보낸편지함] · 붙잡아 둔 발송이면 [보내기 취소]).
|
||||
* API 오류는 인터셉터가 사전 문구로 이미 띄운다 — 호출부에서 다시 띄우지 않는다
|
||||
*/
|
||||
function error(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'error', message, action: action ? sessionBound(action) : undefined })
|
||||
function error(message: string, action?: ToastAction, options: ToastOptions = {}): number {
|
||||
return store.show({
|
||||
kind: 'error',
|
||||
message,
|
||||
action: action ? sessionBound(action) : undefined,
|
||||
expiresAt: options.expiresAt ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
|
||||
|
||||
Reference in New Issue
Block a user