generated from ttipo/project-base
feat(frontend): 설정(비밀번호 · 부재중 응답) · 메일함 관리 · 스팸 신고 · 원문 내려받기 화면
- 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인),
메일함 패널의 부재중 안내(1분마다 다시 계산)
- 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기
— 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다
- 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다
- 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,9 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
// 웹메일 4단계
|
||||
'AUTH_005', // 지금 비밀번호가 틀림 — 비밀번호 화면이 지금 비밀번호 칸 아래에 보인다
|
||||
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import type { AuthUser, LoginPayload } from '@/shared/types/auth'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginPayload } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
@@ -83,5 +85,18 @@ export function useAuth() {
|
||||
return authStore.restore(fetchSessionUser)
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession }
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
|
||||
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
|
||||
*/
|
||||
async function changePassword(
|
||||
payload: ChangePasswordPayload,
|
||||
options?: Pick<AxiosRequestConfig, 'silent'>,
|
||||
): Promise<void> {
|
||||
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
|
||||
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
@@ -27,6 +28,10 @@ export function useBulkMailActions() {
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
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'))
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
@@ -55,11 +60,11 @@ export function useBulkMailActions() {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
@@ -78,6 +83,18 @@ export function useBulkMailActions() {
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 스팸 신고 · 스팸 아님 — 옮기며 스팸 표시를 함께 바꾼다 (되돌리기는 원래 메일함 · 원래 표시로) */
|
||||
async function moveSpam(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const reportsSpam = !inJunk.value
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return
|
||||
}
|
||||
await moveTo(target.mailbox, { isJunk: reportsSpam })
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
@@ -91,5 +108,18 @@ export function useBulkMailActions() {
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
return {
|
||||
busy,
|
||||
allFlagged,
|
||||
inTrash,
|
||||
inJunk,
|
||||
spamTarget,
|
||||
moveTargets,
|
||||
markRead,
|
||||
toggleFlagged,
|
||||
moveTo,
|
||||
moveToTrash,
|
||||
moveSpam,
|
||||
purge,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AxiosRequestConfig } from 'axios'
|
||||
import { buildApiUrl, confirmSession, useApi } from './useApi'
|
||||
import {
|
||||
MAIL_IDEMPOTENCY_HEADER,
|
||||
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
@@ -14,7 +15,12 @@ import type {
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
MailVacation,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreatePayload,
|
||||
MailboxDeletion,
|
||||
MailboxRenamePayload,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
@@ -83,6 +89,59 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
// ── 메일함 관리 (계약 2절 "메일함 관리" — 4단계) ─────────────
|
||||
|
||||
/** 새 메일함 — 같은 자리에 같은 이름이면 409 MAIL_009, 없는 parentId 는 404 RES_001. parentId 가 없으면 맨 위 */
|
||||
function createMailbox(payload: MailboxCreatePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
const body: MailboxCreatePayload = payload.parentId ? payload : { name: payload.name }
|
||||
return api.post<Mailbox, Mailbox>('/mailboxes', body, options)
|
||||
}
|
||||
|
||||
/** 이름 바꾸기 — 기본 메일함은 400 VAL_001 (화면은 메뉴에 두지 않는다) */
|
||||
function renameMailbox(id: string, payload: MailboxRenamePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
|
||||
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
|
||||
*/
|
||||
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
|
||||
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 그 메일함의 안 읽은 메일을 읽음으로 — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function markMailboxRead(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.patch<MailboxBulkResult, MailboxBulkResult>(
|
||||
`/mailboxes/${segment(id)}/messages`,
|
||||
{ isRead: true },
|
||||
{ timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS, ...options },
|
||||
)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 한 요청에 500통씩, remaining 이 0 이 될 때까지 다시 보낸다 */
|
||||
function emptyMailbox(id: string, options?: MailRequestOptions): Promise<MailboxBulkResult> {
|
||||
return api.delete<MailboxBulkResult, MailboxBulkResult>(`/mailboxes/${segment(id)}/messages`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
|
||||
|
||||
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
|
||||
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
|
||||
}
|
||||
|
||||
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
|
||||
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
|
||||
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -248,9 +307,33 @@ export function useMail() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 원문(.eml) 주소 — 받은 그대로의 메일 message/rfc822 (표준 래퍼 없음, 4단계 "스팸 · 원문") */
|
||||
function messageSourceUrl(messageId: string): string {
|
||||
return buildApiUrl(`/messages/${segment(messageId)}/source`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 .eml 로 내려받는다 — 첨부 내려받기와 같이 이동하기 전에 세션을 확인한다 (confirmSession).
|
||||
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자·토스트)는 이미 끝났다.
|
||||
*/
|
||||
async function downloadMessageSource(messageId: string): Promise<boolean> {
|
||||
if (!(await confirmSession())) return false
|
||||
startBrowserDownload(messageSourceUrl(messageId))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
deleteMailbox,
|
||||
markMailboxRead,
|
||||
emptyMailbox,
|
||||
getVacation,
|
||||
saveVacation,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
|
||||
import {
|
||||
movedMessageText,
|
||||
notSpamMovedText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
@@ -15,8 +23,8 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
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))
|
||||
@@ -27,9 +35,12 @@ export function useMailMoveFeedback() {
|
||||
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)
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError, toApiError, type ApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { useToast } from './useToast'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { isSearchRouteName, listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { initialOperationTotal, mailboxOperationCopy, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
|
||||
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
|
||||
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
|
||||
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
|
||||
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
|
||||
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
|
||||
// 비우기 · 지우기는 되돌릴 수 없어 [되돌리기]가 없다. 확인 대화상자는 부르는 쪽이 먼저 띄운다.
|
||||
// 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다 (isBlocked — 단추 · 메뉴 항목을 끄고, 확인하는 순간에도 다시 본다)
|
||||
// - 끝난 뒤: 보고 있던 메일함을 지웠으면 받은편지함으로, 비운 메일함의 메일을 열어 두었으면 그 목록으로 (기록을 쌓지 않는다).
|
||||
// 비우기 · 지우기를 누른 단추(목록 머리 「비우기」 · 지운 메일함의 ⋯)가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 옮긴다
|
||||
|
||||
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
|
||||
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
|
||||
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
|
||||
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
|
||||
|
||||
export type MailboxNameResult =
|
||||
| { status: 'saved'; mailbox: Mailbox }
|
||||
/** inline: 대화상자가 칸 아래에 보일 실패 (알림을 끈 코드) */
|
||||
| { status: 'failed'; error: ApiError; inline: boolean }
|
||||
/** 기다리는 사이 세션이 바뀌었다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 초점이 갈 곳을 잃었는가 — 누른 단추가 사라졌거나 꺼졌다 */
|
||||
function focusIsLost(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body || !active.isConnected
|
||||
}
|
||||
|
||||
export function useMailboxActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
function isRunning(kind: MailboxOperationKind, mailboxId: string): boolean {
|
||||
return mailStore.isMailboxOperationRunning(kind, mailboxId)
|
||||
}
|
||||
|
||||
/** 지금은 시작할 수 없는가 — 휴지통 비우기 ↔ 메일함 지우기 (스토어의 isMailboxOperationBlocked) */
|
||||
function isBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailStore.isMailboxOperationBlocked(kind, mailbox)
|
||||
}
|
||||
|
||||
async function saveName(
|
||||
run: () => Promise<Mailbox | null>,
|
||||
done: (mailbox: Mailbox) => string,
|
||||
): Promise<MailboxNameResult> {
|
||||
try {
|
||||
const mailbox = await run()
|
||||
if (!mailbox) return { status: 'canceled' }
|
||||
toast.success(done(mailbox))
|
||||
return { status: 'saved', mailbox }
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
return { status: 'failed', error: apiError, inline: NAME_INLINE_CODES.includes(apiError.code) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 메일함 — parentId 가 없으면 맨 위 */
|
||||
function create(name: string, parentId: string | null): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.createMailbox(name, parentId, { silent: NAME_INLINE_CODES }),
|
||||
(mailbox) => MAILBOX_RESULT_TEXT.created(mailbox.name),
|
||||
)
|
||||
}
|
||||
|
||||
function rename(mailbox: Mailbox, name: string): Promise<MailboxNameResult> {
|
||||
return saveName(
|
||||
() => mailStore.renameMailbox(mailbox.id, name, { silent: NAME_INLINE_CODES }),
|
||||
() => MAILBOX_RESULT_TEXT.renamed,
|
||||
)
|
||||
}
|
||||
|
||||
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
|
||||
function isViewing(mailboxId: string): boolean {
|
||||
const route = router.currentRoute.value
|
||||
if (route.name === 'mail-mailbox' || route.name === 'mail-message') return route.params.mailboxId === mailboxId
|
||||
return isSearchRouteName(route.name) && scopeMailboxIdOf(mailStore.listSource) === mailboxId
|
||||
}
|
||||
|
||||
/** 끝난 뒤의 이동 (위 규칙) */
|
||||
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
|
||||
if (!isViewing(mailboxId)) return
|
||||
if (kind === 'delete') {
|
||||
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
return
|
||||
}
|
||||
const source = mailStore.listSource
|
||||
if (kind === 'empty' && source && router.currentRoute.value.params.messageId) {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
}
|
||||
|
||||
/** 누른 단추가 사라져 초점을 잃었으면 메일함 패널의 지금 메일함으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
|
||||
async function recoverFocus(): Promise<void> {
|
||||
await nextTick()
|
||||
if (!focusIsLost()) return
|
||||
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
|
||||
}
|
||||
|
||||
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
|
||||
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
|
||||
const copy = mailboxOperationCopy(kind, mailbox.name)
|
||||
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
|
||||
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
|
||||
progress.update(copy.progress(step.processed, step.total)),
|
||||
)
|
||||
if (outcome.status === 'canceled') {
|
||||
progress.dismiss()
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'failed') {
|
||||
if (!outcome.notified) notifyApiError(outcome.error)
|
||||
if (outcome.processed > 0) progress.fail(copy.failed(outcome.processed, outcome.total))
|
||||
else progress.dismiss()
|
||||
return
|
||||
}
|
||||
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
|
||||
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
|
||||
await moveAway(kind, mailbox.id)
|
||||
if (kind !== 'read') await recoverFocus()
|
||||
}
|
||||
|
||||
/** 모두 읽음으로 표시 */
|
||||
function markAllRead(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('read', mailbox)
|
||||
}
|
||||
|
||||
/** 휴지통 · 스팸메일함 비우기(영구 삭제) — 확인 대화상자를 거친 뒤에만 부른다 */
|
||||
function empty(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('empty', mailbox)
|
||||
}
|
||||
|
||||
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
|
||||
function remove(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('delete', mailbox)
|
||||
}
|
||||
|
||||
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
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 { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
|
||||
// (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문" · 계약 2절 "여러 통 한꺼번에" · "스팸 · 원문")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
|
||||
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
// - 뺀 뒤(leaveRemoved): 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
@@ -25,7 +34,9 @@ const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
@@ -37,6 +48,12 @@ export function useMessageRemoval() {
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
/** 스팸메일함에 든 메일 — 「스팸 신고」 대신 「스팸 아님」 */
|
||||
function isInJunk(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const junkId = mailStore.mailboxByRole('junk')?.id
|
||||
return Boolean(junkId && message.mailboxIds.includes(junkId))
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
@@ -63,5 +80,34 @@ export function useMessageRemoval() {
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
/** 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(받은편지함으로 + 스팸 아님 표시) — 그 메일의 메일함으로 정한다 */
|
||||
async function moveSpam(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const reportsSpam = !isInJunk(message)
|
||||
const target = await mailStore.resolveMailboxByRole(reportsSpam ? 'junk' : 'inbox')
|
||||
if ('error' in target) {
|
||||
notifyApiError(target.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
|
||||
feedback.announceMove(result, target.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 뺀 뒤의 이동 (위 규칙) — source 는 일을 시작할 때의 목록 */
|
||||
async function leaveRemoved(
|
||||
outcome: MessageRemovalOutcome,
|
||||
source: MailListSource | null,
|
||||
layout: 'split' | 'list',
|
||||
): Promise<void> {
|
||||
if (!outcome.removed || !source) return
|
||||
if (layout === 'list') {
|
||||
// 목록 보기에서는 빼면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
}
|
||||
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, leaveRemoved }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
|
||||
export interface ProgressToast {
|
||||
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
|
||||
update: (message: string) => void
|
||||
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
succeed: (message: string) => void
|
||||
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
|
||||
fail: (message: string) => void
|
||||
/** 알리지 않고 닫는다 */
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store). 진행 알림도 띄운 세션에서만 바뀐다
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
@@ -27,5 +39,26 @@ export function useToast() {
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
/** 진행 알림을 띄운다 — 저절로 닫히지 않는다. 끝나면 succeed · fail · dismiss 중 하나를 부른다 */
|
||||
function progress(message: string): ProgressToast {
|
||||
const session = authStore.generation
|
||||
let id = store.show({ kind: 'progress', message })
|
||||
const sameSession = () => authStore.generation === session
|
||||
|
||||
function settle(kind: TimedToastKind, text: string) {
|
||||
if (!sameSession()) return
|
||||
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
|
||||
}
|
||||
|
||||
return {
|
||||
update: (text) => {
|
||||
if (sameSession()) store.update(id, { message: text })
|
||||
},
|
||||
succeed: (text) => settle('success', text),
|
||||
fail: (text) => settle('error', text),
|
||||
dismiss: () => store.dismiss(id),
|
||||
}
|
||||
}
|
||||
|
||||
return { success, progress, dismiss: store.dismiss }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user