feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기

확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) ·
고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다.

- 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 —
  연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다
- 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다
- 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:53 +09:00
parent 7ce234750d
commit 894550732e
45 changed files with 5410 additions and 80 deletions
+3
View File
@@ -71,6 +71,9 @@ describe('에러 코드 사전', () => {
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
// 6단계 앞 수정
'MAIL_010', // 엔진이 받는 사람을 모두 거절(없는 주소 등) — 쓰기 화면이 받는 사람 칸 아래 띠에 보인다
// 웹메일 6단계
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
]
for (const code of usedByClient) {
+40 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
import type { MailFilterSet, RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { useMail } from './useMail'
@@ -40,3 +40,42 @@ describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
})
})
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
const SET: MailFilterSet = {
rules: [
{
id: '0f3c2a1e-7b8d-4c5e-9f10-2a3b4c5d6e7f',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
},
],
version: 'v1',
isActive: true,
}
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('읽기는 GET, 저장은 받은 version 과 목록 전체를 PUT 본문 { rules, version } 으로', async () => {
const requests = installMockApi((request) => ok(request.method === 'GET' ? SET : { ...SET, version: 'v2' }))
const mail = useMail()
await expect(mail.getFilters({ silent: true })).resolves.toEqual(SET)
await expect(mail.saveFilters({ rules: SET.rules, version: SET.version })).resolves.toMatchObject({ version: 'v2' })
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
'GET /mail-filters',
'PUT /mail-filters',
])
expect(requests[1]?.data).toEqual({ rules: SET.rules, version: 'v1' })
})
})
+19
View File
@@ -12,6 +12,8 @@ import type {
Draft,
DraftPayload,
DraftSaved,
MailFilterSet,
MailFilterSetPayload,
MailQuota,
MailSignature,
MailState,
@@ -145,6 +147,21 @@ export function useMail() {
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
}
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
/** 규칙 · 지문(version) · 동작 여부 — 저장해 둔 원본이 없거나 깨진 계정은 빈 규칙 + isActive: false 로 온다 */
function getFilters(options?: MailRequestOptions): Promise<MailFilterSet> {
return api.get<MailFilterSet, MailFilterSet>('/mail-filters', options)
}
/**
* 전체 교체 — 받은 version 을 그대로 돌려보낸다. 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었으면 저장하지 않고 409 MAIL_012,
* 만든 엔진 규칙이 한도를 넘으면 400 MAIL_013. 저장하면 꺼져 있던 규칙(isActive: false)도 다시 켜진다
*/
function saveFilters(payload: MailFilterSetPayload, options?: MailRequestOptions): Promise<MailFilterSet> {
return api.put<MailFilterSet, MailFilterSet>('/mail-filters', payload, options)
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -339,6 +356,8 @@ export function useMail() {
emptyMailbox,
getVacation,
saveVacation,
getFilters,
saveFilters,
messageSourceUrl,
downloadMessageSource,
getMailState,
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailFilterRule, MailFilterSet, MailFilterSetPayload } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailFilterActions } from './useMailFilterActions'
// 메일 필터의 지우기 · 되돌리기 알림 (디자인 원본 MailFilterStates "알림 문구" · 계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function rule(id: string, patch: Partial<MailFilterRule> = {}): MailFilterRule {
return {
id,
name: `필터 ${id}`,
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: id }],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
...patch,
}
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
function echo(request: MockRequest): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
describe('useMailFilterActions', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
async function setup(set: MailFilterSet, save: (request: MockRequest) => MockResult | Promise<MockResult> = echo) {
requests = installMockApi((request) => (request.method === 'GET' ? ok(set) : save(request)))
const store = useMailFilterStore()
await store.load()
return { store, actions: useMailFilterActions() }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const messages = () => useToastStore().toasts.map((toast) => toast.message)
function undo(toast: Toast | undefined) {
if (toast?.action && 'run' in toast.action) toast.action.run()
}
it('저장 하나를 기다리는 되돌리기 둘 — 둘 다 보내고 둘 다 성공 알림', async () => {
const toggle = deferred()
// 세 번째 PUT(켜기)만 손으로 끝낸다 — 앞의 둘은 지우기
const { store, actions } = await setup({ rules: [rule('a'), rule('b'), rule('c'), rule('d')], version: 'v1', isActive: true }, (request) =>
puts().length === 3 ? toggle.promise : echo(request),
)
await actions.remove('b')
await actions.remove('d')
const [removedB, removedD] = useToastStore().toasts
expect(messages()).toEqual(['‘필터 b’ 필터를 지웠습니다.', '‘필터 d’ 필터를 지웠습니다.'])
// 켜기가 저장되는 동안 두 알림의 [되돌리기]를 누른다
const switching = store.setRuleEnabled('a', false)
undo(removedB)
undo(removedD)
await flushPromises()
expect(puts()).toHaveLength(3)
toggle.finish(echo(requests[requests.length - 1] as MockRequest))
await switching
await flushPromises()
expect(puts().slice(3).map((payload) => payload.rules.map((item) => item.id))).toEqual([
['a', 'b', 'c'],
['a', 'b', 'c', 'd'],
])
expect(store.filterSet?.rules.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
expect(messages().filter((message) => message === '필터를 되돌렸습니다.')).toHaveLength(2)
})
it('필터 화면이 떠 있지 않을 때 되돌리기가 그사이 바뀜(409)으로 실패하면 알림으로 — 화면이 떠 있으면 목록 위 줄만', async () => {
let conflictNow = false
const { store, actions } = await setup({ rules: [rule('a'), rule('b')], version: 'v1', isActive: true }, (request) =>
conflictNow ? fail(409, 'MAIL_012') : echo(request),
)
await actions.remove('a')
await actions.remove('b')
const [removedA, removedB] = useToastStore().toasts
useToastStore().clear()
// 그사이 다른 곳에서 바꿨다 — 되돌리기는 409
conflictNow = true
// 화면이 떠 있다 — 목록 위 줄이 알린다
const detach = store.attachPage()
undo(removedA)
await flushPromises()
expect(store.conflict).toBe(true)
expect(messages()).toEqual([])
detach()
// 화면 밖 — 볼 곳이 없으니 알림으로
undo(removedB)
await flushPromises()
expect(messages()).toEqual([ERROR_CODE_MESSAGES.MAIL_012])
})
it('되돌리면 50개를 넘으면 보내지 않고 오류 알림 — "필터는 50개까지 만들 수 있습니다. …"', async () => {
const rules = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
const { store, actions } = await setup({ rules, version: 'v1', isActive: true })
await actions.remove('r0')
const [removed] = useToastStore().toasts
// 그사이 다른 곳에서 하나를 더 만들어 다시 50개가 됐다
await store.saveRule(rule('new'))
useToastStore().clear()
undo(removed)
await flushPromises()
expect(puts()).toHaveLength(2)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.'],
])
})
it('지운 알림의 이름 — 이름 없는 필터는 조건 글이 50자에서 잘리고 큰따옴표로 묶인다 (작은따옴표가 겹치지 않게)', async () => {
const long = rule('a', {
name: '',
conditions: [{ field: 'subject', operator: 'contains', value: '견적'.repeat(100) }],
})
const { actions } = await setup({ rules: [long], version: 'v1', isActive: true })
await actions.remove('a')
expect(messages()).toEqual([`“제목에 ‘${'견적'.repeat(22)}견…” 필터를 지웠습니다.`])
})
})
@@ -0,0 +1,59 @@
import { notifyApiError } from './useApi'
import { useToast } from './useToast'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { quotedRuleTitle, type MailFilterRestorePoint } from '@/shared/utils/mail-filter'
import {
useMailFilterStore,
type MailFilterRemoveOutcome,
type MailFilterSaveOutcome,
} from '@/stores/mail-filter.store'
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
// 실패는 인터셉터가 알린다. 그사이 바뀜(409)만은 알림을 끄고 목록 위 줄이 알리는데, 필터 화면이 떠 있지 않으면 볼 곳이 없으니
// 알림으로 알린다(고치기 창의 notifyUnseen 과 같은 규칙). 다시 넣으면 50개를 넘으면 보내지 않고 오류 알림(fullNote)
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
export function useMailFilterActions() {
const filterStore = useMailFilterStore()
const toast = useToast()
/** 고치기 창의 [저장] — 칸 안에 알릴 거절(VAL_001 · MAIL_013 · MAIL_012 · 50개)은 부르는 쪽이 그린다 */
async function save(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
const outcome = await filterStore.saveRule(rule)
if (outcome.ok) toast.success(MAIL_FILTER_TEXT.saved)
return outcome
}
async function restore(point: MailFilterRestorePoint): Promise<void> {
const outcome = await filterStore.restoreRule(point)
if (outcome.ok) {
toast.success(MAIL_FILTER_TEXT.restored)
return
}
if (outcome.full) {
toast.error(MAIL_FILTER_TEXT.fullNote)
return
}
// 되돌리기는 그사이 바뀜(MAIL_012)만 알림을 끈다 — 필터 화면이 떠 있으면 목록 위 줄이 알린다
if (outcome.error?.code === 'MAIL_012' && !filterStore.pageVisible) notifyApiError(outcome.error)
}
/** [이 필터 지우기] — 지웠으면 [되돌리기] 알림 */
async function remove(id: string): Promise<MailFilterRemoveOutcome> {
const outcome = await filterStore.removeRule(id)
if (outcome.ok) {
const { point } = outcome
toast.success(MAIL_FILTER_TEXT.removed(quotedRuleTitle(point.rule)), {
label: MAIL_FILTER_TEXT.undo,
run: () => void restore(point),
})
}
return outcome
}
return { save, remove }
}
@@ -0,0 +1,161 @@
import { onBeforeUnmount, ref } from 'vue'
import { notifyApiError, type ApiError } from './useApi'
import { useMailFilterActions } from './useMailFilterActions'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { useMailFilterStore } from '@/stores/mail-filter.store'
// 설정 › 필터의 고치기 창 상태 — 연 창 · 보내는 중 · 창 안의 거절 (MailFilterPage 가 setup 에서 부른다)
// - 저장 · 지우기 · 다시 불러오기는 한 번에 하나. 결과가 늦게 와도 그사이 닫고 다시 연 창에는 쓰지 않는다(연 차례 token)
// - 창 안에 보이는 거절은 형식(VAL_001) · 엔진 한도(MAIL_013) · 50개 넘음(fullNote) · 그사이 바뀜(MAIL_012 — 목록 위 줄과 함께)이다.
// 그 밖은 인터셉터가 알렸다. 알림을 끈 그 거절이 창(또는 화면)이 사라진 뒤에 오면 알림으로 알린다 — 그사이 바뀜은 화면이 남아 있으면 목록 위 줄이 알린다
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
export interface MailFilterDialogState {
/** 고칠 규칙 — null 이면 새 필터 */
rule: MailFilterRule | null
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
prefillFrom: string | null
/** 연 차례 */
token: number
}
/** closed — 끝나서 창을 닫았다(그 뒤의 초점은 focusRuleId 의 행, 없으면 화면이 정한다). false 면 창이 그대로다 */
export interface MailFilterDialogResult {
closed: boolean
focusRuleId: string | null
}
/** 창 안에 사전 문구로 보이는 저장 거절 */
const DIALOG_ERROR_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013', 'MAIL_012']
const KEPT: MailFilterDialogResult = { closed: false, focusRuleId: null }
export function useMailFilterDialog() {
const filterStore = useMailFilterStore()
const filterActions = useMailFilterActions()
const state = ref<MailFilterDialogState | null>(null)
const pending = ref<MailFilterDialogPending>(null)
const error = ref<string | null>(null)
/** 창 안의 거절이 그사이 바뀜(MAIL_012)이다 — 창 안에 [다시 불러오기]를 둔다 */
const conflict = ref<boolean>(false)
let token = 0
/** 화면이 사라졌다 — 창 안 · 목록 위 줄의 글을 볼 곳이 없다 */
let disposed = false
function showError(message: string | null, isConflict = false) {
error.value = message
conflict.value = message !== null && isConflict
}
function open(rule: MailFilterRule | null, prefillFrom: string | null = null) {
token += 1
state.value = { rule, prefillFrom, token }
pending.value = null
showError(null)
}
function close() {
state.value = null
pending.value = null
showError(null)
}
/** 칸을 고쳤다 — 창 안의 거절을 내린다. 그사이 바뀜은 다시 불러와야 풀리므로 그대로 둔다 */
function clearError() {
if (!conflict.value) showError(null)
}
/** 그 창이 아직 열려 있고 화면도 그대로다 — 결과를 창 안에 보일 수 있다 */
function isCurrent(opened: number): boolean {
return !disposed && state.value?.token === opened
}
function dialogMessageOf(apiError: ApiError | null): string | null {
return apiError && DIALOG_ERROR_CODES.includes(apiError.code) ? apiError.message : null
}
/** 알림을 끈 거절이 보일 곳 없이 왔다 — silenced: 그 요청이 알림을 끈 코드 */
function notifyUnseen(apiError: ApiError | null, silenced: readonly ErrorCode[]) {
if (!apiError || !silenced.includes(apiError.code)) return
if (disposed || apiError.code !== 'MAIL_012') notifyApiError(apiError)
}
/** [저장] — 지금 목록 위에 넣는다(있는 규칙은 그 자리, 새 규칙 · 다른 곳에서 지워진 규칙은 맨 아래 — 스토어의 saveRule) */
async function save(rule: MailFilterRule): Promise<MailFilterDialogResult> {
const current = state.value
if (!current || pending.value || filterStore.saving) return KEPT
pending.value = 'save'
showError(null)
const outcome = await filterActions.save(rule)
if (!isCurrent(current.token)) {
if (!outcome.ok) notifyUnseen(outcome.error, DIALOG_ERROR_CODES)
return KEPT
}
pending.value = null
if (outcome.ok) {
close()
return { closed: true, focusRuleId: rule.id }
}
if (outcome.full) showError(MAIL_FILTER_TEXT.fullNote)
else showError(dialogMessageOf(outcome.error), outcome.error?.code === 'MAIL_012')
return KEPT
}
/** [이 필터 지우기] — 확인 없이 (알림의 [되돌리기]는 useMailFilterActions) */
async function remove(): Promise<MailFilterDialogResult> {
const current = state.value
const target = current?.rule
if (!current || !target || pending.value || filterStore.saving) return KEPT
const rules = filterStore.filterSet?.rules ?? []
const index = rules.findIndex((rule) => rule.id === target.id)
// 다시 불러온 목록에 없다 — 다른 곳에서 이미 지웠다. 지울 것이 없으니 닫는다
if (index < 0) {
close()
return { closed: true, focusRuleId: null }
}
const neighbor = rules[index + 1]?.id ?? rules[index - 1]?.id ?? null
pending.value = 'remove'
showError(null)
const outcome = await filterActions.remove(target.id)
if (!isCurrent(current.token)) {
// 지우기는 그사이 바뀜만 알림을 끈다
if (!outcome.ok) notifyUnseen(outcome.error, ['MAIL_012'])
return KEPT
}
pending.value = null
if (outcome.ok) {
close()
return { closed: true, focusRuleId: neighbor }
}
if (outcome.error?.code === 'MAIL_012') showError(outcome.error.message, true)
return KEPT
}
/**
* 창 안의 [다시 불러오기] — 목록을 다시 받고 창 · 쓴 내용은 그대로 둔다. 받으면 창 안 안내와 목록 위 줄을 내리고 true.
* 못 받으면 인터셉터가 알리고 안내는 그대로 둔다
*/
async function reload(): Promise<boolean> {
const current = state.value
if (!current || pending.value) return false
pending.value = 'reload'
const done = await filterStore.reload()
if (!isCurrent(current.token)) return false
pending.value = null
if (done) showError(null)
return done
}
onBeforeUnmount(() => {
disposed = true
})
return { state, pending, error, conflict, open, close, clearError, save, remove, reload }
}