chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
45 changed files with 5410 additions and 80 deletions
Showing only changes of commit 894550732e - Show all commits
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
@@ -21,6 +22,8 @@ interface Props {
describedBy?: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
wide?: boolean
}
interface Emits {
@@ -33,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
messageId: undefined,
describedBy: undefined,
role: undefined,
wide: false,
})
const emit = defineEmits<Emits>()
@@ -97,7 +101,7 @@ onMounted(() => {
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@@ -135,6 +139,12 @@ onMounted(() => {
box-shadow: var(--shadow-dialog);
}
/* 넓은 창 — 칸이 많은 고치기 창 (디자인 원본 SettingsFilter: 폭 620 · 안쪽 여백 18 20 14) */
.modal-dialog--wide {
width: min(38.75rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-20) var(--space-14);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
@@ -178,13 +188,14 @@ onMounted(() => {
font-weight: 600;
}
:slotted(.modal-dialog__action:not(:disabled):hover) {
:slotted(.modal-dialog__action:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-brand-strong);
}
/*
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* 마우스를 올린 모양은 모두 :not(:disabled):not([aria-disabled='true']) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
* (초점을 지키려고 disabled 대신 aria-disabled 로 막은 단추 — 필터 고치기 창의 [저장] · [이 필터 지우기] — 도 같다)
*/
:slotted(.modal-dialog__action:disabled) {
opacity: 0.5;
@@ -196,7 +207,7 @@ onMounted(() => {
background: var(--color-danger);
}
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
:slotted(.modal-dialog__action--danger:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-danger-strong);
}
@@ -207,7 +218,7 @@ onMounted(() => {
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
:slotted(.modal-dialog__action--secondary:not(:disabled):not([aria-disabled='true']):hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
@@ -341,9 +341,9 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
])
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다 (꺼진 항목은 aria-disabled — 초점은 받는다)
const items = await openMenu(wrapper, '거래처')
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
await flushPromises()
@@ -388,7 +388,7 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
const items = await openMenu(wrapper, '받은편지함')
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
expect(items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])).toEqual([
['모두 읽음으로 표시', true],
['하위 메일함 만들기', false],
])
@@ -587,7 +587,7 @@ describe('MailboxPanel — 메일함 관리', () => {
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
const itemState = (items: DOMWrapper<Element>[]) =>
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
const second = deferred()
@@ -1,49 +1,79 @@
<script setup lang="ts">
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
import { useMail } from '@/composables/useMail'
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { useMailFilterStore } from '@/stores/mail-filter.store'
interface Props {
messageId: string
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
senderEmail?: string | null
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
senderEmail: null,
})
const { downloadMessageSource } = useMail()
const filterStore = useMailFilterStore()
const router = useRouter()
const buttonRef = ref<HTMLButtonElement | null>(null)
const open = ref<boolean>(false)
/** 키보드로 열었다 — 첫 항목에 초점 */
const byKeyboard = ref<boolean>(false)
const ITEMS: readonly EditorMenuItem[] = [
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
]
const sender = computed(() => props.senderEmail?.trim() ?? '')
const items = computed<EditorMenuItem[]>(() => {
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
if (sender.value) {
list.push({
id: 'filter',
label: MAIL_READER_MORE_TEXT.createFilter,
icon: 'filter',
disabled: filterStore.isFull,
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
})
}
return list
})
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
function openMenu(keyboard: boolean) {
byKeyboard.value = keyboard
open.value = true
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
if (sender.value) filterStore.ensureLoaded()
}
function onClick(event: MouseEvent) {
if (open.value) {
open.value = false
return
}
// detail 0 — Enter · Space 로 눌렀다
byKeyboard.value = event.detail === 0
open.value = true
openMenu(event.detail === 0)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || open.value) return
event.preventDefault()
byKeyboard.value = true
open.value = true
openMenu(true)
}
function onClose(restoreFocus: boolean) {
@@ -55,6 +85,9 @@ function onSelect(id: string) {
open.value = false
buttonRef.value?.focus()
if (id === 'source') void downloadMessageSource(props.messageId)
if (id === 'filter' && sender.value && !filterStore.isFull) {
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
}
}
</script>
@@ -78,7 +111,7 @@ function onSelect(id: string) {
v-if="open"
class="reader-more__menu"
:label="MAIL_READER_MORE_TEXT.button"
:items="ITEMS"
:items="items"
:trigger="trigger"
align="end"
:focus-content="!byKeyboard"
@@ -113,8 +146,9 @@ function onSelect(id: string) {
color: var(--color-text);
}
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
.reader-more__menu {
min-width: 12.5rem;
max-width: 17.5rem;
}
</style>
@@ -5,7 +5,7 @@ import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -47,6 +47,9 @@ const DETAIL: MessageDetail = {
attachments: [],
}
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
describe('MessageReader 도구 모음', () => {
let pinia: Pinia
@@ -487,12 +490,20 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
})
async function mountWith(
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
options: {
box?: string
mailboxes?: Mailbox[]
me?: MockResult
refresh?: MockResult
filters?: MailFilterSet
from?: MessageDetail['from']
} = {},
) {
const box = options.box ?? 'inbox'
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
requests = installMockApi((request) => {
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url === '/messages' && request.method === 'GET') {
@@ -504,7 +515,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
const store = useMailStore()
store.mailboxes = mailboxes
store.listSource = { kind: 'mailbox', mailboxId: box }
const open = { ...DETAIL, mailboxIds: [box] }
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
@@ -580,13 +591,14 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await more.trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/source'])
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement).toBe(more.element)
@@ -603,4 +615,94 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(downloads).toEqual([])
expect(useAuthStore().sessionExpired).toBe(true)
})
it('⋯ → 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터로 보낸 사람 주소를 싣고 간다', async () => {
const { wrapper, router } = await mountWith()
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
const item = wrapper.findAll('[role="menuitem"]')[1]
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
expect(item?.attributes('disabled')).toBeUndefined()
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
expect(router.currentRoute.value.query).toEqual({ from: 'doyun.lee@arasystem.it.kr' })
})
const fiftyFilters = (): MailFilterRule[] =>
Array.from({ length: 50 }, (_, index) => ({
id: `f${index}`,
name: '',
isEnabled: true,
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
}))
it('필터를 50개 다 만들었으면 그 항목을 끄고 까닭을 아래에 — 키보드로 가서 까닭은 듣되 고를 수 없다 (WAI-ARIA 메뉴)', async () => {
const { wrapper, router } = await mountWith({ filters: { rules: fiftyFilters(), version: 'v1', isActive: true } })
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const items = wrapper.findAll('[role="menuitem"]')
const item = items[1]
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
expect(item?.attributes('aria-disabled')).toBe('true')
expect(item?.attributes('disabled')).toBeUndefined()
const note = item?.get('.menu__note')
expect(note?.text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
expect(document.activeElement).toBe(items[0]?.element)
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement).toBe(item?.element)
await item?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
// 꺼진 항목을 골라도 메뉴는 그대로다
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
})
it('50개로 받아 둔 뒤 다른 곳에서 지웠으면 — ⋯ 를 다시 열 때 알림 없이 다시 받아 항목을 켠다', async () => {
const server: { filters: MailFilterSet } = { filters: { rules: fiftyFilters(), version: 'v1', isActive: true } }
const { wrapper } = await mountWith({
get filters() {
return server.filters
},
})
const more = wrapper.get('[data-reader-more]')
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBe('true')
await more.trigger('click')
server.filters = { rules: fiftyFilters().slice(1), version: 'v2', isActive: true }
await more.trigger('click')
await flushPromises()
expect(filterItem()?.attributes('aria-disabled')).toBeUndefined()
expect(filterItem()?.find('.menu__note').exists()).toBe(false)
expect(filterGets()).toHaveLength(2)
// 49개면 다시 받지 않는다
await more.trigger('click')
await more.trigger('click')
await flushPromises()
expect(filterGets()).toHaveLength(2)
expect(useToastStore().toasts).toEqual([])
})
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
const { wrapper } = await mountWith({ from: null })
await wrapper.get('[data-reader-more]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
})
})
@@ -3,7 +3,8 @@
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
import { computed } from 'vue'
@@ -110,7 +111,10 @@ function leaveToList() {
:message="message"
:layout="layout"
/>
<MessageMoreMenu :message-id="message.id" />
<MessageMoreMenu
:message-id="message.id"
:sender-email="message.from?.email ?? null"
/>
</template>
</div>
</div>
@@ -2,6 +2,10 @@
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
import { useId } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
@@ -28,9 +32,22 @@ withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<Emits>()
/** 이 메뉴의 고유 접두 — 까닭(note)의 id */
const menuId = useId()
function isToggle(role: string | undefined): boolean {
return role === 'menuitemcheckbox' || role === 'menuitemradio'
}
function noteId(itemId: string): string {
return `${menuId}-${itemId}-note`
}
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
function select(item: EditorMenuItem, event: MouseEvent) {
if (item.separator || item.disabled) return
emit('select', item.id, event.detail === 0)
}
</script>
<template>
@@ -53,12 +70,13 @@ function isToggle(role: string | undefined): boolean {
<button
v-else
type="button"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
:role="item.role ?? 'menuitem'"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:disabled="item.disabled"
@click="(event: MouseEvent) => emit('select', item.id, event.detail === 0)"
:aria-disabled="item.disabled ? 'true' : undefined"
:aria-describedby="item.note ? noteId(item.id) : undefined"
@click="(event: MouseEvent) => select(item, event)"
>
<AppIcon
v-if="item.icon"
@@ -71,7 +89,22 @@ function isToggle(role: string | undefined): boolean {
:color="item.colorGlyph.color"
class="menu__icon"
/>
<span :class="['menu__label', item.preview ? `preview--${item.preview}` : '']">{{ item.label }}</span>
<span
v-if="item.note"
class="menu__text"
>
<span class="menu__label">{{ item.label }}</span>
<!-- 이름에 섞이지 않게 숨기고 aria-describedby 로만 읽힌다 -->
<span
:id="noteId(item.id)"
class="menu__note"
aria-hidden="true"
>{{ item.note }}</span>
</span>
<span
v-else
:class="['menu__label', item.preview ? `preview--${item.preview}` : '']"
>{{ item.label }}</span>
<span
v-if="item.badge"
class="menu__badge"
@@ -117,11 +150,11 @@ function isToggle(role: string | undefined): boolean {
text-align: left;
}
.menu__item:hover:not(:disabled) {
.menu__item:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.menu__item:disabled {
.menu__item[aria-disabled='true'] {
opacity: 0.5;
cursor: default;
}
@@ -131,7 +164,7 @@ function isToggle(role: string | undefined): boolean {
color: var(--color-danger);
}
.menu__item--danger:hover:not(:disabled) {
.menu__item--danger:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
}
@@ -148,6 +181,32 @@ function isToggle(role: string | undefined): boolean {
min-width: 0;
}
/* 이름 + 아래 작은 글 (note) — 글은 메뉴 폭 안에서 줄을 바꾼다 */
.menu__text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.menu__note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
white-space: normal;
}
/* 까닭이 붙은 꺼진 항목 — 이름 · 아이콘만 흐리게, 까닭은 그대로 읽힌다 */
.menu__item--noted[aria-disabled='true'] {
opacity: 1;
}
.menu__item--noted[aria-disabled='true'] .menu__label,
.menu__item--noted[aria-disabled='true'] .menu__icon {
opacity: 0.5;
}
.menu__badge,
.menu__hint {
flex: 0 0 auto;
@@ -33,6 +33,10 @@ const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
/**
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
* disabled 단추는 초점을 받을 수 없어 건너뛴다
*/
const MENU_ITEM = '[role^="menuitem"]:not(:disabled)'
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex="0"]'
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
hint?: string
checked?: boolean
disabled?: boolean
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
note?: string
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
tone?: 'danger'
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
@@ -0,0 +1,278 @@
<script setup lang="ts">
// 고치기 창의 "할 일" (디자인 원본 SettingsFilter) — 줄마다 44:
// [x] 메일함으로 옮기기 + 옮길 메일함(40) · "옮기면 이 아래 필터는 적용하지 않습니다." / [x] 읽음으로 표시 / [x] 중요 표시 /
// [x] 동료에게 전달 + 사내 주소(40) · 안내(잘못이면 그 자리가 빨간 글)
// 끈 할 일의 칸은 흐리게 — 고른 메일함 · 쓴 주소는 남긴다. 할 일을 하나도 고르지 않았으면 묶음 아래 빨간 글(첫 칸이 그 글을 가리킨다).
// 옮길 메일함은 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(filterMoveTargets). 골라 둔 메일함이 지워졌으면
// "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로 보인다(다시 고를 수는 없다). 고를 수 없는 곳(보관함 등)을 가리키면 그 이름을 그대로 보인다
// 고정 id — #mail-filter-move · #mail-filter-move-target · #mail-filter-move-hint · #mail-filter-read · #mail-filter-flag ·
// #mail-filter-forward · #mail-filter-forward-to · #mail-filter-forward-help · #mail-filter-actions-error
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_FORWARD_MAX_LENGTH } from '@/shared/constants/mail-filter'
import type { Mailbox } from '@/shared/types/mail'
import type { MailFilterActionDraft } from '@/shared/utils/mail-filter'
interface Props {
value: MailFilterActionDraft
/** 고를 수 있는 메일함 — 이 차례로 보인다 */
targets: readonly Mailbox[]
/** 모든 메일함 — 골라 둔 메일함이 있는지 본다 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소의 도메인 — 주소 칸의 보기 */
domain: string
/** 할 일을 하나도 고르지 않았다 — 칸 문구 */
actionsError: string | null
/** 전달 주소의 잘못 — 칸 문구 (안내 자리에 빨간 글로) */
forwardError: string | null
/** 처음 초점을 첫 칸(메일함으로 옮기기)에 — 읽기 화면에서 만든 새 필터 */
autofocusFirst: boolean
}
interface Emits {
(e: 'update', patch: Partial<MailFilterActionDraft>): void
}
interface MoveOption {
id: string
label: string
disabled: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const moveRef = ref<HTMLInputElement | null>(null)
const forwardRef = ref<HTMLInputElement | null>(null)
const options = computed<MoveOption[]>(() => {
const list = props.targets.map((mailbox) => ({ id: mailbox.id, label: mailbox.name, disabled: false }))
const target = props.value.moveTarget
if (target === '' || list.some((option) => option.id === target)) return list
const known = props.mailboxes.find((mailbox) => mailbox.id === target)
const current = known
? { id: known.id, label: known.name, disabled: false }
: { id: target, label: MAIL_FILTER_DIALOG_TEXT.missingMailboxOption, disabled: true }
return [current, ...list]
})
function checked(event: Event): boolean {
return (event.target as HTMLInputElement).checked
}
function valueOf(event: Event): string {
return (event.target as HTMLInputElement | HTMLSelectElement).value
}
defineExpose({
/** 할 일이 없을 때 · 읽기 화면에서 만든 새 필터 — 첫 칸(메일함으로 옮기기)으로 */
focusFirst: () => moveRef.value?.focus(),
/** 전달 주소가 잘못됐을 때 */
focusForward: () => forwardRef.value?.focus(),
})
</script>
<template>
<fieldset class="filter-actions">
<legend class="filter-actions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.actionsLegend }}
</legend>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-move"
ref="moveRef"
type="checkbox"
class="filter-actions__box"
:checked="value.move"
:autofocus="autofocusFirst || undefined"
:aria-describedby="actionsError ? 'mail-filter-actions-error' : undefined"
@change="emit('update', { move: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.move }}</span>
</label>
<select
id="mail-filter-move-target"
class="form-input filter-actions__select"
:aria-label="MAIL_FILTER_DIALOG_TEXT.moveTarget"
aria-describedby="mail-filter-move-hint"
:value="value.moveTarget"
:disabled="!value.move"
@change="emit('update', { moveTarget: valueOf($event) })"
>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</div>
<p
id="mail-filter-move-hint"
class="form-message filter-actions__hint filter-actions__hint--spaced"
>
{{ MAIL_FILTER_DIALOG_TEXT.moveHint }}
</p>
<label class="filter-actions__check">
<input
id="mail-filter-read"
type="checkbox"
class="filter-actions__box"
:checked="value.markAsRead"
@change="emit('update', { markAsRead: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.markAsRead }}</span>
</label>
<label class="filter-actions__check">
<input
id="mail-filter-flag"
type="checkbox"
class="filter-actions__box"
:checked="value.flag"
@change="emit('update', { flag: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.flag }}</span>
</label>
<div class="filter-actions__line">
<label class="filter-actions__check filter-actions__check--lead">
<input
id="mail-filter-forward"
type="checkbox"
class="filter-actions__box"
:checked="value.forward"
@change="emit('update', { forward: checked($event) })"
>
<span>{{ MAIL_FILTER_DIALOG_TEXT.forward }}</span>
</label>
<input
id="mail-filter-forward-to"
ref="forwardRef"
class="form-input filter-actions__forward"
type="email"
:value="value.forwardTo"
:maxlength="MAIL_FILTER_FORWARD_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.forwardPlaceholder(domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.forwardTarget"
:aria-invalid="forwardError ? 'true' : 'false'"
aria-describedby="mail-filter-forward-help"
:disabled="!value.forward"
autocomplete="off"
spellcheck="false"
@input="emit('update', { forwardTo: valueOf($event) })"
>
</div>
<p
id="mail-filter-forward-help"
:class="['form-message', 'filter-actions__hint', { 'form-message--error': forwardError }]"
>
<AppIcon
v-if="forwardError"
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ forwardError ?? MAIL_FILTER_DIALOG_TEXT.forwardHint }}</span>
</p>
<p
v-if="actionsError"
id="mail-filter-actions-error"
class="form-message form-message--error filter-actions__error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ actionsError }}</span>
</p>
</fieldset>
</template>
<style scoped>
.filter-actions {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-actions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 고르기 + 칸 한 줄 — 좁으면 칸이 아래로 내려간다 */
.filter-actions__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-12);
min-height: var(--size-touch);
}
.filter-actions__check {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
/* 칸 앞의 이름 — 두 줄의 칸이 같은 선에서 시작한다 (176) */
.filter-actions__check--lead {
min-width: 11rem;
}
.filter-actions__box {
width: var(--size-option-input);
height: var(--size-option-input);
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
/* 칸 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-actions__select,
.filter-actions__forward {
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-10);
}
.filter-actions__select {
min-width: 12.5rem;
}
.filter-actions__forward {
flex: 1 1 15rem;
min-width: 0;
max-width: 20rem;
}
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 */
.filter-actions__hint {
margin-left: calc(var(--size-option-input) + var(--space-10));
}
.filter-actions__hint--spaced {
margin-bottom: var(--space-4);
}
.filter-actions__error {
margin-top: var(--space-4);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
// 고치기 창의 "어떤 메일에" (디자인 원본 SettingsFilter) — 고르기(모두 · 하나라도 · 모든 메일 — 44, 최대 320) + 조건 줄(MailFilterConditionRow) +
// [조건 더하기](40 — 5개까지, 새 줄은 제목) · "5개까지 · 글자는 대소문자를 가리지 않습니다". 모든 메일이면 조건 줄을 숨긴다(쓴 조건은 부모가 남겨 둔다).
// 줄을 더하면 새 줄의 "어디에서"로, 지우면 같은 자리(마지막이었으면 앞) 줄의 찾을 글로 초점을 옮긴다 — 누른 단추가 사라지거나 꺼지므로
// 고정 id — #mail-filter-match · #mail-filter-add-condition (줄의 오류 id 는 MailFilterConditionRow)
import { nextTick, type ComponentPublicInstance } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailFilterConditionRow from '@/components/settings/MailFilterConditionRow.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_MATCH_LABELS,
MAIL_FILTER_MAX_CONDITIONS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterMatch } from '@/shared/types/mail'
interface Props {
match: MailFilterMatch
conditions: readonly MailFilterCondition[]
/** 줄마다의 잘못 — 칸 문구 (차례는 conditions 와 같다) */
errors: readonly (string | null)[]
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기 */
domain: string
}
interface Emits {
(e: 'update:match', match: MailFilterMatch): void
(e: 'update-condition', index: number, patch: Partial<MailFilterCondition>): void
(e: 'add'): void
(e: 'remove', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const MATCHES = Object.keys(MAIL_FILTER_MATCH_LABELS) as MailFilterMatch[]
/** 조건 줄 — 차례대로 (잘못된 줄 · 새 줄에 초점을 준다) */
const rows: (InstanceType<typeof MailFilterConditionRow> | null)[] = []
function setRow(index: number, row: Element | ComponentPublicInstance | null) {
rows[index] = row as InstanceType<typeof MailFilterConditionRow> | null
}
function setMatch(event: Event) {
const match = MATCHES.find((candidate) => candidate === (event.target as HTMLSelectElement).value)
if (match) emit('update:match', match)
}
function add() {
if (props.conditions.length >= MAIL_FILTER_MAX_CONDITIONS) return
const added = props.conditions.length
emit('add')
void nextTick(() => rows[added]?.focusField())
}
function remove(index: number) {
if (props.conditions.length <= 1) return
const next = Math.min(index, props.conditions.length - 2)
emit('remove', index)
void nextTick(() => rows[next]?.focusValue())
}
defineExpose({
/** 잘못된 줄의 찾을 글 칸으로 */
focusValue: (index: number) => rows[index]?.focusValue(),
})
</script>
<template>
<fieldset class="filter-conditions">
<legend class="filter-conditions__legend">
{{ MAIL_FILTER_DIALOG_TEXT.matchLegend }}
</legend>
<select
id="mail-filter-match"
class="form-input filter-conditions__match"
:aria-label="MAIL_FILTER_DIALOG_TEXT.matchLegend"
:value="match"
@change="setMatch"
>
<option
v-for="option in MATCHES"
:key="option"
:value="option"
>
{{ MAIL_FILTER_MATCH_LABELS[option] }}
</option>
</select>
<template v-if="match !== 'always'">
<ul class="filter-conditions__list">
<MailFilterConditionRow
v-for="(condition, index) in conditions"
:key="index"
:ref="(row) => setRow(index, row)"
:condition="condition"
:index="index"
:can-remove="conditions.length > 1"
:error="errors[index] ?? null"
:domain="domain"
@update="(patch) => emit('update-condition', index, patch)"
@remove="remove(index)"
/>
</ul>
<div class="filter-conditions__add">
<button
id="mail-filter-add-condition"
type="button"
class="filter-conditions__add-button"
:disabled="conditions.length >= MAIL_FILTER_MAX_CONDITIONS"
@click="add"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="1.9"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.addCondition }}</span>
</button>
<span class="filter-conditions__hint">{{ MAIL_FILTER_DIALOG_TEXT.conditionHint }}</span>
</div>
</template>
</fieldset>
</template>
<style scoped>
.filter-conditions {
display: flex;
flex-direction: column;
gap: var(--space-8);
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.filter-conditions__legend {
margin: 0 0 var(--space-6);
padding: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-conditions__match {
max-width: 20rem;
}
.filter-conditions__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
}
.filter-conditions__add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
/* [조건 더하기] 높이 40 — 밀도 예외 (tokens.css "6단계") */
.filter-conditions__add-button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-filter-control);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-text-secondary);
font-weight: 500;
}
.filter-conditions__add-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-conditions__add-button:disabled {
color: var(--color-icon-muted);
cursor: not-allowed;
}
.filter-conditions__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
// 고치기 창의 조건 한 줄 (디자인 원본 SettingsFilter) — 어디에서(116) · 포함/포함하지 않음(132) · 찾을 글 · ✕(36×40). 칸 높이 40 — 밀도 예외(tokens.css "6단계").
// 잘못은 [저장]을 누를 때 그 줄 아래 빨간 글로 (디자인 원본 MailFilterStates) — 찾을 글 칸이 aria-invalid · aria-describedby 로 가리킨다.
// 조건이 하나뿐이면 ✕ 를 끈다 (모두 · 하나라도는 조건이 1개 이상)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import {
MAIL_FILTER_DIALOG_TEXT,
MAIL_FILTER_FIELD_LABELS,
MAIL_FILTER_OPERATOR_LABELS,
MAIL_FILTER_VALUE_MAX_LENGTH,
MAIL_FILTER_VALUE_PLACEHOLDERS,
} from '@/shared/constants/mail-filter'
import type { MailFilterCondition, MailFilterField, MailFilterOperator } from '@/shared/types/mail'
interface Props {
condition: MailFilterCondition
/** 0부터 — 칸 이름은 "조건 1 — …" */
index: number
canRemove: boolean
/** 이 줄의 잘못 (칸 · 사전 문구) — 없으면 null */
error: string | null
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기("예: all@<도메인>") */
domain: string
}
interface Emits {
(e: 'update', patch: Partial<MailFilterCondition>): void
(e: 'remove'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const FIELDS = Object.keys(MAIL_FILTER_FIELD_LABELS) as MailFilterField[]
const OPERATORS = Object.keys(MAIL_FILTER_OPERATOR_LABELS) as MailFilterOperator[]
const fieldRef = ref<HTMLSelectElement | null>(null)
const valueRef = ref<HTMLInputElement | null>(null)
const no = computed(() => props.index + 1)
const errorId = computed(() => `mail-filter-condition-${props.index}-error`)
function selected(event: Event): string {
return (event.target as HTMLSelectElement).value
}
function setField(event: Event) {
const field = FIELDS.find((candidate) => candidate === selected(event))
if (field) emit('update', { field })
}
function setOperator(event: Event) {
const operator = OPERATORS.find((candidate) => candidate === selected(event))
if (operator) emit('update', { operator })
}
function setValue(event: Event) {
emit('update', { value: (event.target as HTMLInputElement).value })
}
defineExpose({
/** 잘못된 줄 — 찾을 글 칸으로 */
focusValue: () => valueRef.value?.focus(),
/** 방금 더한 줄 — 어디에서 칸으로 */
focusField: () => fieldRef.value?.focus(),
})
</script>
<template>
<li class="filter-condition">
<div class="filter-condition__row">
<select
ref="fieldRef"
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionField(no)"
:value="condition.field"
data-filter-condition-field
@change="setField"
>
<option
v-for="field in FIELDS"
:key="field"
:value="field"
>
{{ MAIL_FILTER_FIELD_LABELS[field] }}
</option>
</select>
<select
class="form-input filter-condition__control"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionOperator(no)"
:value="condition.operator"
data-filter-condition-operator
@change="setOperator"
>
<option
v-for="operator in OPERATORS"
:key="operator"
:value="operator"
>
{{ MAIL_FILTER_OPERATOR_LABELS[operator] }}
</option>
</select>
<input
ref="valueRef"
class="form-input filter-condition__control filter-condition__value"
type="text"
:value="condition.value"
:maxlength="MAIL_FILTER_VALUE_MAX_LENGTH"
:placeholder="MAIL_FILTER_VALUE_PLACEHOLDERS[condition.field](domain)"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionValue(no)"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? errorId : undefined"
autocomplete="off"
data-filter-condition-value
@input="setValue"
>
<button
type="button"
class="filter-condition__remove"
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionRemove(no)"
:disabled="!canRemove"
data-filter-condition-remove
@click="emit('remove')"
>
<AppIcon
name="x"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
<p
v-if="error"
:id="errorId"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
</li>
</template>
<style scoped>
.filter-condition {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* 어디에서 116 · 포함/포함하지 않음 132 · 찾을 글 · ✕ 36 (디자인 원본 SettingsFilter) */
.filter-condition__row {
display: grid;
grid-template-columns: 7.25rem 8.25rem minmax(0, 1fr) var(--size-filter-remove);
align-items: center;
gap: var(--space-8);
}
/* 칸 높이 40 — 밀도 예외 */
.filter-condition__control {
min-width: 0;
min-height: var(--size-filter-control);
height: var(--size-filter-control);
padding: 0 var(--space-8);
}
.filter-condition__value {
padding: 0 var(--space-10);
}
.filter-condition__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-filter-remove);
height: var(--size-filter-control);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-condition__remove:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.filter-condition__remove:disabled {
color: var(--color-border-strong);
}
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
// [새 필터] — 설정 › 필터 머리 오른쪽 · 필터가 없을 때의 안내 안 (디자인 원본 SettingsFilter · MailFilterStates — 브랜드색 44, + 아이콘 15)
// 누를 수 없을 때(50개를 다 만들었다 · 아직 받는 중)는 disabled 가 아니라 aria-disabled — 고치기 창을 닫으면 초점이 이 단추로 돌아오기 때문이다.
// 까닭은 옆의 글을 describedBy 로 가리킨다
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Props {
disabled?: boolean
/** 누를 수 없는 까닭의 id */
describedBy?: string
}
interface Emits {
(e: 'create'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
describedBy: undefined,
})
const emit = defineEmits<Emits>()
const buttonRef = ref<HTMLButtonElement | null>(null)
function onClick() {
if (!props.disabled) emit('create')
}
defineExpose({
focus: () => buttonRef.value?.focus(),
})
</script>
<template>
<button
ref="buttonRef"
type="button"
class="filter-create"
:aria-disabled="disabled ? 'true' : undefined"
:aria-describedby="describedBy"
data-filter-create
@click="onClick"
>
<AppIcon
name="plus"
:size="15"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.create }}</span>
</button>
</template>
<style scoped>
.filter-create {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.filter-create:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
/* 꺼진 모양 (디자인 원본 MailFilterStates "50개를 다 만들었을 때") */
.filter-create[aria-disabled='true'] {
background: var(--color-skeleton);
color: var(--color-icon-muted);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,466 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
// 서버의 거절(VAL_001 · MAIL_013 · 그사이 바뀜 MAIL_012)과 50개 넘음은 단추 줄 위에 — 부모가 error 로 준다(무엇이든 고치면 clear-error).
// 그사이 바뀜(conflict)이면 그 옆에 [다시 불러오기] — 누르면 부모가 목록을 다시 받고, 받았으면 [저장]으로 초점을 옮긴다(focusSave)
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
import { computed, reactive, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
import { getErrorMessage } from '@/composables/useApi'
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
import {
defaultMoveTarget,
emptyCondition,
filterDraftOf,
filterDraftProblems,
filterMoveTargets,
filterRuleOf,
hasDraftAction,
hasFilterProblems,
mailDomainOf,
newFilterDraft,
newFilterRuleBase,
noFilterProblems,
type MailFilterActionDraft,
type MailFilterConditionProblem,
type MailFilterDraft,
type MailFilterForwardProblem,
type MailFilterProblems,
} from '@/shared/utils/mail-filter'
interface Props {
open: boolean
/** 고칠 규칙 — null 이면 새 필터 */
rule: MailFilterRule | null
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
prefillFrom?: string | null
/** 모든 메일함 — 옮길 메일함 고르기 · 지워진 메일함 판정 */
mailboxes: readonly Mailbox[]
/** 로그인한 직원 주소 — 사내 도메인 · 자기 주소 판정 */
userEmail: string
pending?: MailFilterDialogPending
/** 다른 저장(켜기 · 순서)을 기다리는 중 */
busy?: boolean
/** 서버가 거절한 까닭 — 사전 문구 (50개 넘음은 fullNote) */
error?: string | null
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
conflict?: boolean
}
interface Emits {
(e: 'save', rule: MailFilterRule): void
(e: 'remove'): void
(e: 'cancel'): void
(e: 'clear-error'): void
/** 그사이 바뀜 — 목록을 다시 받아 달라 (창 · 쓴 내용은 그대로) */
(e: 'reload'): void
}
const props = withDefaults(defineProps<Props>(), {
prefillFrom: null,
pending: null,
busy: false,
error: null,
conflict: false,
})
const emit = defineEmits<Emits>()
const nameRef = ref<HTMLInputElement | null>(null)
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
const saveRef = ref<HTMLButtonElement | null>(null)
const targets = computed(() => filterMoveTargets(props.mailboxes))
const domain = computed(() => mailDomainOf(props.userEmail))
/** 읽기 화면에서 만든 새 필터 — 처음 초점이 할 일의 첫 칸이다 */
const prefilled = computed(() => props.rule === null && Boolean(props.prefillFrom))
const locked = computed(() => props.pending !== null || props.busy)
const title = computed(() =>
props.rule ? MAIL_FILTER_DIALOG_TEXT.editTitle : MAIL_FILTER_DIALOG_TEXT.createTitle,
)
const draft = reactive<MailFilterDraft>(initialDraft())
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
let base = baseOf()
const actionsValue = computed<MailFilterActionDraft>(() => ({
move: draft.move,
moveTarget: draft.moveTarget,
markAsRead: draft.markAsRead,
flag: draft.flag,
forward: draft.forward,
forwardTo: draft.forwardTo,
}))
const nameError = computed(() => (problems.value.name ? getErrorMessage('VAL_001') : null))
const conditionErrors = computed(() => problems.value.conditions.map(conditionMessage))
const actionsError = computed(() => (problems.value.actions ? MAIL_FILTER_DIALOG_TEXT.actionsEmpty : null))
const forwardError = computed(() => forwardMessage(problems.value.forward))
function initialDraft(): MailFilterDraft {
const target = defaultMoveTarget(targets.value)
return props.rule
? filterDraftOf(props.rule, target)
: newFilterDraft({ moveTarget: target, from: props.prefillFrom })
}
function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
}
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
function resetDraft() {
Object.assign(draft, initialDraft())
problems.value = noFilterProblems(draft.conditions.length)
base = baseOf()
}
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.conditionEmpty
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
if (problem === 'invalid') return getErrorMessage('VAL_001')
return null
}
function forwardMessage(problem: MailFilterForwardProblem | null): string | null {
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.forwardEmpty
if (problem === 'invalid') return MAIL_FILTER_DIALOG_TEXT.forwardInvalid
if (problem === 'external') return MAIL_FILTER_DIALOG_TEXT.forwardExternal(domain.value)
if (problem === 'self') return MAIL_FILTER_DIALOG_TEXT.forwardSelf
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
return null
}
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
function edited(patch: Partial<MailFilterProblems>) {
problems.value = { ...problems.value, ...patch }
if (props.error) emit('clear-error')
}
function setName(event: Event) {
draft.name = (event.target as HTMLInputElement).value
edited({ name: false })
}
function setMatch(match: MailFilterMatch) {
draft.match = match
if (match !== 'always' && draft.conditions.length === 0) draft.conditions = [emptyCondition()]
edited({ conditions: draft.conditions.map(() => null) })
}
function updateCondition(index: number, patch: Partial<MailFilterCondition>) {
draft.conditions = draft.conditions.map((condition, at) => (at === index ? { ...condition, ...patch } : condition))
edited({ conditions: problems.value.conditions.map((problem, at) => (at === index ? null : problem)) })
}
function addCondition() {
draft.conditions = [...draft.conditions, emptyCondition('subject')]
edited({ conditions: [...problems.value.conditions, null] })
}
function removeCondition(index: number) {
draft.conditions = draft.conditions.filter((_, at) => at !== index)
edited({ conditions: problems.value.conditions.filter((_, at) => at !== index) })
}
function updateActions(patch: Partial<MailFilterActionDraft>) {
Object.assign(draft, patch)
const touchesForward = 'forward' in patch || 'forwardTo' in patch
edited({
actions: problems.value.actions && !hasDraftAction(draft),
forward: touchesForward ? null : problems.value.forward,
})
}
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
function focusFirstProblem(found: MailFilterProblems) {
const condition = found.conditions.findIndex((problem) => problem !== null)
if (found.name) nameRef.value?.focus()
else if (condition >= 0) conditionsRef.value?.focusValue(condition)
else if (found.actions) actionsRef.value?.focusFirst()
else if (found.forward) actionsRef.value?.focusForward()
}
function save() {
if (locked.value) return
const found = filterDraftProblems(draft, props.userEmail)
problems.value = found
if (hasFilterProblems(found)) {
focusFirstProblem(found)
return
}
emit('save', filterRuleOf(draft, base))
}
function remove() {
if (!locked.value) emit('remove')
}
function reload() {
if (props.pending === null) emit('reload')
}
defineExpose({
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
focusSave: () => saveRef.value?.focus(),
})
// 열 때마다(연 채로 다른 규칙을 받아도) 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
watch(
() => [props.open, props.rule, props.prefillFrom] as const,
([open]) => {
if (open) resetDraft()
},
)
</script>
<template>
<ModalDialog
id="mail-filter-dialog"
:open="open"
:title="title"
title-id="mail-filter-dialog-title"
wide
@cancel="emit('cancel')"
>
<div class="filter-dialog">
<div class="filter-dialog__field">
<label
for="mail-filter-name"
class="filter-dialog__label"
>
{{ MAIL_FILTER_DIALOG_TEXT.nameLabel }}
<span class="filter-dialog__optional">{{ MAIL_FILTER_DIALOG_TEXT.nameOptional }}</span>
</label>
<input
id="mail-filter-name"
ref="nameRef"
class="form-input"
type="text"
:value="draft.name"
:maxlength="MAIL_FILTER_NAME_MAX_LENGTH"
:placeholder="MAIL_FILTER_DIALOG_TEXT.namePlaceholder"
:aria-invalid="nameError ? 'true' : 'false'"
:aria-describedby="nameError ? 'mail-filter-name-error' : undefined"
:autofocus="!prefilled || undefined"
autocomplete="off"
@input="setName"
>
<p
v-if="nameError"
id="mail-filter-name-error"
class="form-message form-message--error"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ nameError }}</span>
</p>
</div>
<MailFilterConditionFields
ref="conditionsRef"
:match="draft.match"
:conditions="draft.conditions"
:errors="conditionErrors"
:domain="domain"
@update:match="setMatch"
@update-condition="updateCondition"
@add="addCondition"
@remove="removeCondition"
/>
<MailFilterActionFields
ref="actionsRef"
:value="actionsValue"
:targets="targets"
:mailboxes="mailboxes"
:domain="domain"
:actions-error="actionsError"
:forward-error="forwardError"
:autofocus-first="prefilled"
@update="updateActions"
/>
<p class="filter-dialog__notice">
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
</p>
<div
v-if="error"
class="filter-dialog__error"
>
<p
id="mail-filter-error"
class="form-message form-message--error filter-dialog__error-text"
role="alert"
>
<AppIcon
name="alert-circle"
:size="14"
:stroke-width="2"
/>
<span>{{ error }}</span>
</p>
<button
v-if="conflict"
id="mail-filter-reload"
type="button"
class="filter-dialog__reload"
aria-describedby="mail-filter-error"
:aria-disabled="pending !== null ? 'true' : undefined"
:aria-busy="pending === 'reload' ? 'true' : undefined"
@click="reload"
>
{{ MAIL_FILTER_TEXT.reload }}
</button>
</div>
</div>
<template #actions>
<button
v-if="rule"
id="mail-filter-remove"
type="button"
class="modal-dialog__action filter-dialog__remove"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'remove' ? 'true' : undefined"
@click="remove"
>
<AppIcon
name="trash"
:size="15"
/>
<span>{{ MAIL_FILTER_DIALOG_TEXT.remove }}</span>
</button>
<button
id="mail-filter-save"
ref="saveRef"
type="button"
class="modal-dialog__action filter-dialog__save"
:aria-disabled="locked ? 'true' : undefined"
:aria-busy="pending === 'save' ? 'true' : undefined"
@click="save"
>
{{ MAIL_FILTER_DIALOG_TEXT.save }}
</button>
<button
id="mail-filter-cancel"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('cancel')"
>
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
<style scoped>
/* 묶음 사이 16 — 제목과의 사이도 16(창의 8 + 8), 단추 줄 앞도 18 (디자인 원본 SettingsFilter) */
.filter-dialog {
display: flex;
flex-direction: column;
gap: var(--space-16);
margin: var(--space-8) 0 var(--space-6);
}
.filter-dialog__field {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.filter-dialog__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.filter-dialog__optional {
font-weight: 400;
color: var(--color-text-muted);
}
/* 단추 줄 위의 거절 — 그사이 바뀜이면 옆에 [다시 불러오기] (36 — 목록 위 줄의 단추와 같은 밀도 예외) */
.filter-dialog__error {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-10);
}
.filter-dialog__error-text {
flex: 1 1 18rem;
}
.filter-dialog__reload {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-brand-strong);
font-weight: 600;
}
.filter-dialog__reload:hover:not([aria-disabled='true']) {
background: var(--color-brand-subtle);
}
.filter-dialog__reload[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.filter-dialog__notice {
margin: 0;
padding: var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
.modal-dialog__action.filter-dialog__remove {
gap: var(--space-6);
margin-right: auto;
padding: 0 var(--space-12);
background: transparent;
color: var(--color-danger);
}
.modal-dialog__action.filter-dialog__remove:not([aria-disabled='true']):hover {
background: var(--color-danger-surface);
color: var(--color-danger-strong);
}
.modal-dialog__action.filter-dialog__save {
padding: 0 var(--space-18);
}
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
.modal-dialog__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
// 필터가 아직 없을 때 — 목록 자리의 안내 (디자인 원본 MailFilterStates): 점선 상자 · 깔때기 36 · "아직 필터가 없습니다" · 설명 · [새 필터]
// [새 필터]는 머리의 단추와 같은 일을 한다
import AppIcon from '@/components/AppIcon.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
interface Emits {
(e: 'create'): void
}
const emit = defineEmits<Emits>()
</script>
<template>
<div
class="filter-empty"
data-filter-empty
>
<AppIcon
name="filter"
:size="36"
:stroke-width="1.5"
class="filter-empty__icon"
/>
<p class="filter-empty__title">
{{ MAIL_FILTER_TEXT.emptyTitle }}
</p>
<p class="filter-empty__text">
{{ MAIL_FILTER_TEXT.emptyText }}
</p>
<MailFilterCreateButton
class="filter-empty__create"
@create="emit('create')"
/>
</div>
</template>
<style scoped>
.filter-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-8);
padding: var(--space-40) var(--space-24);
border: var(--border-width) dashed var(--color-border-strong);
border-radius: var(--radius-lg);
text-align: center;
}
.filter-empty__icon {
color: var(--color-icon-muted);
}
.filter-empty__title {
margin: var(--space-4) 0 0;
font-size: var(--font-size-body);
font-weight: 600;
}
.filter-empty__text {
max-width: 25rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-empty__create {
margin-top: var(--space-6);
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
// 필터 목록 — 테두리 상자 하나에 행(MailFilterRow) (디자인 원본 SettingsFilter). 위에서부터 차례로 적용하므로 순서 있는 목록(ol)이다.
// 켜기 · ▲▼ · 행 누름(고치기)을 부모에 알리고, 부모가 행 안의 단추에 초점을 줄 수 있게 한다(focusRule — 순서를 바꾸거나 지운 뒤)
import type { ComponentPublicInstance } from 'vue'
import MailFilterRow from '@/components/settings/MailFilterRow.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
interface Props {
rules: readonly MailFilterRule[]
mailboxes: readonly Mailbox[]
/** 저장을 기다리는 중 — 행마다 켜기 · 순서를 막는다(aria-disabled — 목록 전체에 aria-busy 는 걸지 않는다) */
busy: boolean
}
interface Emits {
(e: 'toggle', rule: MailFilterRule): void
(e: 'move', rule: MailFilterRule, offset: -1 | 1): void
(e: 'edit', rule: MailFilterRule): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
/** 규칙 id → 행 — 행이 옮겨 다녀도 같은 행을 찾는다 */
const rows = new Map<string, InstanceType<typeof MailFilterRow>>()
function setRow(id: string, row: Element | ComponentPublicInstance | null) {
if (row) rows.set(id, row as InstanceType<typeof MailFilterRow>)
else rows.delete(id)
}
defineExpose({
/** 그 규칙 행의 단추에 초점 — 행이 없으면 false */
focusRule(id: string, part: MailFilterRowPart): boolean {
const row = rows.get(id)
row?.focus(part)
return row !== undefined
},
})
</script>
<template>
<ol
class="filter-list"
:aria-label="MAIL_FILTER_TEXT.listLabel"
>
<MailFilterRow
v-for="(rule, index) in rules"
:key="rule.id"
:ref="(row) => setRow(rule.id, row)"
:rule="rule"
:index="index"
:count="rules.length"
:mailboxes="mailboxes"
:busy="busy"
@toggle="emit('toggle', rule)"
@move="(offset) => emit('move', rule, offset)"
@edit="emit('edit', rule)"
/>
</ol>
</template>
<style scoped>
.filter-list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
</script>
<template>
<div
class="filter-notes"
data-filter-notes
>
<p class="filter-notes__note">
<span aria-hidden="true">·</span>
<span><strong class="filter-notes__strong">{{ MAIL_FILTER_TEXT.noteOrderStrong }}</strong>{{
MAIL_FILTER_TEXT.noteOrderAfter
}}</span>
</p>
<p
v-for="note in NOTES"
:key="note"
class="filter-notes__note"
>
<span aria-hidden="true">·</span>
<span>{{ note }}</span>
</p>
</div>
</template>
<style scoped>
.filter-notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 43.75rem;
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.filter-notes__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.filter-notes__strong {
font-weight: 600;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/shared/constants/icons'
interface Props {
icon: IconName
message: string
actionLabel: string
busy?: boolean
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
live?: 'alert' | 'status'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
live: 'status',
})
const emit = defineEmits<Emits>()
function onAction() {
if (!props.busy) emit('action')
}
</script>
<template>
<div
class="filter-notice"
:role="live"
>
<AppIcon
:name="icon"
:size="16"
:stroke-width="1.9"
class="filter-notice__icon"
/>
<span class="filter-notice__text">{{ message }}</span>
<button
type="button"
class="filter-notice__action"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
@click="onAction"
>
{{ actionLabel }}
</button>
</div>
</template>
<style scoped>
.filter-notice {
display: flex;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-12);
border-radius: var(--radius-md);
background: var(--color-warning-surface);
color: var(--color-warning-text);
}
.filter-notice__icon {
margin-top: var(--space-2);
}
.filter-notice__text {
flex: 1 1 auto;
min-width: 0;
align-self: center;
}
/* 줄 안의 단추 36 — 밀도 예외 (알림 권하기 줄의 단추와 같다) */
.filter-notice__action {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-item);
background: transparent;
color: var(--color-warning-text);
font-weight: 600;
}
.filter-notice__action:hover:not([aria-disabled='true']) {
background: var(--color-warning-hover);
}
.filter-notice__action[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
// 필터 목록의 한 행 (디자인 원본 SettingsFilter — 행 60, 칸 22 · 40 · 나머지 · ▲▼)
// 순서 번호 · 켜기 스위치(role="switch" — 그림은 36×20, 누르는 곳은 칸 전체 40×44 — 밀도 예외) · 이름(굵게)과 요약 한 줄(누르면 고치기 창) ·
// ▲ ▼(32×40 — 밀도 예외, 메일함 ⋯ 와 같다). 단추 이름에 넣는 이름은 quotedRuleTitle(길면 자르고 따옴표가 겹치지 않게).
// 꺼 둔 규칙은 글자를 흐리게, 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄.
// 저장하는 동안(busy)과 맨 위의 ▲ · 맨 아래의 ▼ 는 disabled 가 아니라 aria-disabled — 누른 단추가 초점을 잃지 않게 (5단계의 규칙과 같다)
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import { isMoveTargetMissing, quotedRuleTitle, ruleSummary, ruleTitle } from '@/shared/utils/mail-filter'
interface Props {
rule: MailFilterRule
/** 0부터 */
index: number
count: number
mailboxes: readonly Mailbox[]
/** 다른 저장을 기다리는 중 — 켜기 · 순서를 막는다 */
busy: boolean
}
interface Emits {
(e: 'toggle'): void
(e: 'move', offset: -1 | 1): void
(e: 'edit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editRef = ref<HTMLButtonElement | null>(null)
const upRef = ref<HTMLButtonElement | null>(null)
const downRef = ref<HTMLButtonElement | null>(null)
const title = computed(() => ruleTitle(props.rule))
/** 단추 이름에 넣는 이름 — 따옴표로 묶고, 조건 글이 길면 자른다 */
const quoted = computed(() => quotedRuleTitle(props.rule))
const summary = computed(() => ruleSummary(props.rule, mailboxName))
const missing = computed(() => isMoveTargetMissing(props.rule, props.mailboxes))
const isFirst = computed(() => props.index === 0)
const isLast = computed(() => props.index === props.count - 1)
const summaryId = computed(() => `mail-filter-${props.index}-summary`)
const missingId = computed(() => `mail-filter-${props.index}-missing`)
function mailboxName(id: string): string | null {
return props.mailboxes.find((mailbox) => mailbox.id === id)?.name ?? null
}
function toggle() {
if (!props.busy) emit('toggle')
}
function move(offset: -1 | 1) {
const atEdge = offset < 0 ? isFirst.value : isLast.value
if (!props.busy && !atEdge) emit('move', offset)
}
defineExpose({
focus: (part: MailFilterRowPart) => ({ edit: editRef, up: upRef, down: downRef })[part].value?.focus(),
})
</script>
<template>
<li
:class="['filter-row', { 'is-off': !rule.isEnabled, 'is-busy': busy }]"
:data-filter-rule="rule.id"
>
<span class="filter-row__no">{{ index + 1 }}</span>
<button
type="button"
class="filter-row__switch"
role="switch"
:aria-checked="rule.isEnabled ? 'true' : 'false'"
:aria-label="MAIL_FILTER_TEXT.switchLabel(quoted)"
:aria-disabled="busy ? 'true' : undefined"
data-filter-switch
@click="toggle"
>
<span
class="filter-row__track"
aria-hidden="true"
>
<span class="filter-row__knob" />
</span>
</button>
<button
ref="editRef"
type="button"
class="filter-row__main"
:aria-label="MAIL_FILTER_TEXT.editLabel(quoted)"
:aria-describedby="missing ? `${summaryId} ${missingId}` : summaryId"
data-filter-edit
@click="emit('edit')"
>
<span class="filter-row__title">{{ title }}</span>
<span
:id="summaryId"
class="filter-row__summary"
>{{ summary }}</span>
<span
v-if="missing"
:id="missingId"
class="filter-row__missing"
data-filter-missing
>
<AppIcon
name="alert-circle"
:size="13"
:stroke-width="2"
/>
<span>{{ MAIL_FILTER_TEXT.missingMailbox }}</span>
</span>
</button>
<div class="filter-row__order">
<button
ref="upRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isFirst }]"
:aria-label="MAIL_FILTER_TEXT.moveUp(quoted)"
:aria-disabled="busy || isFirst ? 'true' : undefined"
data-filter-up
@click="move(-1)"
>
<AppIcon
name="chevron-up"
:size="15"
:stroke-width="1.9"
/>
</button>
<button
ref="downRef"
type="button"
:class="['filter-row__arrow', { 'is-edge': isLast }]"
:aria-label="MAIL_FILTER_TEXT.moveDown(quoted)"
:aria-disabled="busy || isLast ? 'true' : undefined"
data-filter-down
@click="move(1)"
>
<AppIcon
name="chevron-down"
:size="15"
:stroke-width="1.9"
/>
</button>
</div>
</li>
</template>
<style scoped>
/* 행 60 — 칸 22(순서) · 40(스위치) · 나머지(이름 · 요약) · ▲▼ (디자인 원본 SettingsFilter) */
.filter-row {
display: grid;
grid-template-columns: 1.375rem 2.5rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-10);
min-height: 3.75rem;
padding: var(--space-6) var(--space-8) var(--space-6) var(--space-14);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-row__no {
font-size: var(--font-size-small);
color: var(--color-icon-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
/* 누르는 곳은 칸 전체 40×44(밀도 예외 — tokens.css "6단계", 툴바 아이콘 단추와 같은 폭), 보이는 스위치는 36×20 */
.filter-row__switch {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-icon-button);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
}
.filter-row__track {
position: relative;
display: block;
width: 2.25rem;
height: 1.25rem;
border-radius: 0.625rem;
background: var(--color-border-strong);
transition: background-color 0.15s;
}
.filter-row__knob {
position: absolute;
top: var(--space-2);
left: var(--space-2);
width: 1rem;
height: 1rem;
border-radius: var(--radius-round);
background: var(--color-surface);
box-shadow: var(--shadow-switch-knob);
transition: transform 0.15s;
}
.filter-row__switch[aria-checked='true'] .filter-row__track {
background: var(--color-brand);
}
.filter-row__switch[aria-checked='true'] .filter-row__knob {
transform: translateX(1rem);
}
.filter-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6) var(--space-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
}
.filter-row__main:hover {
background: var(--color-surface-panel);
}
.filter-row__title,
.filter-row__summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-row__title {
font-weight: 600;
}
.filter-row__summary {
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
/* 꺼 둔 규칙 — 글자를 흐리게 */
.filter-row.is-off .filter-row__title {
color: var(--color-text-muted);
}
.filter-row.is-off .filter-row__summary {
color: var(--color-icon-muted);
}
.filter-row__missing {
display: flex;
align-items: center;
gap: var(--space-6);
font-size: var(--font-size-label);
color: var(--color-danger);
white-space: normal;
}
.filter-row__order {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ▲ ▼ 32×40 — 밀도 예외 (메일함 행의 ⋯ 와 같은 크기 — tokens.css "6단계") */
.filter-row__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-mailbox-more);
height: var(--size-row-min);
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.filter-row__arrow:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 맨 위의 ▲ · 맨 아래의 ▼ — 꺼진 모양 */
.filter-row__arrow.is-edge {
color: var(--color-border-strong);
cursor: default;
}
/* 저장을 기다리는 동안 — 모양은 그대로, 누를 수만 없다 */
.filter-row.is-busy .filter-row__switch,
.filter-row.is-busy .filter-row__arrow:not(.is-edge) {
cursor: progress;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
interface Props {
@@ -19,16 +20,24 @@ defineProps<Props>()
:aria-labelledby="titleId"
>
<div class="settings-page__column">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
<div class="settings-page__intro">
<h1
:id="titleId"
class="settings-page__title"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
</div>
<div
v-if="$slots.actions"
class="settings-page__actions"
>
{{ title }}
</h1>
<p class="settings-page__description">
{{ description }}
</p>
<slot name="actions" />
</div>
</div>
<slot />
</div>
@@ -53,6 +62,18 @@ defineProps<Props>()
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/* 머리 오른쪽 단추 — 제목 · 설명이 360 보다 좁아지면 아래로 내려간다 (디자인 원본 SettingsFilter) */
.settings-page__head--actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-12) var(--space-20);
}
.settings-page__head--actions .settings-page__intro {
flex: 1 1 22.5rem;
}
.settings-page__intro {
display: flex;
flex-direction: column;
@@ -0,0 +1,4 @@
// 메일 필터 화면의 목록(MailFilterList · MailFilterRow)과 화면(MailFilterPage)이 주고받는 값
/** 행 안에서 초점을 줄 곳 — 고치기(행) · ▲ · ▼ (순서를 바꾸거나 지운 뒤 화면이 정한다) */
export type MailFilterRowPart = 'edit' | 'up' | 'down'
+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 }
}
@@ -0,0 +1,784 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MailFilterRule, MailFilterSet, MailFilterSetPayload, MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailFilterPage from './MailFilterPage.vue'
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function mailbox(id: string, name: string, role: Mailbox['role'] = null): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const MAILBOXES: Mailbox[] = [
mailbox('inbox', '받은편지함', 'inbox'),
mailbox('sent', '보낸편지함', 'sent'),
mailbox('drafts', '임시보관함', 'drafts'),
mailbox('junk', '스팸메일함', 'junk'),
mailbox('trash', '휴지통', 'trash'),
mailbox('archive', '보관함', 'archive'),
mailbox('vendor', '거래처'),
mailbox('notice', '전체 공지'),
]
const ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
/** 디자인 원본 SettingsFilter 의 시안과 같은 다섯 규칙 */
const RULES: MailFilterRule[] = [
{
id: 'r1',
name: '휴가 중 전달',
isEnabled: false,
match: 'always',
conditions: [],
actions: { ...ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' },
},
{
id: 'r2',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'vendor' },
},
{
id: 'r3',
name: '결재 알림',
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: '[결재]' }],
actions: { ...ACTIONS, markAsRead: true, flag: true },
},
{
id: 'r4',
name: '',
isEnabled: false,
match: 'all',
conditions: [{ field: 'to', operator: 'contains', value: 'all@arasystem.it.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'notice', markAsRead: true },
},
{
id: 'r5',
name: '알파 프로젝트',
isEnabled: true,
match: 'any',
conditions: [
{ field: 'subject', operator: 'contains', value: '알파' },
{ field: 'body', operator: 'contains', value: 'Project Alpha' },
],
actions: { ...ACTIONS, moveToMailboxId: 'gone' },
},
]
const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
interface Server {
filters?: MailFilterSet | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
function echo(request: MockRequest): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('MailFilterPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function installServer(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.url === '/mail-filters' && request.method === 'GET') {
const filters = server.filters ?? SET
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
}
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
return ok(null)
})
}
async function mountPage(server: Server = {}, path = '/settings/mail/filters') {
installServer(server)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const rows = (wrapper: VueWrapper) => wrapper.findAll('[data-filter-rule]')
const row = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-filter-rule="${id}"]`)
const dialog = (wrapper: VueWrapper) => wrapper.get('#mail-filter-dialog')
const isOpen = (wrapper: VueWrapper) => dialog(wrapper).attributes('open') !== undefined
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
const conditionValues = (wrapper: VueWrapper) =>
wrapper.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value)
/** 사람이 누른 것처럼 — 초점을 먼저 준다 (창을 닫으면 초점이 돌아올 곳) */
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
;(target.element as HTMLElement).focus()
await target.trigger('click')
await flushPromises()
}
async function openRule(wrapper: VueWrapper, id: string) {
await click(row(wrapper, id).get('[data-filter-edit]'))
}
async function save(wrapper: VueWrapper) {
await click(wrapper.get('#mail-filter-save'))
}
it('목록 — 순서 번호 · 켜기 스위치 · 이름과 요약 한 줄, 꺼 둔 규칙은 흐리게, 지워진 메일함은 빨간 안내', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('필터')
expect(requests.map((request) => `${request.method} ${request.url}`).sort()).toEqual([
'GET /mail-filters',
'GET /mailboxes',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__no').text())).toEqual(['1', '2', '3', '4', '5'])
expect(rows(wrapper).map((item) => item.get('.filter-row__title').text())).toEqual([
'휴가 중 전달',
'거래처 메일',
'결재 알림',
'받는 사람에 ‘all@arasystem.it.kr’ 포함',
'알파 프로젝트',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__summary').text())).toEqual([
'모든 메일 → 전달: doyun.lee@arasystem.it.kr',
'보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처',
'제목에 ‘[결재]’ 포함 → 읽음으로 표시 · 중요 표시',
'→ 옮기기: 전체 공지 · 읽음으로 표시',
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함 → 옮기기: 없는 메일함',
])
const switches = wrapper.findAll('[data-filter-switch]')
expect(switches.map((item) => item.attributes('aria-checked'))).toEqual(['false', 'true', 'true', 'false', 'true'])
expect(switches[1]?.attributes()).toMatchObject({ role: 'switch', 'aria-label': '‘거래처 메일’ 필터 사용' })
// 이름 없는 필터 — 조건 글이 이름이라 작은따옴표가 겹치지 않게 큰따옴표로 묶는다
expect(switches[3]?.attributes('aria-label')).toBe('“받는 사람에 ‘all@arasystem.it.kr’ 포함” 필터 사용')
expect(row(wrapper, 'r4').get('[data-filter-up]').attributes('aria-label')).toBe(
'“받는 사람에 ‘all@arasystem.it.kr’ 포함” 위로',
)
// 켜기마다 목록 전체가 바쁜 것으로 읽히지 않게 — 칸마다 aria-disabled
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
])
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
expect(wrapper.get('[data-filter-notes]').findAll('p')).toHaveLength(4)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
let reply: MailFilterSet | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get filters() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(toasts()).toEqual([])
expect(wrapper.get('[data-filter-create]').attributes('aria-disabled')).toBe('true')
reply = SET
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(rows(wrapper)).toHaveLength(5)
})
it('필터가 없으면 목록 자리에 안내 — 안의 [새 필터]는 머리의 단추와 같은 일을 한다', async () => {
const { wrapper } = await mountPage({ filters: { rules: [], version: 'v0', isActive: true } })
const empty = wrapper.get('[data-filter-empty]')
expect(empty.text()).toContain('아직 필터가 없습니다')
expect(wrapper.find('[data-filter-count]').exists()).toBe(false)
await click(empty.get('[data-filter-create]'))
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(wrapper.find('#mail-filter-remove').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-name')
})
it('켜기 스위치 — 화면을 먼저 바꾸고 전체 교체로 저장한다 (알리지 않는다), 실패하면 되돌리고 오류 알림', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
const toggle = row(wrapper, 'r2').get('[data-filter-switch]')
await toggle.trigger('click')
expect(toggle.attributes('aria-checked')).toBe('false')
expect(puts()).toHaveLength(1)
expect(puts()[0]?.version).toBe('v1')
expect(puts()[0]?.rules.map((rule) => rule.isEnabled)).toEqual([false, false, true, false, true])
// 저장하는 동안은 다른 켜기 · 순서를 보내지 않는다 (막아도 초점은 그대로 — aria-disabled)
const other = row(wrapper, 'r3').get('[data-filter-switch]')
expect(other.attributes('aria-disabled')).toBe('true')
await other.trigger('click')
await row(wrapper, 'r3').get('[data-filter-up]').trigger('click')
expect(puts()).toHaveLength(1)
reply.finish(fail(503, 'MAIL_001'))
await flushPromises()
expect(toggle.attributes('aria-checked')).toBe('true')
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('켜기가 저장되면 알림이 없다', async () => {
const { wrapper } = await mountPage()
await row(wrapper, 'r1').get('[data-filter-switch]').trigger('click')
await flushPromises()
expect(row(wrapper, 'r1').get('[data-filter-switch]').attributes('aria-checked')).toBe('true')
expect(row(wrapper, 'r1').classes('is-off')).toBe(false)
expect(toasts()).toEqual([])
})
it('▲ ▼ — 한 칸 옮겨 저장하고, 누른 단추에 초점이 남는다 (맨 위로 오면 그 ▲ 는 꺼진 채로 초점을 둔다)', async () => {
const { wrapper } = await mountPage()
await click(row(wrapper, 'r2').get('[data-filter-up]'))
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
const up = row(wrapper, 'r2').get('[data-filter-up]')
expect(document.activeElement).toBe(up.element)
expect(up.attributes('aria-disabled')).toBe('true')
// 맨 위에서 다시 눌러도 보내지 않는다
await click(up)
expect(puts()).toHaveLength(1)
expect(toasts()).toEqual([])
})
it('행을 누르면 그 규칙으로 채운 고치기 창 — [저장]하면 그 자리에 저장하고 "필터를 저장했습니다.", 초점은 그 행으로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('필터 고치기')
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('거래처 메일')
expect((wrapper.get('#mail-filter-match').element as HTMLSelectElement).value).toBe('all')
expect(conditionValues(wrapper)).toEqual(['@hanbit-partners.co.kr'])
expect((wrapper.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('#mail-filter-move-target').element as HTMLSelectElement).value).toBe('vendor')
expect(wrapper.get('#mail-filter-forward-to').attributes('disabled')).toBeDefined()
expect(wrapper.get('#mail-filter-remove').text()).toBe('이 필터 지우기')
expect(document.activeElement?.id).toBe('mail-filter-name')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await wrapper.get('#mail-filter-read').setValue(true)
await save(wrapper)
expect(puts()).toEqual([
{
version: 'v1',
rules: [
RULES[0],
{ ...RULES[1], name: '한빛 거래처', actions: { ...RULES[1]?.actions, markAsRead: true } },
RULES[2],
RULES[3],
RULES[4],
],
},
])
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
expect(row(wrapper, 'r2').get('.filter-row__title').text()).toBe('한빛 거래처')
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
})
it('새 필터 — 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만, 첫 잘못된 칸에 초점 (알림 없음 · 보내지 않는다)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
)
expect(wrapper.get('#mail-filter-actions-error').text()).toBe('할 일을 하나 이상 골라 주세요.')
const value = wrapper.get('[data-filter-condition-value]')
expect(value.attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'mail-filter-condition-0-error' })
expect(document.activeElement).toBe(value.element)
await value.setValue('가격 ${price}')
expect(wrapper.find('#mail-filter-condition-0-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward').setValue(true)
expect(wrapper.find('#mail-filter-actions-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward-to').setValue('me@gmail.com')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
expect(document.activeElement).toBe(value.element)
await value.setValue('견적서')
await save(wrapper)
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('Ara.Kim@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('내 주소로는 전달할 수 없습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
expect(wrapper.get('#mail-filter-forward-help').text()).toBe(
'사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
)
await save(wrapper)
const added = puts()[0]?.rules.at(-1)
expect(puts()[0]?.rules).toHaveLength(6)
expect(added).toMatchObject({
name: '',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '견적서' }],
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
})
expect(added?.id).toMatch(/^[0-9a-f-]{36}$/)
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('조건 줄 — [조건 더하기]는 5개까지(새 줄은 제목), 하나만 남으면 ✕ 를 끈다 · 모든 메일이면 조건을 보내지 않는다', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
for (let count = 1; count < 5; count += 1) await wrapper.get('#mail-filter-add-condition').trigger('click')
await flushPromises()
const fields = wrapper.findAll('[data-filter-condition-field]')
expect(fields.map((field) => (field.element as HTMLSelectElement).value)).toEqual([
'from',
'subject',
'subject',
'subject',
'subject',
])
expect(document.activeElement).toBe(fields[4]?.element)
expect(wrapper.get('#mail-filter-add-condition').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe(
'예: @hanbit-partners.co.kr 또는 이름',
)
for (let count = 0; count < 4; count += 1) await wrapper.get('[data-filter-condition-remove]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-filter-condition-value]')).toHaveLength(1)
expect(wrapper.get('[data-filter-condition-remove]').attributes('disabled')).toBeDefined()
await wrapper.get('#mail-filter-match').setValue('always')
expect(wrapper.find('[data-filter-condition-value]').exists()).toBe(false)
await wrapper.get('#mail-filter-flag').setValue(true)
await save(wrapper)
expect(puts()[0]?.rules.at(-1)).toMatchObject({ match: 'always', conditions: [], actions: { flag: true } })
})
it('옮길 메일함 — 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통, 지워진 메일함은 "(지워진 메일함)"이 골라진 채로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r5')
const select = wrapper.get('#mail-filter-move-target')
const options = select.findAll('option')
expect(options.map((option) => option.text())).toEqual([
'(지워진 메일함) — 다른 메일함을 고르세요',
'받은편지함',
'거래처',
'전체 공지',
'보관함',
'스팸메일함',
'휴지통',
])
expect(options[0]?.attributes('disabled')).toBeDefined()
expect((select.element as HTMLSelectElement).value).toBe('gone')
await select.setValue('junk')
await save(wrapper)
expect(puts()[0]?.rules[4]?.actions.moveToMailboxId).toBe('junk')
expect(wrapper.find('[data-filter-missing]').exists()).toBe(false)
})
it('서버의 거절(MAIL_013 · VAL_001)은 창 안에 사전 문구로 — 알림 없이 창을 그대로 두고, 고치면 내린다', async () => {
const { wrapper } = await mountPage({ save: () => fail(400, 'MAIL_013') })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_013)
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-name').setValue('결재')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
})
it('저장하는 동안 [저장] · [이 필터 지우기]는 aria-disabled — 다시 눌러도 보내지 않는다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(wrapper.get('#mail-filter-save').attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-busy': 'true' })
expect(wrapper.get('#mail-filter-remove').attributes('aria-disabled')).toBe('true')
await save(wrapper)
await click(wrapper.get('#mail-filter-remove'))
expect(puts()).toHaveLength(1)
reply.finish(echo(requests[requests.length - 1] as MockRequest))
await flushPromises()
expect(isOpen(wrapper)).toBe(false)
})
it('그사이 다른 곳에서 바뀜(409 MAIL_012) — 알림 없이 목록 위 줄 + 창 안 문구, 창은 닫지 않는다 · [다시 불러오기]는 새로 받고 창을 닫는다', async () => {
let filters: MailFilterSet = SET
const { wrapper } = await mountPage({
get filters() {
return filters
},
save: () => fail(409, 'MAIL_012'),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
const bar = wrapper.get('[data-filter-conflict]')
expect(bar.attributes('role')).toBe('alert')
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
await click(bar.get('button'))
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r3'])
})
interface ConflictServer {
filters: MailFilterSet | MockResult
conflict: boolean
}
/** 고치기 창에서 409 를 받은 상태 — 창 안의 [다시 불러오기]를 누르기 직전 */
async function conflictInDialog(server: ConflictServer) {
const { wrapper } = await mountPage({
get filters() {
return server.filters
},
save: (request) => (server.conflict ? fail(409, 'MAIL_012') : echo(request)),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
return wrapper
}
it('그사이 바뀜 — 창 안의 [다시 불러오기]는 목록만 다시 받고 창 · 쓴 것은 그대로, 초점은 [저장] · 다음 [저장]은 다시 받은 목록 위에', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const reload = wrapper.get('#mail-filter-reload')
expect(reload.text()).toBe('다시 불러오기')
expect(reload.attributes('aria-describedby')).toBe('mail-filter-error')
// 칸을 고쳐도 그사이 바뀜 안내는 남는다 — 다시 받아야 풀린다
await wrapper.get('#mail-filter-name').setValue('고친 이름 2')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
// 다른 곳에서 r2 를 끄고 r1 을 지웠다
const others = [{ ...(RULES[1] as MailFilterRule), isEnabled: false, name: '다른 곳' }, ...RULES.slice(2)]
server.filters = { rules: others, version: 'v7', isActive: true }
server.conflict = false
await click(reload)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름 2')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-save')
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r3', 'r4', 'r5'])
expect(toasts()).toEqual([])
await save(wrapper)
// 같은 id 자리에서 바꾸고, 켜짐은 다시 받은 목록의 값(꺼짐)이다 — 창은 켜기를 고치지 않는다
expect(puts().at(-1)).toEqual({
version: 'v7',
rules: [{ ...RULES[1], name: '고친 이름 2', isEnabled: false }, ...RULES.slice(2)],
})
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('다시 받은 목록에서 그 규칙이 다른 곳에서 지워졌으면 맨 아래에 넣는다', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
server.filters = { rules: [RULES[0] as MailFilterRule, RULES[2] as MailFilterRule], version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts().at(-1)?.version).toBe('v7')
expect(puts().at(-1)?.rules.map((rule) => [rule.id, rule.name])).toEqual([
['r1', '휴가 중 전달'],
['r3', '결재 알림'],
['r2', '고친 이름'],
])
})
it('지워진 규칙을 맨 아래에 넣으면 50개를 넘을 때 — 보내지 않고 창 안에 "필터는 50개까지 …" (알림 없음)', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
server.filters = { rules: fifty, version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts()).toHaveLength(1)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(wrapper.find('#mail-filter-reload').exists()).toBe(false)
expect(toasts()).toEqual([])
})
it('창 안의 [다시 불러오기]를 실패하면 인터셉터 알림만 — 창과 안내 · 단추는 그대로', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 MAIL_001 과 섞이지 않게 다른 코드로
server.filters = fail(500, 'SYS_001')
await click(wrapper.get('#mail-filter-reload'))
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
expect(wrapper.find('#mail-filter-reload').exists()).toBe(true)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(true)
})
it('전달 칸 — 비우면 "전달할 주소를 입력해 주세요.", 형식이 틀리면 "전달할 주소 형식이 올바르지 않습니다." (칸 아래 · 알림 없음)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-value]').setValue('견적')
await wrapper.get('#mail-filter-forward').setValue(true)
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소를 입력해 주세요.')
expect(wrapper.get('#mail-filter-forward-to').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('doyun..lee@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소 형식이 올바르지 않습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
})
it('받는 사람 칸 · 전달 칸의 보기는 로그인한 직원 주소의 도메인으로', async () => {
useAuthStore().startSession({ email: 'mina.choi@ara.test', name: '최미나' })
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-field]').setValue('to')
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe('예: all@ara.test')
expect(wrapper.get('#mail-filter-forward-to').attributes('placeholder')).toBe('이름@ara.test')
})
it('지우기 — 확인 없이 지우고 알림 [되돌리기], 초점은 다음 행 · 되돌리면 지운 자리에 다시 저장한다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await click(wrapper.get('#mail-filter-remove'))
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r1', 'r3', 'r4', 'r5'])
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper)).toHaveLength(4)
expect(document.activeElement).toBe(row(wrapper, 'r3').get('[data-filter-edit]').element)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘거래처 메일’ 필터를 지웠습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(puts()[1]).toEqual({ version: 'v1+', rules: RULES })
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r1', 'r2', 'r3', 'r4', 'r5'])
expect(toasts().at(-1)).toBe('필터를 되돌렸습니다.')
})
it('50개를 다 만들었으면 [새 필터]를 끄고 까닭을 옆에 — 눌러도 열지 않는다', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper } = await mountPage({ filters: { rules: fifty, version: 'v1', isActive: true } })
const create = wrapper.get('[data-filter-create]')
expect(create.attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-describedby': 'mail-filter-full-note' })
expect(wrapper.get('#mail-filter-full-note').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
await click(create)
expect(isOpen(wrapper)).toBe(false)
})
it('동작하지 않을 때(isActive: false) — 목록 위 줄 + [다시 켜기]는 지금 목록 그대로 저장한다', async () => {
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
const bar = wrapper.get('[data-filter-inactive]')
expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.')
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
// 누른 단추가 사라졌다 — 머리의 [새 필터]로
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('읽기 화면에서 — ?from= 이면 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 열리고 초점은 할 일의 첫 칸, 주소에서 지운다', async () => {
const { wrapper, router } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect((wrapper.get('[data-filter-condition-field]').element as HTMLSelectElement).value).toBe('from')
expect((wrapper.get('[data-filter-condition-operator]').element as HTMLSelectElement).value).toBe('contains')
expect(conditionValues(wrapper)).toEqual(['seojun.park@arasystem.it.kr'])
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
// 취소하면 — 창을 연 단추가 없다(주소로 왔다) · 머리의 [새 필터]로
await click(wrapper.get('#mail-filter-cancel'))
expect(isOpen(wrapper)).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('?from= 이어도 50개를 다 만들었으면 창을 열지 않는다 (주소에서는 지운다)', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper, router } = await mountPage(
{ filters: { rules: fifty, version: 'v1', isActive: true } },
'/settings/mail/filters?from=seojun.park%40arasystem.it.kr',
)
expect(isOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.query).toEqual({})
})
it('읽기 화면 ⋯ 「이런 메일로 필터 만들기」를 누르면 — 설정 › 필터의 새 필터 창이 그 메일의 보낸 사람 주소로 채워져 열린다', async () => {
installServer()
const message: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: '견적서 보내 드립니다',
preview: '',
receivedAt: '2026-10-28T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
const mailStore = useMailStore()
mailStore.selected = message
mailStore.selectedId = message.id
mailStore.selectedStatus = 'ready'
// 앱과 같은 이름의 라우트 — 읽기 화면과 필터 화면만 실제 화면으로 (같은 이름을 다시 더하면 바꿔 낀다)
const router = createTestRouter()
router.addRoute({ path: '/mail/:mailboxId/:messageId', name: 'mail-message', component: MessageReader })
router.addRoute({ path: '/settings/mail/filters', name: 'settings-mail-filters', component: MailFilterPage })
await router.push('/mail/inbox/m1')
await router.isReady()
const app = mount({ render: () => h(RouterView) }, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(app)
await app.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const item = app.findAll('[role="menuitem"]').find((menuItem) => menuItem.text() === '이런 메일로 필터 만들기')
await item?.trigger('click')
await flushPromises()
expect(app.get('h1').text()).toBe('필터')
expect(app.get('#mail-filter-dialog').attributes('open')).toBeDefined()
expect(app.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(
app.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value),
).toEqual(['seojun.park@arasystem.it.kr'])
expect((app.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
})
@@ -0,0 +1,330 @@
<script setup lang="ts">
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · README "6단계 — 메일 필터") — /settings/mail/filters · 탭 제목 "메일 필터"
// 머리 "필터" + 설명 + 오른쪽 [새 필터](50개면 끄고 까닭을 옆에) → 목록 위 노란 줄(다른 곳에서 바뀜 · 동작하지 않음) →
// 목록(MailFilterList — 없으면 MailFilterEmpty) + "필터 N개 · 50개까지 …" → 안내 상자(MailFilterNotes). 행 · [새 필터]는 고치기 창(MailFilterDialog)을 연다.
// - 들어올 때마다 필터를 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다). 메일함은 메일 화면이 받아 둔 목록이 있으면 그대로 쓰며 다시 맞추고
// (다른 탭에서 지웠을 수 있다) 없으면 받는다 — 옮길 메일함의 이름 · 지워진 메일함 안내에 쓴다. 둘 다 받아야 목록을 그린다
// (받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도")
// - 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(스토어 — 오류 알림은 인터셉터). 창의 저장 · 지우기는 useMailFilterDialog, 그 알림은 useMailFilterActions
// - 409 MAIL_012 는 알리지 않고 목록 위 줄 + [다시 불러오기](목록을 새로 받고 창을 닫는다). 고치던 창은 닫지 않고, 창 안에도
// 같은 사전 문구와 [다시 불러오기]를 둔다(창이 모달이라 뒤의 줄을 누를 수 없다) — 창 안의 것은 창 · 쓴 내용을 그대로 두고 목록만 다시 받는다.
// 받으면 초점을 [저장]으로 옮기고, 다음 [저장]은 다시 받은 목록 위에 넣는다
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import MailFilterDialog from '@/components/settings/MailFilterDialog.vue'
import MailFilterEmpty from '@/components/settings/MailFilterEmpty.vue'
import MailFilterList from '@/components/settings/MailFilterList.vue'
import MailFilterNotes from '@/components/settings/MailFilterNotes.vue'
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { getErrorMessage, type ApiError } from '@/composables/useApi'
import { useMailFilterDialog, type MailFilterDialogResult } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const {
state: dialog,
pending: dialogPending,
error: dialogError,
conflict: dialogConflict,
open: openDialog,
close: closeDialog,
clearError: clearDialogError,
save: saveDialog,
remove: removeDialog,
reload: reloadDialog,
} = useMailFilterDialog()
const createRef = ref<InstanceType<typeof MailFilterCreateButton> | null>(null)
const listRef = ref<InstanceType<typeof MailFilterList> | null>(null)
const dialogRef = ref<InstanceType<typeof MailFilterDialog> | null>(null)
/** 이 화면이 떠 있다고 스토어에 알린 것을 내린다 (화면 밖 되돌리기의 실패 알림 — useMailFilterActions) */
let detachPage: (() => void) | null = null
/** 목록 위 줄의 단추를 누르고 기다리는 중 */
const noticeBusy = ref<'reload' | 'reactivate' | null>(null)
/** ?from= 을 이미 받아 창을 열었다 — 주소에서 지울 때까지 다시 열지 않는다 */
let fromHandled = false
const rules = computed<readonly MailFilterRule[]>(() => filterStore.filterSet?.rules ?? [])
const ready = computed(
() => filterStore.status === 'ready' && filterStore.filterSet !== null && mailStore.mailboxesStatus === 'ready',
)
const loadError = computed<ApiError | null>(() => {
if (filterStore.status === 'error' && filterStore.error) return filterStore.error
if (mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError) return mailStore.mailboxesError
return null
})
const full = computed(() => ready.value && filterStore.isFull)
const countText = computed(() => MAIL_FILTER_TEXT.count(formatCount(rules.value.length)))
const conflictText = getErrorMessage('MAIL_012')
/** 메일함 — 받아 둔 목록이 있으면 그대로 보이며 다시 맞춘다(다른 탭에서 지웠을 수 있다), 없으면 받는다 */
function loadMailboxes() {
if (mailStore.mailboxesStatus === 'ready') void mailStore.loadMailboxes({ background: true })
else void mailStore.ensureMailboxes()
}
function retry() {
if (filterStore.status === 'error') void filterStore.load()
if (mailStore.mailboxesStatus === 'error') void mailStore.ensureMailboxes()
}
/** 초점을 잃었다 — 본문으로 갔거나, 닫힌 창 안에 남았다(창을 연 단추가 사라져 돌아갈 곳이 없을 때) */
function focusLost(): boolean {
const active = document.activeElement
if (active === null || active === document.body) return true
const dialogElement = active.closest('dialog')
return dialogElement !== null && !dialogElement.hasAttribute('open')
}
/** 누른 단추가 사라져 초점을 잃었으면 — 그 규칙 행의 단추, 없으면 머리의 [새 필터]로 */
async function restoreFocus(ruleId: string | null, part: MailFilterRowPart = 'edit') {
await nextTick()
if (!focusLost()) return
if (ruleId !== null && listRef.value?.focusRule(ruleId, part)) return
createRef.value?.focus()
}
function openNew() {
if (ready.value && !filterStore.isFull) openDialog(null)
}
function cancelDialog() {
const ruleId = dialog.value?.rule?.id ?? null
closeDialog()
void restoreFocus(ruleId)
}
/** 창의 저장 · 지우기가 끝나 창을 닫았으면 — 초점이 사라진 단추에 남지 않게 */
async function settleDialog(result: Promise<MailFilterDialogResult>) {
const { closed, focusRuleId } = await result
if (closed) void restoreFocus(focusRuleId)
}
function toggleRule(rule: MailFilterRule) {
void filterStore.setRuleEnabled(rule.id, !rule.isEnabled)
}
/** 순서 바꾸기 — 행을 옮기면(실패해서 되돌릴 때도) 누른 ▲ · ▼ 가 초점을 잃을 수 있어 같은 단추로 돌려준다 */
async function moveRule(rule: MailFilterRule, offset: -1 | 1) {
const part: MailFilterRowPart = offset < 0 ? 'up' : 'down'
const moving = filterStore.moveRule(rule.id, offset)
void restoreFocus(rule.id, part)
if (!(await moving)) void restoreFocus(rule.id, part)
}
/** 창 안의 [다시 불러오기] — 창 · 쓴 내용은 그대로 두고 목록을 다시 받는다. 받았으면 [저장]으로 */
async function reloadInDialog() {
if (await reloadDialog()) {
await nextTick()
dialogRef.value?.focusSave()
}
}
/** 목록 위 줄의 [다시 불러오기] — 목록을 새로 받고 고치기 창을 닫는다 */
async function reload() {
if (noticeBusy.value) return
noticeBusy.value = 'reload'
const done = await filterStore.reload()
noticeBusy.value = null
if (!done) return
closeDialog()
void restoreFocus(null)
}
/** [다시 켜기] — 지금 목록 그대로 저장한다 */
async function reactivate() {
if (noticeBusy.value || filterStore.saving) return
noticeBusy.value = 'reactivate'
await filterStore.reactivate()
noticeBusy.value = null
void restoreFocus(null)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void filterStore.load()
loadMailboxes()
onMounted(() => {
detachPage = filterStore.attachPage()
})
onBeforeUnmount(() => {
detachPage?.()
})
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(?from=<주소>) — 받은 뒤 한 번 새 필터 창을 열고(50개면 열지 않는다) 주소에서 지운다
watch(
[ready, () => route.query[MAIL_FILTER_FROM_QUERY]],
([isReady, from]) => {
if (from === undefined) {
fromHandled = false
return
}
if (!isReady || fromHandled) return
fromHandled = true
const address = typeof from === 'string' ? from.trim() : ''
if (address && !filterStore.isFull) openDialog(null, address)
const query = Object.fromEntries(Object.entries(route.query).filter(([key]) => key !== MAIL_FILTER_FROM_QUERY))
void router.replace({ query })
},
{ immediate: true },
)
</script>
<template>
<SettingsPageFrame
title-id="filter-page-title"
:title="MAIL_FILTER_TEXT.title"
:description="MAIL_FILTER_TEXT.description"
>
<template #actions>
<div class="filter-page__create">
<MailFilterCreateButton
ref="createRef"
:disabled="!ready || full"
:described-by="full ? 'mail-filter-full-note' : undefined"
@create="openNew"
/>
<p
v-if="full"
id="mail-filter-full-note"
class="filter-page__full-note"
>
{{ MAIL_FILTER_TEXT.fullNote }}
</p>
</div>
</template>
<MailLoadError
v-if="!ready && loadError"
:error="loadError"
leave-label=""
@retry="retry"
/>
<template v-else-if="ready">
<MailFilterNotice
v-if="filterStore.conflict"
icon="rotate-cw"
:message="conflictText"
:action-label="MAIL_FILTER_TEXT.reload"
:busy="noticeBusy === 'reload'"
live="alert"
data-filter-conflict
@action="reload"
/>
<MailFilterNotice
v-if="filterStore.filterSet?.isActive === false"
icon="alert-circle"
:message="MAIL_FILTER_TEXT.inactive"
:action-label="MAIL_FILTER_TEXT.reactivate"
:busy="noticeBusy === 'reactivate' || filterStore.saving"
data-filter-inactive
@action="reactivate"
/>
<div
v-if="rules.length > 0"
class="filter-page__list"
>
<MailFilterList
ref="listRef"
:rules="rules"
:mailboxes="mailStore.mailboxes"
:busy="filterStore.saving"
@toggle="toggleRule"
@move="moveRule"
@edit="(rule) => openDialog(rule)"
/>
<p
class="filter-page__count"
data-filter-count
>
{{ countText }}
</p>
</div>
<MailFilterEmpty
v-else
@create="openNew"
/>
<MailFilterNotes />
<MailFilterDialog
ref="dialogRef"
:open="dialog !== null"
:rule="dialog?.rule ?? null"
:prefill-from="dialog?.prefillFrom ?? null"
:mailboxes="mailStore.mailboxes"
:user-email="authStore.user?.email ?? ''"
:pending="dialogPending"
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
@save="(rule) => settleDialog(saveDialog(rule))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"
@clear-error="clearDialogError"
@reload="reloadInDialog"
/>
</template>
<div
v-else
class="filter-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* [새 필터] + 50개를 다 만들었을 때의 까닭 (디자인 원본 MailFilterStates) */
.filter-page__create {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-10) var(--space-14);
}
.filter-page__full-note {
max-width: 17.5rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-page__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.filter-page__count {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받는 동안의 자리 — 목록 몇 줄만큼 */
.filter-page__placeholder {
min-height: 15rem;
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
</style>
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
['필터', '/settings/mail/filters'],
['알림', '/settings/mail/notify'],
],
])
@@ -69,4 +70,23 @@ describe('SettingsLayout', () => {
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
})
it('설정 › 메일 › 필터 — /settings/mail/filters, 탭 제목 "메일 필터" (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
expect(router.currentRoute.value.meta.title).toBe('메일 필터')
expect(router.currentRoute.value.meta.requiresAuth).toBe(true)
expect(router.currentRoute.value.query).toEqual({ from: 'seojun.park@arasystem.it.kr' })
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
})
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/filters')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('필터')
expect(current.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'
+8 -1
View File
@@ -47,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림) + 하위 화면.
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -72,6 +72,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailVacationPage.vue'),
meta: { title: '부재중 자동 응답' },
},
{
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 (화면이 받은 뒤 지운다)
path: 'mail/filters',
name: SETTINGS_ROUTES.filters,
component: () => import('@/pages/settings/MailFilterPage.vue'),
meta: { title: '메일 필터' },
},
{
path: 'mail/notify',
name: SETTINGS_ROUTES.notify,
+8
View File
@@ -252,6 +252,14 @@ export const ICONS = {
// ── 5단계: 실시간 새 메일 · 바탕화면 알림 (디자인 원본 SettingsNotify · MailRealtime) ──
/** 설정 메뉴의 알림 · 시험 알림 · 목록 위 권하기 줄 */
bell: [path('M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9'), path('M13.73 21a2 2 0 0 1-3.46 0')],
// ── 6단계: 메일 필터 (디자인 원본 SettingsFilter · MailFilterStates) ──
/** 설정 메뉴의 필터 · 필터가 없을 때 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 (깔때기) */
filter: [polygon('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')],
/** 필터 목록의 ▲ (순서 위로) */
'chevron-up': [polyline('18 15 12 9 6 15')],
/** 목록 위 줄 "다른 곳에서 필터가 바뀌어…" (한 바퀴 화살표) */
'rotate-cw': [polyline('23 4 23 10 17 10'), path('M20.49 15a9 9 0 1 1-2.12-9.36L23 10')],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -0,0 +1,159 @@
// 설정 › 메일 › 필터 상수 — 계약 2절 "메일 필터" · 디자인 README "6단계 — 메일 필터" (디자인 원본 SettingsFilter · MailFilterStates)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다(넘으면 400 VAL_001). 길이는 JS 문자열 길이(UTF-16 단위)로 센다.
import type { MailFilterField, MailFilterMatch, MailFilterOperator } from '@/shared/types/mail'
/** 필터 수 — 50개까지 (다 만들면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다) */
export const MAIL_FILTER_MAX_RULES = 50
/** 이름 — 0~50자 (비우면 조건이 이름 자리에 온다) */
export const MAIL_FILTER_NAME_MAX_LENGTH = 50
/** 조건 수 — 모두 · 하나라도는 1~5개 */
export const MAIL_FILTER_MAX_CONDITIONS = 5
/** 조건의 글 — 앞뒤 공백을 뺀 1~200자 */
export const MAIL_FILTER_VALUE_MAX_LENGTH = 200
/** 전달 주소 — 메일 주소의 최대 길이 (서버와 같다) */
export const MAIL_FILTER_FORWARD_MAX_LENGTH = 254
/** 전달 주소의 @ 앞 — 64자까지 (RFC 5321 · 서버의 IsEmail 과 같다) */
export const MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH = 64
/**
* 알림 · 단추 이름에 넣는 필터 이름 — 이보다 길면 자르고 "…"를 붙인다. 이름 한도와 같아 이름은 잘리지 않고,
* 이름이 없어 조건 글(5개 × 200자까지)이 이름 자리에 올 때만 잘린다
*/
export const MAIL_FILTER_LABEL_MAX_LENGTH = MAIL_FILTER_NAME_MAX_LENGTH
/**
* 규칙 값에 쓸 수 없는 글 — 엔진(Stalwart)은 문자열의 `${…}` 를 늘 변수로 펼친다.
* 펼쳐져 빈 값이 되면 `포함`이 모든 메일에 맞는다 (계약 2절 "메일 필터"의 "만드는 Sieve")
*/
export const MAIL_FILTER_FORBIDDEN_SEQUENCE = '${'
/** 읽기 화면 ⋯ 「이런 메일로 필터 만들기」가 보낸 사람 주소를 싣는 쿼리 — /settings/mail/filters?from=<주소> */
export const MAIL_FILTER_FROM_QUERY = 'from'
/** 조건의 "어디에서" — 고치기 창의 고르기와 목록 요약이 함께 쓴다 */
export const MAIL_FILTER_FIELD_LABELS: Readonly<Record<MailFilterField, string>> = {
from: '보낸 사람',
to: '받는 사람',
subject: '제목',
body: '본문',
}
export const MAIL_FILTER_OPERATOR_LABELS: Readonly<Record<MailFilterOperator, string>> = {
contains: '포함',
notContains: '포함하지 않음',
}
/** "어떤 메일에" 고르기 */
export const MAIL_FILTER_MATCH_LABELS: Readonly<Record<MailFilterMatch, string>> = {
all: '다음 조건이 모두 맞는 메일',
any: '다음 조건 가운데 하나라도 맞는 메일',
always: '모든 메일',
}
/** 찾을 글 칸의 보기 — 어디에서 찾는지에 따라. domain 은 로그인한 직원 주소의 도메인 (받는 사람의 보기는 사내 주소) */
export const MAIL_FILTER_VALUE_PLACEHOLDERS: Readonly<Record<MailFilterField, (domain: string) => string>> = {
from: () => '예: @hanbit-partners.co.kr 또는 이름',
to: (domain) => `예: all@${domain}`,
subject: () => '예: [결재]',
body: () => '예: 견적서',
}
/**
* 목록 한 줄의 요약 — "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처 · 읽음으로 표시".
* 조건은 " 그리고 " / " 또는 ", 할 일은 " · " 로 잇는다. 이름이 없으면 조건이 이름 자리에 온다
*/
export const MAIL_FILTER_SUMMARY_TEXT = {
condition: (field: string, value: string, operator: string) => `${field}에 ‘${value}’ ${operator}`,
and: ' 그리고 ',
or: ' 또는 ',
always: '모든 메일',
/** 조건과 할 일 사이 — 이름이 없으면 할 일 앞에만 */
arrow: '→ ',
move: (mailbox: string) => `옮기기: ${mailbox}`,
/** 옮길 메일함이 지워졌다 */
missingMailbox: '없는 메일함',
markAsRead: '읽음으로 표시',
flag: '중요 표시',
forward: (address: string) => `전달: ${address}`,
actionSeparator: ' · ',
} as const
export const MAIL_FILTER_TEXT = {
title: '필터',
description:
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에만 적용됩니다.',
create: '새 필터',
/**
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
* 되돌리기 · 다른 곳에서 지워진 규칙의 저장이 한도를 넘을 때(오류 알림 · 고치기 창 안)
*/
fullNote: `필터는 ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.`,
listLabel: '필터 — 위에서부터 차례로 적용',
count: (count: string) => `필터 ${count}개 · ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 행을 누르면 고칩니다.`,
// 단추 이름 · 알림의 quoted 는 따옴표로 묶은 이름(quotedRuleTitle — 길면 자르고, 이름에 작은따옴표가 있으면 큰따옴표로 묶는다)
/** 켜기 스위치(role="switch") — 켜짐 · 꺼짐은 aria-checked 가 알린다 (이름은 바꾸지 않는다) */
switchLabel: (quoted: string) => `${quoted} 필터 사용`,
editLabel: (quoted: string) => `${quoted} 필터 고치기`,
moveUp: (quoted: string) => `${quoted} 위로`,
moveDown: (quoted: string) => `${quoted} 아래로`,
missingMailbox: '옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
emptyTitle: '아직 필터가 없습니다',
emptyText:
'보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.',
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
reload: '다시 불러오기',
inactive: '지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.',
reactivate: '다시 켜기',
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로"는 굵게
noteOrderStrong: '위에서부터 차례로',
noteOrderAfter: ' 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
noteSpam: '스팸으로 판정된 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
noteForward:
'전달은 사내 주소로만 할 수 있고, 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
noteVacation: '부재중 자동 응답은 필터와 함께 동작합니다.',
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
saved: '필터를 저장했습니다.',
removed: (quoted: string) => `${quoted} 필터를 지웠습니다.`,
undo: '되돌리기',
/** 되돌리기가 끝났을 때 (디자인 원본 SettingsFilter 의 시안) */
restored: '필터를 되돌렸습니다.',
} as const
/** 고치기 창 (모달 620 — 디자인 원본 SettingsFilter) */
export const MAIL_FILTER_DIALOG_TEXT = {
createTitle: '새 필터',
editTitle: '필터 고치기',
nameLabel: '이름',
nameOptional: '(선택)',
namePlaceholder: '비워 두면 조건이 이름이 됩니다',
matchLegend: '어떤 메일에',
conditionField: (no: number) => `조건 ${no} — 어디에서`,
conditionOperator: (no: number) => `조건 ${no} — 포함 · 포함하지 않음`,
conditionValue: (no: number) => `조건 ${no} — 찾을 글`,
conditionRemove: (no: number) => `조건 ${no} 지우기`,
addCondition: '조건 더하기',
conditionHint: `${MAIL_FILTER_MAX_CONDITIONS}개까지 · 글자는 대소문자를 가리지 않습니다`,
actionsLegend: '할 일',
move: '메일함으로 옮기기',
moveTarget: '옮길 메일함',
moveHint: '옮기면 이 아래 필터는 적용하지 않습니다.',
/** 옮길 메일함이 지워졌다 — 이 항목이 골라진 채로 보인다 (다시 고를 수는 없다) */
missingMailboxOption: '(지워진 메일함) — 다른 메일함을 고르세요',
markAsRead: '읽음으로 표시',
flag: '중요 표시',
forward: '동료에게 전달',
forwardTarget: '전달할 사내 주소',
/** domain 은 로그인한 직원 주소의 도메인 */
forwardPlaceholder: (domain: string) => `이름@${domain}`,
forwardHint: '사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
notice: '새로 오는 메일에만 적용됩니다 — 이미 받은 메일은 그대로입니다. 스팸으로 판정된 메일에는 적용하지 않습니다.',
remove: '이 필터 지우기',
save: '저장',
cancel: '취소',
// 칸 아래 빨간 글 — [저장]을 누를 때 검사한다(알림은 띄우지 않는다). 그 밖의 형식 오류(이름 · 찾을 글의 길이 · 제어 문자)는 에러 코드 사전의 VAL_001
conditionEmpty: '비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
conditionVariable: '‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
// 전달 주소 — 캔버스에는 바깥 주소만 있다. 빈 칸 · 형식 · 내 주소는 2026-10-28 구현하며 정함 (형식 문구는 서버의 거절 문구와 같다)
forwardEmpty: '전달할 주소를 입력해 주세요.',
forwardInvalid: '전달할 주소 형식이 올바르지 않습니다.',
forwardExternal: (domain: string) => `사내 주소(@${domain})로만 전달할 수 있습니다.`,
forwardSelf: '내 주소로는 전달할 수 없습니다.',
actionsEmpty: '할 일을 하나 이상 골라 주세요.',
} as const
+2
View File
@@ -312,6 +312,8 @@ export const MAIL_SPAM_TEXT = {
export const MAIL_READER_MORE_TEXT = {
button: '더 보기',
downloadSource: '원문 내려받기 (.eml)',
/** 설정 › 필터의 새 필터 창이 보낸 사람 주소로 채워져 열린다 (6단계 — 디자인 원본 MailFilterStates) */
createFilter: '이런 메일로 필터 만들기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
@@ -1,6 +1,6 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기])가
// 이 이름을 함께 쓴다.
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터")
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
import type { IconName } from '@/shared/constants/icons'
@@ -9,6 +9,7 @@ export const SETTINGS_ROUTES = {
password: 'settings-account-password',
signature: 'settings-mail-signature',
vacation: 'settings-mail-vacation',
filters: 'settings-mail-filters',
notify: 'settings-mail-notify',
} as const
@@ -37,6 +38,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
items: [
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
{ routeName: SETTINGS_ROUTES.filters, label: '필터', icon: 'filter' },
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
+59
View File
@@ -275,6 +275,65 @@ export interface MailVacation {
textBody: string
}
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
/** 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음) */
export type MailFilterMatch = 'all' | 'any' | 'always'
/** 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */
export type MailFilterField = 'from' | 'to' | 'subject' | 'body'
export type MailFilterOperator = 'contains' | 'notContains'
export interface MailFilterCondition {
field: MailFilterField
operator: MailFilterOperator
/** 1~200자(앞뒤 공백을 뺀 값) — 제어 문자 · `${` 는 안 된다 */
value: string
}
/** 할 일 — 하나 이상 */
export interface MailFilterActions {
/**
* 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 (보낸편지함 · 임시보관함은 400 VAL_001).
* 지운 메일함을 가리킬 수 있다 — 그러면 받은편지함으로 들어온다 (목록에 빨간 안내)
*/
moveToMailboxId: string | null
markAsRead: boolean
flag: boolean
/** 사내 주소(MAIL_DOMAIN)만, 자기 주소는 안 된다 */
forwardTo: string | null
}
export interface MailFilterRule {
/** 클라이언트가 만든다(UUID) — 목록 안에서 겹치지 않게 */
id: string
/** 0~50자 — 비면 화면이 조건으로 이름을 보인다 */
name: string
isEnabled: boolean
match: MailFilterMatch
/** all · any 는 1~5개, always 는 빈 배열 */
conditions: MailFilterCondition[]
actions: MailFilterActions
}
/** GET · PUT /mail-filters 응답 */
export interface MailFilterSet {
/** 위에서부터 차례로 적용한다 — 50개까지 */
rules: MailFilterRule[]
/** 지금 규칙의 지문 — PUT 에 그대로 돌려보낸다 (그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012) */
version: string
/** false = 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다 */
isActive: boolean
}
/** PUT /mail-filters 본문 — 전체 교체 (켜기 · 순서 · 지우기 · 고치기 모두) */
export interface MailFilterSetPayload {
rules: MailFilterRule[]
/** 받은(마지막으로 저장한) MailFilterSet 의 version */
version: string
}
// ── 서명 (계약 2절 "서명") ─────────────────────────────
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
+3 -27
View File
@@ -1,40 +1,16 @@
// 중복 발송 방지 키 — 계약 2절 "발송 본문"의 Idempotency-Key 헤더 값 (무작위 UUID)
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { createUuid } from '@/shared/utils/uuid'
/** 보내기 한 번의 결과 — 성공했거나, 실패한 에러 코드 */
export type SendOutcome = 'sent' | ErrorCode
/** UUID 한 개의 바이트 수 */
const UUID_BYTES = 16
/** 7번째 바이트 상위 4비트 = 버전(0100: 무작위 v4) */
const VERSION_BYTE = 6
/** 9번째 바이트 상위 2비트 = 변형(10: RFC 9562) */
const VARIANT_BYTE = 8
/** 16진 32자리 → 8-4-4-4-12 */
const UUID_LAYOUT = /^(.{8})(.{4})(.{4})(.{4})(.{12})$/
const HEX_RADIX = 16
function toHex(byte: number): string {
return byte.toString(HEX_RADIX).padStart(2, '0')
}
/** crypto.getRandomValues 로 UUID v4 를 만든다 — randomUUID 를 쓸 수 없을 때만 쓴다 */
function randomUuidV4(): string {
const bytes = crypto.getRandomValues(new Uint8Array(UUID_BYTES))
bytes[VERSION_BYTE] = ((bytes[VERSION_BYTE] ?? 0) & 0x0f) | 0x40
bytes[VARIANT_BYTE] = ((bytes[VARIANT_BYTE] ?? 0) & 0x3f) | 0x80
return Array.from(bytes, toHex).join('').replace(UUID_LAYOUT, '$1-$2-$3-$4-$5')
}
/**
* 쓰기 화면 하나에 쓸 새 키.
* crypto.randomUUID 는 보안 컨텍스트(HTTPS·localhost)에서만 있다 — 사내망 IP 로 연 개발 서버처럼
* 그렇지 않은 곳에서도 쓰기 화면이 깨지지 않게, 같은 형식(UUID v4)을 직접 만들어 쓴다.
* 쓰기 화면 하나에 쓸 새 키 — UUID v4 (보안 컨텍스트 밖에서도 같은 형식, uuid.ts)
*/
export function createIdempotencyKey(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return randomUuidV4()
return createUuid()
}
/**
@@ -0,0 +1,356 @@
import { describe, expect, it } from 'vitest'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import {
actionsText,
conditionsText,
defaultMoveTarget,
filterDraftOf,
filterDraftProblems,
filterMoveTargets,
filterRuleOf,
hasFilterProblems,
isMoveTargetMissing,
mailDomainOf,
newFilterDraft,
quotedRuleTitle,
ruleSummary,
ruleTitle,
withoutRule,
withRuleEnabled,
withRuleMoved,
withRuleRestored,
withRuleSaved,
type MailFilterDraft,
} from './mail-filter'
// 메일 필터 순수 함수 — 목록 요약 · 옮길 메일함 · 고치기 창의 값 · 검사 · 목록 바꾸기 (계약 2절 "메일 필터" · 디자인 원본 SettingsFilter)
const ME = 'ara.kim@arasystem.it.kr'
function mailbox(id: string, name: string, role: Mailbox['role'] = null, parentId: string | null = null): Mailbox {
return { id, name, role, parentId, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const INBOX = mailbox('inbox', '받은편지함', 'inbox')
const SENT = mailbox('sent', '보낸편지함', 'sent')
const DRAFTS = mailbox('drafts', '임시보관함', 'drafts')
const JUNK = mailbox('junk', '스팸메일함', 'junk')
const TRASH = mailbox('trash', '휴지통', 'trash')
const ARCHIVE = mailbox('archive', '보관함', 'archive')
const VENDOR = mailbox('vendor', '거래처')
const NOTICE = mailbox('notice', '전체 공지')
const ALPHA = mailbox('alpha', '알파', null, 'inbox')
const OLD = mailbox('old', '지난 메일', null, 'archive')
const MAILBOXES = [TRASH, VENDOR, ARCHIVE, OLD, JUNK, NOTICE, SENT, DRAFTS, ALPHA, INBOX]
function rule(patch: Partial<MailFilterRule> = {}): MailFilterRule {
return {
id: 'r1',
name: '',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null },
...patch,
}
}
const nameOf = (id: string) => MAILBOXES.find((box) => box.id === id)?.name ?? null
function draft(patch: Partial<MailFilterDraft> = {}): MailFilterDraft {
return { ...newFilterDraft({ moveTarget: 'vendor' }), ...patch }
}
describe('목록의 요약 (디자인 원본 SettingsFilter)', () => {
it('조건은 "보낸 사람에 ‘…’ 포함", 모두는 " 그리고 ", 하나라도는 " 또는 ", 모든 메일은 "모든 메일"', () => {
const conditions: MailFilterRule['conditions'] = [
{ field: 'subject', operator: 'contains', value: '알파' },
{ field: 'body', operator: 'notContains', value: 'Project Alpha' },
]
expect(conditionsText(rule())).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함')
expect(conditionsText(rule({ match: 'all', conditions }))).toBe(
'제목에 ‘알파’ 포함 그리고 본문에 ‘Project Alpha’ 포함하지 않음',
)
expect(conditionsText(rule({ match: 'any', conditions }))).toBe(
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함하지 않음',
)
expect(conditionsText(rule({ match: 'always', conditions: [] }))).toBe('모든 메일')
})
it('할 일은 옮기기 · 읽음 · 중요 · 전달 차례로 " · " — 지워진 메일함은 "없는 메일함"', () => {
expect(
actionsText({ moveToMailboxId: 'vendor', markAsRead: true, flag: true, forwardTo: 'doyun.lee@arasystem.it.kr' }, nameOf),
).toBe('옮기기: 거래처 · 읽음으로 표시 · 중요 표시 · 전달: doyun.lee@arasystem.it.kr')
expect(actionsText({ moveToMailboxId: 'gone', markAsRead: false, flag: false, forwardTo: null }, nameOf)).toBe(
'옮기기: 없는 메일함',
)
})
it('이름이 있으면 이름 + "조건 → 할 일", 없으면 조건이 이름 자리에 오고 요약은 "→ 할 일"', () => {
const named = rule({ name: '거래처 메일', actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null } })
expect(ruleTitle(named)).toBe('거래처 메일')
expect(ruleSummary(named, nameOf)).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처')
const unnamed = rule({ name: ' ', actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null } })
expect(ruleTitle(unnamed)).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함')
expect(ruleSummary(unnamed, nameOf)).toBe('→ 읽음으로 표시')
})
it('알림 · 단추 이름의 이름 — 따옴표로 묶고, 조건 글이 이름 자리에 오면 작은따옴표가 겹치지 않게 큰따옴표로', () => {
expect(quotedRuleTitle(rule({ name: '거래처 메일' }))).toBe('‘거래처 메일’')
expect(quotedRuleTitle(rule())).toBe('“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함”')
expect(quotedRuleTitle(rule({ name: '‘중요’ 메일' }))).toBe('“‘중요’ 메일”')
})
it('알림 · 단추 이름의 이름 — 50자(이름 한도)를 넘으면 자르고 "…" (이름은 잘리지 않는다 · 이모지를 반쪽으로 자르지 않는다)', () => {
const name = '가'.repeat(50)
expect(quotedRuleTitle(rule({ name }))).toBe(`‘${name}’`)
const long = rule({ conditions: [{ field: 'subject', operator: 'contains', value: 'x'.repeat(200) }] })
const label = quotedRuleTitle(long)
expect(label).toBe(`“제목에 ‘${'x'.repeat(45)}…”`)
expect(Array.from(label.slice(1, -2))).toHaveLength(50)
const emoji = rule({ conditions: [{ field: 'body', operator: 'contains', value: '😀'.repeat(60) }] })
expect(quotedRuleTitle(emoji)).toBe(`“본문에 ‘${'😀'.repeat(45)}…”`)
})
it('옮길 메일함이 목록에 없으면 지워진 것이다 — 옮기지 않는 규칙은 아니다', () => {
const moving = (id: string | null) =>
rule({ actions: { moveToMailboxId: id, markAsRead: true, flag: false, forwardTo: null } })
expect(isMoveTargetMissing(moving('gone'), MAILBOXES)).toBe(true)
expect(isMoveTargetMissing(moving('vendor'), MAILBOXES)).toBe(false)
expect(isMoveTargetMissing(moving(null), MAILBOXES)).toBe(false)
})
})
describe('옮길 메일함 고르기', () => {
it('받은편지함(과 그 아래) → 내 메일함 → 보관함(과 그 아래) → 스팸메일함 → 휴지통, 보낸편지함 · 임시보관함은 뺀다', () => {
expect(filterMoveTargets(MAILBOXES).map((box) => box.name)).toEqual([
'받은편지함',
'알파',
'거래처',
'전체 공지',
'보관함',
'지난 메일',
'스팸메일함',
'휴지통',
])
})
it('새 필터의 옮길 메일함은 처음 나오는 내 메일함, 없으면 받은편지함', () => {
expect(defaultMoveTarget(filterMoveTargets(MAILBOXES))).toBe('alpha')
expect(defaultMoveTarget(filterMoveTargets([INBOX, JUNK, TRASH]))).toBe('inbox')
expect(defaultMoveTarget([])).toBe('')
})
it('사내 도메인은 주소의 @ 뒤 (소문자)', () => {
expect(mailDomainOf('Ara.Kim@AraSystem.it.kr')).toBe('arasystem.it.kr')
expect(mailDomainOf('')).toBe('')
})
})
describe('고치기 창의 값 ↔ 규칙', () => {
it('새 필터 — 모두 · 보낸 사람 빈 조건 한 줄 · 할 일 없음, 읽기 화면에서 왔으면 그 주소', () => {
expect(newFilterDraft({ moveTarget: 'vendor' })).toEqual({
name: '',
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '' }],
move: false,
moveTarget: 'vendor',
markAsRead: false,
flag: false,
forward: false,
forwardTo: '',
})
expect(newFilterDraft({ moveTarget: 'vendor', from: 'seojun.park@arasystem.it.kr' }).conditions).toEqual([
{ field: 'from', operator: 'contains', value: 'seojun.park@arasystem.it.kr' },
])
})
it('저장할 때 이름 · 글 · 주소의 앞뒤 공백을 빼고, 모든 메일이면 조건을 비우고, 끈 할 일은 빈 값이다', () => {
const value = draft({
name: ' 거래처 메일 ',
match: 'always',
conditions: [{ field: 'subject', operator: 'contains', value: ' 견적 ' }],
move: false,
moveTarget: 'vendor',
markAsRead: true,
forward: true,
forwardTo: ' doyun.lee@arasystem.it.kr ',
})
expect(filterRuleOf(value, { id: 'r9', isEnabled: false })).toEqual({
id: 'r9',
isEnabled: false,
name: '거래처 메일',
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
})
expect(filterRuleOf({ ...value, match: 'any', move: true }, { id: 'r9', isEnabled: true })).toMatchObject({
conditions: [{ field: 'subject', operator: 'contains', value: '견적' }],
actions: { moveToMailboxId: 'vendor' },
})
})
it('있는 규칙을 고칠 때 — 모든 메일 규칙도 조건 칸 한 줄을 남겨 두고, 옮기지 않던 규칙의 고르기는 기본 메일함', () => {
const always = rule({
match: 'always',
conditions: [],
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
})
expect(filterDraftOf(always, 'vendor')).toMatchObject({
match: 'always',
conditions: [{ field: 'from', operator: 'contains', value: '' }],
move: false,
moveTarget: 'vendor',
forward: true,
forwardTo: 'doyun.lee@arasystem.it.kr',
})
const gone = rule({ actions: { moveToMailboxId: 'gone', markAsRead: false, flag: false, forwardTo: null } })
expect(filterDraftOf(gone, 'vendor')).toMatchObject({ move: true, moveTarget: 'gone' })
})
})
describe('검사 — 서버와 같은 규칙 (계약 2절 "메일 필터"의 "클라이언트")', () => {
it('빈 조건 · `${` · 200자 넘음 · 제어 문자 — 줄마다 따로 (모든 메일이면 보지 않는다)', () => {
const value = draft({
markAsRead: true,
conditions: [
{ field: 'from', operator: 'contains', value: ' ' },
{ field: 'subject', operator: 'contains', value: '가격 ${price}' },
{ field: 'body', operator: 'contains', value: 'a'.repeat(201) },
{ field: 'body', operator: 'contains', value: '탭\t문자' },
{ field: 'to', operator: 'notContains', value: ` ${'b'.repeat(200)} ` },
],
})
expect(filterDraftProblems(value, ME).conditions).toEqual(['empty', 'variable', 'invalid', 'invalid', null])
expect(filterDraftProblems({ ...value, match: 'always' }, ME).conditions).toEqual([null, null, null, null, null])
})
it('할 일이 하나도 없으면 잘못이다 — 전달을 켰으면 주소가 틀려도 할 일은 있다', () => {
expect(filterDraftProblems(draft({ conditions: [{ field: 'from', operator: 'contains', value: 'x' }] }), ME)).toMatchObject({
actions: true,
forward: null,
})
expect(filterDraftProblems(draft({ forward: true, forwardTo: ' ' }), ME)).toMatchObject({
actions: false,
forward: 'empty',
})
})
it('전달은 사내 주소(대소문자 무시)만, 내 주소 · `${` 는 안 된다', () => {
const forward = (forwardTo: string) => filterDraftProblems(draft({ forward: true, forwardTo }), ME).forward
expect(forward('Doyun.Lee@ARASYSTEM.it.kr')).toBeNull()
expect(forward(' doyun.lee@arasystem.it.kr ')).toBeNull()
expect(forward("o'neil+filter_1@arasystem.it.kr")).toBeNull()
expect(forward('me@gmail.com')).toBe('external')
expect(forward('doyun.lee@arasystem.it.kr.evil.com')).toBe('external')
expect(forward('ARA.KIM@arasystem.it.kr')).toBe('self')
expect(forward('${x}@arasystem.it.kr')).toBe('variable')
expect(forward('')).toBe('empty')
// 로그인한 주소를 모르면 사내 주소를 알 수 없다 — 보내지 않는다
expect(filterDraftProblems(draft({ forward: true, forwardTo: 'doyun.lee@arasystem.it.kr' }), '').forward).toBe('external')
})
it.each([
['연달은 점', 'a..b@arasystem.it.kr'],
['앞의 점', '.ab@arasystem.it.kr'],
['뒤의 점', 'ab.@arasystem.it.kr'],
['@ 앞 65자', `${'a'.repeat(65)}@arasystem.it.kr`],
['쉼표', 'a,b@arasystem.it.kr'],
['괄호', 'a(b)@arasystem.it.kr'],
['큰따옴표', 'a"b@arasystem.it.kr'],
['빈칸', 'doyun lee@arasystem.it.kr'],
['@ 둘', 'a@b@arasystem.it.kr'],
['@ 앞이 빔', '@arasystem.it.kr'],
['254자 넘음', `${'a'.repeat(64)}@${'b'.repeat(190)}.arasystem.it.kr`],
['한글', '김아라@arasystem.it.kr'],
])('전달 주소의 형식 — %s 는 서버(IsEmail)가 받지 않으니 화면도 막는다 (dot-atom · @ 앞 64자)', (_label, address) => {
expect(filterDraftProblems(draft({ forward: true, forwardTo: address }), ME).forward).toBe('invalid')
})
it('전달 주소의 @ 앞은 64자까지 된다', () => {
expect(filterDraftProblems(draft({ forward: true, forwardTo: `${'a'.repeat(64)}@arasystem.it.kr` }), ME).forward).toBeNull()
})
it('이름은 50자 · 제어 문자 없음 (앞뒤 공백은 빼고 잰다)', () => {
const name = (value: string) => filterDraftProblems(draft({ name: value, markAsRead: true }), ME).name
expect(name(` ${'가'.repeat(50)} `)).toBe(false)
expect(name('가'.repeat(51))).toBe(true)
expect(name('이름\t탭')).toBe(true)
})
it('문제가 없으면 hasFilterProblems 가 false', () => {
const value = draft({ conditions: [{ field: 'from', operator: 'contains', value: '@hanbit' }], move: true })
expect(hasFilterProblems(filterDraftProblems(value, ME))).toBe(false)
expect(hasFilterProblems(filterDraftProblems({ ...value, move: false }, ME))).toBe(true)
})
})
describe('목록 바꾸기 — 모두 PUT 전체 교체', () => {
const A = rule({ id: 'a' })
const B = rule({ id: 'b' })
const C = rule({ id: 'c' })
it('켜기 · 끄기는 그 규칙만', () => {
expect(withRuleEnabled([A, B], 'b', false).map((item) => [item.id, item.isEnabled])).toEqual([
['a', true],
['b', false],
])
})
it('한 칸 위 · 아래 — 끝이면 null', () => {
expect(withRuleMoved([A, B, C], 'b', -1)?.map((item) => item.id)).toEqual(['b', 'a', 'c'])
expect(withRuleMoved([A, B, C], 'b', 1)?.map((item) => item.id)).toEqual(['a', 'c', 'b'])
expect(withRuleMoved([A, B, C], 'a', -1)).toBeNull()
expect(withRuleMoved([A, B, C], 'c', 1)).toBeNull()
expect(withRuleMoved([A, B, C], 'x', 1)).toBeNull()
})
it('고친 규칙은 그 자리, 새 규칙은 끝', () => {
const edited = { ...B, name: '고침' }
expect(withRuleSaved([A, B, C], edited).map((item) => item.name || item.id)).toEqual(['a', '고침', 'c'])
expect(withRuleSaved([A, B], C).map((item) => item.id)).toEqual(['a', 'b', 'c'])
})
it('지우면 그 규칙과 자리(앞 · 뒤 규칙)를 돌려주고, 되돌리기는 그 자리에 다시 넣는다 (그사이 바뀐 다른 규칙은 그대로)', () => {
const removal = withoutRule([A, B, C], 'b')
expect(removal?.rules.map((item) => item.id)).toEqual(['a', 'c'])
expect(removal?.point).toEqual({ rule: B, index: 1, afterId: 'a', beforeId: 'c' })
expect(withoutRule([A], 'x')).toBeNull()
const changed = withRuleEnabled(removal?.rules ?? [], 'c', false)
const restored = removal ? withRuleRestored(changed, removal.point) : null
expect(restored?.map((item) => [item.id, item.isEnabled])).toEqual([
['a', true],
['b', true],
['c', false],
])
// 이미 있으면 null
expect(withRuleRestored([A, B], { rule: B, index: 0, afterId: null, beforeId: null })).toBeNull()
})
it('되돌릴 자리 — 맨 위였으면 맨 위, 아니면 앞 규칙 뒤 → (앞 규칙이 없으면) 뒤 규칙 앞 → 지운 때의 차례(넘으면 끝)', () => {
const D = rule({ id: 'd' })
const at = (rules: MailFilterRule[], point: Omit<Parameters<typeof withRuleRestored>[1], 'rule'>) =>
withRuleRestored(rules, { rule: D, ...point })?.map((item) => item.id)
expect(at([B, C], { index: 2, afterId: null, beforeId: 'b' })).toEqual(['d', 'b', 'c'])
expect(at([C, A], { index: 1, afterId: 'a', beforeId: 'c' })).toEqual(['c', 'a', 'd'])
expect(at([C, B], { index: 1, afterId: 'a', beforeId: 'c' })).toEqual(['d', 'c', 'b'])
expect(at([B], { index: 5, afterId: 'a', beforeId: 'c' })).toEqual(['b', 'd'])
expect(at([B, C], { index: 1, afterId: 'x', beforeId: null })).toEqual(['b', 'd', 'c'])
})
it('지우기 여럿을 어떤 차례로 되돌려도 원래 차례로 돌아온다 (앞 · 뒤 규칙 기준)', () => {
const D = rule({ id: 'd' })
const first = withoutRule([A, B, C, D], 'b')
const second = first ? withoutRule(first.rules, 'd') : null
if (!first || !second) throw new Error('지우지 못했다')
const bThenD = withRuleRestored(withRuleRestored(second.rules, first.point) ?? [], second.point)
const dThenB = withRuleRestored(withRuleRestored(second.rules, second.point) ?? [], first.point)
expect(bThenD?.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
expect(dThenB?.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
})
})
+378
View File
@@ -0,0 +1,378 @@
// 메일 필터 — 목록의 요약 · 옮길 메일함 · 고치기 창의 칸 · 검사 · 목록 바꾸기 (순수 함수만 둔다)
// 계약 2절 "메일 필터" · 디자인 README "6단계 — 메일 필터" (디자인 원본 SettingsFilter · MailFilterStates)
import {
MAIL_FILTER_FIELD_LABELS,
MAIL_FILTER_FORBIDDEN_SEQUENCE,
MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH,
MAIL_FILTER_FORWARD_MAX_LENGTH,
MAIL_FILTER_LABEL_MAX_LENGTH,
MAIL_FILTER_NAME_MAX_LENGTH,
MAIL_FILTER_OPERATOR_LABELS,
MAIL_FILTER_SUMMARY_TEXT,
MAIL_FILTER_VALUE_MAX_LENGTH,
} from '@/shared/constants/mail-filter'
import type {
Mailbox,
MailboxRole,
MailFilterActions,
MailFilterCondition,
MailFilterField,
MailFilterMatch,
MailFilterRule,
} from '@/shared/types/mail'
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
import { hasControlChar, orderMailboxes } from '@/shared/utils/mailbox'
import { createUuid } from '@/shared/utils/uuid'
// ── 목록의 요약 ────────────────────────────────────────
/** "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함" */
export function conditionText(condition: MailFilterCondition): string {
return MAIL_FILTER_SUMMARY_TEXT.condition(
MAIL_FILTER_FIELD_LABELS[condition.field],
condition.value,
MAIL_FILTER_OPERATOR_LABELS[condition.operator],
)
}
/** 조건 전체 — 모두는 " 그리고 ", 하나라도는 " 또는 " 로 잇는다. 모든 메일이면 "모든 메일" */
export function conditionsText(rule: Pick<MailFilterRule, 'match' | 'conditions'>): string {
if (rule.match === 'always') return MAIL_FILTER_SUMMARY_TEXT.always
const joiner = rule.match === 'all' ? MAIL_FILTER_SUMMARY_TEXT.and : MAIL_FILTER_SUMMARY_TEXT.or
return rule.conditions.map(conditionText).join(joiner)
}
/**
* 할 일 — "옮기기: 거래처 · 읽음으로 표시 · 중요 표시 · 전달: doyun.lee@arasystem.it.kr" (그 순서).
* mailboxName 은 옮길 메일함의 이름 — 지워진 메일함이면 null ("없는 메일함")
*/
export function actionsText(actions: MailFilterActions, mailboxName: (id: string) => string | null): string {
const parts: string[] = []
if (actions.moveToMailboxId !== null) {
const name = mailboxName(actions.moveToMailboxId) ?? MAIL_FILTER_SUMMARY_TEXT.missingMailbox
parts.push(MAIL_FILTER_SUMMARY_TEXT.move(name))
}
if (actions.markAsRead) parts.push(MAIL_FILTER_SUMMARY_TEXT.markAsRead)
if (actions.flag) parts.push(MAIL_FILTER_SUMMARY_TEXT.flag)
if (actions.forwardTo) parts.push(MAIL_FILTER_SUMMARY_TEXT.forward(actions.forwardTo))
return parts.join(MAIL_FILTER_SUMMARY_TEXT.actionSeparator)
}
/** 목록에 보이는 이름 — 이름이 없으면 조건이 이름 자리에 온다 (길면 화면이 줄임표로 자른다) */
export function ruleTitle(rule: Pick<MailFilterRule, 'name' | 'match' | 'conditions'>): string {
return rule.name.trim() || conditionsText(rule)
}
/** 길면 자르고 "…" — 글자(코드 포인트) 단위로 센다 (이모지를 반쪽으로 자르지 않게) */
function truncate(text: string, max: number): string {
const chars = Array.from(text)
return chars.length > max ? `${chars.slice(0, max).join('')}…` : text
}
/**
* 알림 · 단추 이름에 넣는 이름 — 따옴표로 묶는다. 이름이 없어 조건 글(5개 × 200자까지)이 오면 길이를 잘라 "…"를 붙이고,
* 작은따옴표가 들었으면(조건 글은 찾을 글을 ‘…’ 로 묶는다) 큰따옴표로 묶어 따옴표가 겹치지 않게 한다
*/
export function quotedRuleTitle(rule: Pick<MailFilterRule, 'name' | 'match' | 'conditions'>): string {
const title = truncate(ruleTitle(rule), MAIL_FILTER_LABEL_MAX_LENGTH)
return /[‘’]/.test(title) ? `“${title}”` : `‘${title}’`
}
/** 이름 아래 요약 한 줄 — 이름이 있으면 "조건 → 할 일", 없으면(조건이 이미 이름 자리에 있다) "→ 할 일" */
export function ruleSummary(rule: MailFilterRule, mailboxName: (id: string) => string | null): string {
const actions = `${MAIL_FILTER_SUMMARY_TEXT.arrow}${actionsText(rule.actions, mailboxName)}`
return rule.name.trim() ? `${conditionsText(rule)} ${actions}` : actions
}
/** 옮길 메일함이 지워졌다 — 받은편지함으로 들어온다 (목록에 빨간 안내 · 고치기 창은 "(지워진 메일함)"이 골라진 채로) */
export function isMoveTargetMissing(rule: MailFilterRule, mailboxes: readonly Mailbox[]): boolean {
const target = rule.actions.moveToMailboxId
return target !== null && !mailboxes.some((mailbox) => mailbox.id === target)
}
// ── 옮길 메일함 ────────────────────────────────────────
/**
* 옮길 메일함 고르기의 묶음 차례 — 뿌리 메일함의 역할로 정한다(하위 메일함은 뿌리를 따라간다).
* 목록에 없는 역할(보낸편지함 · 임시보관함)은 그 아래 메일함까지 두지 않는다 — 서버가 받지 않는다(400 VAL_001)
*/
const MOVE_TARGET_GROUPS: Partial<Record<MailboxRole | 'mine', number>> = {
inbox: 0,
mine: 1,
archive: 2,
junk: 3,
trash: 4,
}
const MOVE_TARGET_GROUP_COUNT = 5
/**
* 옮길 메일함 고르기의 목록 — 받은편지함(과 그 아래) → 내 메일함 → 보관함(과 그 아래) → 스팸메일함 → 휴지통
* (같은 묶음 안은 메일함 패널 순서). 보관함은 서버가 받고, mailcow 에서 옮겨 온 계정에는 보관함이 있다
*/
export function filterMoveTargets(mailboxes: readonly Mailbox[]): Mailbox[] {
const groups: Mailbox[][] = Array.from({ length: MOVE_TARGET_GROUP_COUNT }, () => [])
let group: number | undefined
for (const { mailbox, depth } of orderMailboxes(mailboxes)) {
if (depth === 0) group = MOVE_TARGET_GROUPS[mailbox.role ?? 'mine']
if (group !== undefined) groups[group]?.push(mailbox)
}
return groups.flat()
}
/** 새 필터의 옮길 메일함 — 내 메일함이 있으면 그 첫째, 없으면 목록의 첫째(받은편지함). 고를 곳이 없으면 빈 값 */
export function defaultMoveTarget(targets: readonly Mailbox[]): string {
return (targets.find((mailbox) => mailbox.role === null) ?? targets[0])?.id ?? ''
}
/** 로그인한 직원 주소의 도메인 — 전달은 이 도메인의 주소로만 (소문자) */
export function mailDomainOf(email: string): string {
const at = email.lastIndexOf('@')
return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase()
}
// ── 고치기 창의 칸 ──────────────────────────────────────
/** 고치기 창이 다루는 값 — 저장할 때 규칙(filterRuleOf)으로 바꾼다 */
export interface MailFilterDraft {
name: string
match: MailFilterMatch
/** 모두 · 하나라도의 조건 — 모든 메일로 바꿔도 남겨 둔다(다시 바꾸면 그대로 돌아온다). 저장할 때 모든 메일이면 뺀다 */
conditions: MailFilterCondition[]
move: boolean
/** 옮길 메일함 id — 옮기기를 끈 동안에도 고른 것을 남겨 둔다 */
moveTarget: string
markAsRead: boolean
flag: boolean
forward: boolean
/** 끈 동안에도 쓴 것을 남겨 둔다 */
forwardTo: string
}
/** 고치기 창의 "할 일" 칸 */
export type MailFilterActionDraft = Pick<
MailFilterDraft,
'move' | 'moveTarget' | 'markAsRead' | 'flag' | 'forward' | 'forwardTo'
>
/** 할 일을 하나라도 골랐다 (옮기기 · 읽음 · 중요 · 전달) */
export function hasDraftAction(draft: MailFilterActionDraft): boolean {
return draft.move || draft.markAsRead || draft.flag || draft.forward
}
/** 빈 조건 한 줄 — 처음 줄은 보낸 사람, [조건 더하기]는 제목 (디자인 원본 SettingsFilter) */
export function emptyCondition(field: MailFilterField = 'from'): MailFilterCondition {
return { field, operator: 'contains', value: '' }
}
/**
* 새 필터 — 모두 맞음 · 빈 조건 한 줄 · 할 일 없음.
* from 이 있으면(읽기 화면 ⋯ 「이런 메일로 필터 만들기」) 조건이 "보낸 사람 · 포함 · 그 주소"다
*/
export function newFilterDraft(options: { moveTarget: string; from?: string | null }): MailFilterDraft {
return {
name: '',
match: 'all',
conditions: [{ ...emptyCondition('from'), value: options.from ?? '' }],
move: false,
moveTarget: options.moveTarget,
markAsRead: false,
flag: false,
forward: false,
forwardTo: '',
}
}
/** 있는 규칙을 고칠 때 — 모든 메일 규칙도 조건 칸 한 줄을 남겨 둔다(모두 · 하나라도로 바꾸면 보인다) */
export function filterDraftOf(rule: MailFilterRule, moveTarget: string): MailFilterDraft {
const conditions = rule.conditions.length > 0 ? rule.conditions.map((condition) => ({ ...condition })) : [emptyCondition()]
return {
name: rule.name,
match: rule.match,
conditions,
move: rule.actions.moveToMailboxId !== null,
moveTarget: rule.actions.moveToMailboxId ?? moveTarget,
markAsRead: rule.actions.markAsRead,
flag: rule.actions.flag,
forward: rule.actions.forwardTo !== null,
forwardTo: rule.actions.forwardTo ?? '',
}
}
/** 새 규칙의 id · 켜짐 — 새로 만든 필터는 켜진 채로 끝에 붙는다 */
export function newFilterRuleBase(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
return { id: createUuid(), isEnabled: true }
}
/**
* 고치기 창의 값 → 보낼 규칙. 이름 · 글 · 전달 주소는 앞뒤 공백을 뺀다(서버도 뺀 값으로 잰다).
* 모든 메일이면 조건을 비우고, 끈 할 일은 빈 값(null · false)이다
*/
export function filterRuleOf(draft: MailFilterDraft, base: Pick<MailFilterRule, 'id' | 'isEnabled'>): MailFilterRule {
return {
id: base.id,
isEnabled: base.isEnabled,
name: draft.name.trim(),
match: draft.match,
conditions:
draft.match === 'always'
? []
: draft.conditions.map((condition) => ({ ...condition, value: condition.value.trim() })),
actions: {
moveToMailboxId: draft.move && draft.moveTarget ? draft.moveTarget : null,
markAsRead: draft.markAsRead,
flag: draft.flag,
forwardTo: draft.forward ? draft.forwardTo.trim() : null,
},
}
}
// ── 검사 (서버와 같은 규칙 — 계약 2절 "메일 필터"의 "클라이언트") ─────────────
/** 조건의 문제 — 비었다 · `${` 가 들었다 · 길이 · 제어 문자 */
export type MailFilterConditionProblem = 'empty' | 'variable' | 'invalid'
/** 전달의 문제 — 비었다 · `${` 가 들었다 · 형식(서버의 IsEmail 이 받지 않는다) · 사내 주소가 아니다 · 내 주소다 */
export type MailFilterForwardProblem = 'empty' | 'variable' | 'invalid' | 'external' | 'self'
export interface MailFilterProblems {
/** 이름이 50자를 넘거나 제어 문자가 들었다 */
name: boolean
/** draft.conditions 와 같은 차례 — 문제가 없으면 null (모든 메일이면 모두 null) */
conditions: (MailFilterConditionProblem | null)[]
/** 할 일을 하나도 고르지 않았다 */
actions: boolean
forward: MailFilterForwardProblem | null
}
function conditionProblem(value: string): MailFilterConditionProblem | null {
const trimmed = value.trim()
if (trimmed === '') return 'empty'
if (trimmed.includes(MAIL_FILTER_FORBIDDEN_SEQUENCE)) return 'variable'
if (trimmed.length > MAIL_FILTER_VALUE_MAX_LENGTH || hasControlChar(trimmed)) return 'invalid'
return null
}
/**
* 전달 주소의 @ 앞 — RFC 5322 dot-atom: atext(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~) 덩어리를 점으로 잇는다
* (앞 · 뒤 · 연달은 점 없음). 서버(IsEmail)가 받는 것보다 좁다 — 화면이 통과시킨 주소는 서버도 받는다
*/
const DOT_ATOM_LOCAL_PART = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/
function forwardProblem(address: string, ownEmail: string): MailFilterForwardProblem | null {
const trimmed = address.trim()
if (trimmed === '') return 'empty'
if (trimmed.includes(MAIL_FILTER_FORBIDDEN_SEQUENCE)) return 'variable'
const at = trimmed.lastIndexOf('@')
const local = trimmed.slice(0, Math.max(at, 0))
const wellFormed =
at > 0 &&
trimmed.length <= MAIL_FILTER_FORWARD_MAX_LENGTH &&
local.length <= MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH &&
DOT_ATOM_LOCAL_PART.test(local) &&
isValidEmail(trimmed)
if (!wellFormed) return 'invalid'
const ownDomain = mailDomainOf(ownEmail)
if (ownDomain === '' || mailDomainOf(trimmed) !== ownDomain) return 'external'
return normalizeEmail(trimmed) === normalizeEmail(ownEmail) ? 'self' : null
}
/** [저장]을 누를 때 본다 — ownEmail 은 로그인한 직원 주소 (사내 도메인 · 자기 주소 판정) */
export function filterDraftProblems(draft: MailFilterDraft, ownEmail: string): MailFilterProblems {
const name = draft.name.trim()
return {
name: name.length > MAIL_FILTER_NAME_MAX_LENGTH || hasControlChar(name),
conditions: draft.conditions.map((condition) =>
draft.match === 'always' ? null : conditionProblem(condition.value),
),
actions: !hasDraftAction(draft),
forward: draft.forward ? forwardProblem(draft.forwardTo, ownEmail) : null,
}
}
export function hasFilterProblems(problems: MailFilterProblems): boolean {
return (
problems.name ||
problems.actions ||
problems.forward !== null ||
problems.conditions.some((problem) => problem !== null)
)
}
/** 문제가 없는 상태 — 칸을 고치면 그 칸의 문제만 내린다 */
export function noFilterProblems(conditionCount: number): MailFilterProblems {
return { name: false, conditions: Array.from({ length: conditionCount }, () => null), actions: false, forward: null }
}
// ── 목록 바꾸기 (켜기 · 순서 · 저장 · 지우기 · 되돌리기 — 모두 PUT 전체 교체) ─────────────
export function withRuleEnabled(rules: readonly MailFilterRule[], id: string, isEnabled: boolean): MailFilterRule[] {
return rules.map((rule) => (rule.id === id ? { ...rule, isEnabled } : rule))
}
/** 한 칸 위(-1) · 아래(1)로 — 끝이라 옮길 수 없으면 null */
export function withRuleMoved(rules: readonly MailFilterRule[], id: string, offset: -1 | 1): MailFilterRule[] | null {
const index = rules.findIndex((rule) => rule.id === id)
const target = index + offset
if (index < 0 || target < 0 || target >= rules.length) return null
const next = [...rules]
const [moved] = next.splice(index, 1)
if (!moved) return null
next.splice(target, 0, moved)
return next
}
/** 고친 규칙은 그 자리에, 새 규칙은 끝에 */
export function withRuleSaved(rules: readonly MailFilterRule[], rule: MailFilterRule): MailFilterRule[] {
return rules.some((existing) => existing.id === rule.id)
? rules.map((existing) => (existing.id === rule.id ? rule : existing))
: [...rules, rule]
}
/** 지운 규칙과 그 자리 — 되돌리기가 다시 넣을 곳을 앞 · 뒤 규칙으로 기억한다 (되돌리기 여럿이 어떤 차례로 와도 원래 차례에 가깝게) */
export interface MailFilterRestorePoint {
rule: MailFilterRule
/** 지운 때의 차례 — 앞 · 뒤 규칙이 모두 없을 때만 쓴다 */
index: number
/** 바로 앞 규칙 — 맨 위였으면 null */
afterId: string | null
/** 바로 뒤 규칙 — 맨 아래였으면 null */
beforeId: string | null
}
/** 지운 목록과 되돌릴 자리. 없는 id 면 null */
export function withoutRule(
rules: readonly MailFilterRule[],
id: string,
): { rules: MailFilterRule[]; point: MailFilterRestorePoint } | null {
const index = rules.findIndex((rule) => rule.id === id)
const removed = rules[index]
if (!removed) return null
return {
rules: rules.filter((rule) => rule.id !== id),
point: { rule: removed, index, afterId: rules[index - 1]?.id ?? null, beforeId: rules[index + 1]?.id ?? null },
}
}
/** 다시 넣을 차례 — 맨 위였으면 맨 위, 아니면 앞 규칙 바로 뒤 → (앞 규칙이 없으면) 뒤 규칙 바로 앞 → 지운 때의 차례(넘으면 끝) */
function restoreIndex(rules: readonly MailFilterRule[], point: MailFilterRestorePoint): number {
if (point.afterId === null) return 0
const after = rules.findIndex((rule) => rule.id === point.afterId)
if (after >= 0) return after + 1
const before = point.beforeId === null ? -1 : rules.findIndex((rule) => rule.id === point.beforeId)
if (before >= 0) return before
return Math.min(Math.max(point.index, 0), rules.length)
}
/**
* 되돌리기 — 지운 규칙을 지운 자리에 다시 넣는다(restoreIndex). 지운 뒤 바꾼 다른 규칙은 그대로 둔다.
* 이미 있으면(다른 되돌리기 · 다른 곳에서 되살렸다) null
*/
export function withRuleRestored(
rules: readonly MailFilterRule[],
point: MailFilterRestorePoint,
): MailFilterRule[] | null {
if (rules.some((existing) => existing.id === point.rule.id)) return null
const next = [...rules]
next.splice(restoreIndex(rules, point), 0, point.rule)
return next
}
+2 -2
View File
@@ -116,13 +116,13 @@ export function mailboxActions(mailbox: Mailbox): MailboxAction[] {
return actions
}
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름에 쓸 수 없다 (서버와 같은 범위) */
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름 · 메일 필터의 글에 쓸 수 없다 (서버와 같은 범위) */
const CONTROL_CHAR_RANGES: readonly (readonly [number, number])[] = [
[0x00, 0x1f],
[0x7f, 0x9f],
]
function hasControlChar(text: string): boolean {
export function hasControlChar(text: string): boolean {
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index)
if (CONTROL_CHAR_RANGES.some(([start, end]) => code >= start && code <= end)) return true
+33
View File
@@ -0,0 +1,33 @@
// 무작위 UUID v4 — 발송 중복 방지 키(idempotency-key.ts) · 메일 필터 규칙 id(mail-filter.ts)가 함께 쓴다
/** UUID 한 개의 바이트 수 */
const UUID_BYTES = 16
/** 7번째 바이트 상위 4비트 = 버전(0100: 무작위 v4) */
const VERSION_BYTE = 6
/** 9번째 바이트 상위 2비트 = 변형(10: RFC 9562) */
const VARIANT_BYTE = 8
/** 16진 32자리 → 8-4-4-4-12 */
const UUID_LAYOUT = /^(.{8})(.{4})(.{4})(.{4})(.{12})$/
const HEX_RADIX = 16
function toHex(byte: number): string {
return byte.toString(HEX_RADIX).padStart(2, '0')
}
/** crypto.getRandomValues 로 UUID v4 를 만든다 — randomUUID 를 쓸 수 없을 때만 쓴다 */
function randomUuidV4(): string {
const bytes = crypto.getRandomValues(new Uint8Array(UUID_BYTES))
bytes[VERSION_BYTE] = ((bytes[VERSION_BYTE] ?? 0) & 0x0f) | 0x40
bytes[VARIANT_BYTE] = ((bytes[VARIANT_BYTE] ?? 0) & 0x3f) | 0x80
return Array.from(bytes, toHex).join('').replace(UUID_LAYOUT, '$1-$2-$3-$4-$5')
}
/**
* 새 UUID v4.
* crypto.randomUUID 는 보안 컨텍스트(HTTPS·localhost)에서만 있다 — 사내망 IP 로 연 개발 서버처럼
* 그렇지 않은 곳에서도 화면이 깨지지 않게, 같은 형식(UUID v4)을 직접 만들어 쓴다.
*/
export function createUuid(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return randomUuidV4()
}
@@ -0,0 +1,407 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
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 { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailFilterStore } from './mail-filter.store'
// 메일 필터 스토어 — 모두 PUT 전체 교체 · 한 번에 하나 · 늦게 온 응답 · 409 · 켜기 · 순서의 되돌리기 (계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const OTHER = { email: 'minsu.park@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,
}
}
const A = rule('a')
const B = rule('b')
const C = rule('c')
const SET: MailFilterSet = { rules: [A, B, C], version: 'v1', isActive: true }
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꾸거나 다른 일을 해 본다 */
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
/** 서버 흉내 — PUT 을 받으면 version 을 올려 그대로 돌려준다 */
function saved(request: MockRequest, version = 'v2'): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version, isActive: true })
}
describe('mail-filter.store', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
async function loaded(handler: (request: MockRequest) => MockResult | Promise<MockResult>) {
requests = installMockApi((request) => (request.method === 'GET' ? ok(SET) : handler(request)))
const store = useMailFilterStore()
await store.load()
return store
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const ids = (rules: readonly MailFilterRule[] | undefined) => rules?.map((item) => item.id)
it('불러오기는 알리지 않는다 — 못 받으면 상태에 담고, 받으면 50개 여부를 안다', async () => {
requests = installMockApi(() => fail(503, 'MAIL_001'))
const store = useMailFilterStore()
await store.load()
expect(store.status).toBe('error')
expect(store.error?.code).toBe('MAIL_001')
expect(useToastStore().toasts).toEqual([])
const fifty = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
restoreApiAdapter()
requests = installMockApi(() => ok({ rules: fifty, version: 'v1', isActive: true }))
store.ensureLoaded()
await flushPromises()
expect(store.status).toBe('ready')
expect(store.isFull).toBe(true)
// 받아 둔 것이 있으면 다시 묻지 않는다 (읽기 화면 ⋯)
store.ensureLoaded()
expect(requests).toHaveLength(1)
})
it('불러오는 사이 로그아웃하고 다른 계정으로 들어오면, 늦게 온 응답을 새 사용자의 목록으로 쓰지 않는다', async () => {
const reply = deferred()
requests = installMockApi(() => reply.promise)
const store = useMailFilterStore()
const authStore = useAuthStore()
const loading = store.load()
authStore.clearAuth()
await nextTick()
authStore.startSession(OTHER)
await nextTick()
reply.finish(ok(SET))
await loading
expect(store.filterSet).toBeNull()
expect(store.status).toBe('idle')
})
it('저장하는 사이 세션이 바뀌면(같은 사람이 다시 들어와도) 결과를 쓰지 않고 보내지 않은 것으로 돌려준다', async () => {
const reply = deferred()
const store = await loaded(() => reply.promise)
const authStore = useAuthStore()
const saving = store.saveRule({ ...A, name: '고침' })
authStore.clearAuth()
await nextTick()
authStore.startSession(USER)
await nextTick()
reply.finish(ok({ ...SET, version: 'v2' }))
await expect(saving).resolves.toEqual({ ok: false, error: null })
expect(store.filterSet).toBeNull()
expect(store.saving).toBe(false)
})
it('받은 version 으로 전체 교체하고, 응답이 새 목록 · version 이다 — 다음 저장은 새 version 으로', async () => {
const store = await loaded((request) => saved(request, `v${puts().length + 1}`))
await store.saveRule({ ...B, name: '고친 이름' })
await store.saveRule(rule('d'))
expect(puts().map((payload) => [payload.version, ids(payload.rules)])).toEqual([
['v1', ['a', 'b', 'c']],
['v2', ['a', 'b', 'c', 'd']],
])
expect(puts()[0]?.rules[1]?.name).toBe('고친 이름')
expect(store.filterSet?.version).toBe('v3')
})
it('저장은 한 번에 하나 — 기다리는 동안 온 켜기 · 순서 · 저장은 보내지 않는다', async () => {
const reply = deferred()
const store = await loaded(() => reply.promise)
const first = store.setRuleEnabled('a', false)
expect(store.saving).toBe(true)
await expect(store.moveRule('b', -1)).resolves.toBe(false)
await expect(store.saveRule(rule('d'))).resolves.toEqual({ ok: false, error: null })
expect(puts()).toHaveLength(1)
reply.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
await expect(first).resolves.toBe(true)
expect(store.saving).toBe(false)
})
it('켜기 · 순서는 화면을 먼저 바꾸고, 실패하면 되돌린다 (오류 알림은 인터셉터)', async () => {
const reply = deferred()
const store = await loaded(() => reply.promise)
const moving = store.moveRule('c', -1)
expect(ids(store.filterSet?.rules)).toEqual(['a', 'c', 'b'])
reply.finish(fail(503, 'MAIL_001'))
await expect(moving).resolves.toBe(false)
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c'])
expect(store.filterSet?.version).toBe('v1')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('켜기가 실패해도 되돌린다 — 끝 칸의 순서 바꾸기는 보내지 않는다', async () => {
const store = await loaded(() => fail(400, 'MAIL_013'))
await expect(store.setRuleEnabled('b', false)).resolves.toBe(false)
expect(store.filterSet?.rules.map((item) => item.isEnabled)).toEqual([true, true, true])
// 엔진 한도는 목록에서는 알림으로 (창 안이 아니다)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_013])
await expect(store.moveRule('a', -1)).resolves.toBe(false)
expect(puts()).toHaveLength(1)
})
it('409 MAIL_012 — 알리지 않고 conflict 를 켜고 되돌린다. 다시 불러오면 내린다', async () => {
const store = await loaded(() => fail(409, 'MAIL_012'))
await expect(store.setRuleEnabled('a', false)).resolves.toBe(false)
expect(store.conflict).toBe(true)
expect(store.filterSet?.rules[0]?.isEnabled).toBe(true)
expect(useToastStore().toasts).toEqual([])
const outcome = await store.saveRule(rule('d'))
expect(outcome.ok ? null : outcome.error?.code).toBe('MAIL_012')
restoreApiAdapter()
installMockApi(() => ok({ rules: [B], version: 'v7', isActive: true }))
await expect(store.reload()).resolves.toBe(true)
expect(store.conflict).toBe(false)
expect(ids(store.filterSet?.rules)).toEqual(['b'])
})
it('고치기 창의 저장 거절(VAL_001 · MAIL_013)은 알리지 않는다 — 창 안에 보인다', async () => {
const store = await loaded(() => fail(400, 'VAL_001'))
const outcome = await store.saveRule(rule('d'))
expect(outcome.ok ? null : outcome.error?.code).toBe('VAL_001')
expect(useToastStore().toasts).toEqual([])
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c'])
})
it('지우면 지운 규칙과 자리를 돌려준다 — 되돌리기는 그 자리에 다시 넣고, 그사이 바뀐 다른 규칙은 그대로 둔다', async () => {
const store = await loaded((request) => saved(request, `v${puts().length + 1}`))
const removal = await store.removeRule('b')
expect(removal).toMatchObject({ ok: true, point: { index: 1, afterId: 'a', beforeId: 'c', rule: { id: 'b' } } })
await store.setRuleEnabled('c', false)
if (!removal.ok) throw new Error('지우지 못했다')
await store.restoreRule(removal.point)
expect(store.filterSet?.rules.map((item) => [item.id, item.isEnabled])).toEqual([
['a', true],
['b', true],
['c', false],
])
expect(puts().map((payload) => payload.version)).toEqual(['v1', 'v2', 'v3'])
// 이미 되돌렸으면 다시 보내지 않는다
await expect(store.restoreRule(removal.point)).resolves.toMatchObject({ ok: true })
expect(puts()).toHaveLength(3)
})
it('되돌리기는 다른 저장이 끝난 뒤에 보낸다 (알림의 단추는 한 번뿐이다)', async () => {
const reply = deferred()
let calls = 0
const store = await loaded((request) => {
calls += 1
return calls === 1 ? reply.promise : saved(request, 'v3')
})
const toggling = store.setRuleEnabled('a', false)
const restoring = store.restoreRule({ rule: rule('z'), index: 0, afterId: null, beforeId: 'a' })
await flushPromises()
expect(puts()).toHaveLength(1)
reply.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
await toggling
await expect(restoring).resolves.toMatchObject({ ok: true })
expect(puts()[1]).toEqual({ rules: [rule('z'), { ...A, isEnabled: false }, B, C], version: 'v2' })
})
it('[다시 켜기] — 지금 목록 그대로 저장하고, 응답의 isActive 를 쓴다', async () => {
requests = installMockApi((request) =>
request.method === 'GET' ? ok({ ...SET, isActive: false }) : saved(request),
)
const store = useMailFilterStore()
await store.load()
expect(store.filterSet?.isActive).toBe(false)
await expect(store.reactivate()).resolves.toMatchObject({ ok: true })
expect(puts()).toEqual([{ rules: [A, B, C], version: 'v1' }])
expect(store.filterSet?.isActive).toBe(true)
})
it('저장한 목록이 곧 보이는 목록이다 — 그 전에 시작한 불러오기가 늦게 와도 덮지 않는다', async () => {
const stale = deferred()
let gets = 0
requests = installMockApi((request) => {
if (request.method === 'PUT') return saved(request)
gets += 1
return gets === 1 ? ok(SET) : stale.promise
})
const store = useMailFilterStore()
await store.load()
const reloading = store.reload()
await store.saveRule(rule('d'))
stale.finish(ok({ rules: [], version: 'v0', isActive: true }))
await reloading
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c', 'd'])
expect(store.filterSet?.version).toBe('v2')
})
it('저장 하나를 기다리는 되돌리기 둘 — 누른 차례대로 둘 다 보내고 둘 다 성공한다 (앞 것이 끝난 뒤 다음 것)', async () => {
const first = deferred()
let calls = 0
const store = await loaded((request) => {
calls += 1
return calls === 1 ? first.promise : saved(request, `v${calls + 1}`)
})
const toggling = store.setRuleEnabled('a', false)
const restoreX = store.restoreRule({ rule: rule('x'), index: 1, afterId: 'a', beforeId: 'b' })
const restoreY = store.restoreRule({ rule: rule('y'), index: 3, afterId: 'b', beforeId: 'c' })
await flushPromises()
expect(puts()).toHaveLength(1)
first.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
await toggling
const [x, y] = await Promise.all([restoreX, restoreY])
expect(x.ok).toBe(true)
expect(y.ok).toBe(true)
expect(puts().map((payload) => [payload.version, ids(payload.rules)])).toEqual([
['v1', ['a', 'b', 'c']],
['v2', ['a', 'x', 'b', 'c']],
['v3', ['a', 'x', 'b', 'y', 'c']],
])
expect(ids(store.filterSet?.rules)).toEqual(['a', 'x', 'b', 'y', 'c'])
})
it('되돌리면 50개를 넘으면 보내지 않는다 (full)', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
requests = installMockApi((request) =>
request.method === 'GET' ? ok({ rules: fifty, version: 'v1', isActive: true }) : saved(request),
)
const store = useMailFilterStore()
await store.load()
await expect(store.restoreRule({ rule: rule('x'), index: 0, afterId: null, beforeId: 'r0' })).resolves.toEqual({
ok: false,
error: null,
full: true,
})
expect(puts()).toEqual([])
})
it('켜기가 실패해 되돌려진 뒤의 고치기 저장 — 켜짐은 창을 연 때가 아니라 저장하는 순간 목록의 값을 쓴다', async () => {
const reply = deferred()
let calls = 0
const store = await loaded((request) => {
calls += 1
return calls === 1 ? reply.promise : saved(request)
})
const toggling = store.setRuleEnabled('b', false)
// 저장하는 동안 창을 열었다 — 창이 받은 규칙은 꺼진 채다
const opened = store.filterSet?.rules[1]
expect(opened?.isEnabled).toBe(false)
reply.finish(fail(503, 'MAIL_001'))
await toggling
expect(store.filterSet?.rules[1]?.isEnabled).toBe(true)
await store.saveRule({ ...(opened as MailFilterRule), name: '고친 이름' })
expect(puts()[1]?.rules[1]).toMatchObject({ id: 'b', name: '고친 이름', isEnabled: true })
})
it('다시 불러온 뒤의 고치기 저장 — 다시 받은 목록 · version 위에 넣는다. 다른 곳에서 지워졌으면 맨 아래, 그때 50개를 넘으면 보내지 않는다', async () => {
let filters: MailFilterSet = SET
requests = installMockApi((request) => (request.method === 'GET' ? ok(filters) : saved(request, 'v9')))
const store = useMailFilterStore()
await store.load()
filters = { rules: [C, A], version: 'v5', isActive: true }
await store.reload()
await store.saveRule({ ...B, name: '다른 곳에서 지워진 규칙' })
expect(puts()[0]).toMatchObject({ version: 'v5' })
expect(ids(puts()[0]?.rules)).toEqual(['c', 'a', 'b'])
filters = { rules: Array.from({ length: 50 }, (_, index) => rule(`r${index}`)), version: 'v6', isActive: true }
await store.reload()
await expect(store.saveRule({ ...B, name: '또' })).resolves.toEqual({ ok: false, error: null, full: true })
// 있는 규칙을 고치는 것은 50개여도 된다
await expect(store.saveRule({ ...rule('r3'), name: '고침' })).resolves.toMatchObject({ ok: true })
expect(puts()).toHaveLength(2)
})
it('보낸 뒤 다시 불러왔으면 늦게 온 409 로 목록 위 줄(conflict)을 다시 켜지 않는다', async () => {
const reply = deferred()
let gets = 0
requests = installMockApi((request) => {
if (request.method === 'PUT') return reply.promise
gets += 1
return ok(gets === 1 ? SET : { ...SET, version: 'v8' })
})
const store = useMailFilterStore()
await store.load()
const toggling = store.setRuleEnabled('a', false)
await store.reload()
reply.finish(fail(409, 'MAIL_012'))
await toggling
expect(store.conflict).toBe(false)
expect(store.filterSet?.version).toBe('v8')
})
it('화면이 떠 있는 수를 센다 — 같은 내리기를 두 번 불러도 한 번만 뺀다', () => {
installMockApi(() => ok(SET))
const store = useMailFilterStore()
expect(store.pageVisible).toBe(false)
const detachFirst = store.attachPage()
const detachSecond = store.attachPage()
detachFirst()
detachFirst()
expect(store.pageVisible).toBe(true)
detachSecond()
expect(store.pageVisible).toBe(false)
})
})
+291
View File
@@ -0,0 +1,291 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useMail } from '@/composables/useMail'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_MAX_RULES } from '@/shared/constants/mail-filter'
import type { MailFilterRule, MailFilterSet } from '@/shared/types/mail'
import type { MailFilterRestorePoint } from '@/shared/utils/mail-filter'
import {
withoutRule,
withRuleEnabled,
withRuleMoved,
withRuleRestored,
withRuleSaved,
} from '@/shared/utils/mail-filter'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 메일 필터 스토어 — 계약 2절 "메일 필터" (사용자마다 규칙 목록 하나 — 엔진의 Sieve 스크립트에 부재중 응답과 함께 둔다)
// - 설정 › 필터는 들어올 때마다 새로 받는다(load) — 다른 탭 · PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
// 읽기 화면 ⋯ 는 50개를 다 만들었는지 알려고, 받아 둔 것이 없을 때(받아 둔 목록이 50개면 열 때마다) 받는다(ensureLoaded — 알리지 않는다)
// - 켜기 · 순서 · 지우기 · 고치기 · 되돌리기 · 다시 켜기 모두 PUT 전체 교체다 — 받은(마지막으로 저장한) version 으로 보내고, 응답이 새 목록 · version 이다.
// 그래서 저장은 한 번에 하나다(saving) — 저장하는 중에 온 켜기 · 순서 · 저장은 보내지 않는다(화면은 그동안 aria-disabled).
// 되돌리기만은 알림의 단추가 한 번뿐이라 누른 차례대로 줄을 세워(restoreQueue) 앞 저장이 끝난 뒤에 보낸다
// - 켜기 · 순서 바꾸기는 화면을 먼저 바꾸고 실패하면 되돌린다 (오류 알림은 인터셉터 — 디자인 README "6단계 › 상태")
// - 409 MAIL_012(그사이 다른 곳에서 바뀜)는 알리지 않고 conflict 를 켠다 — 화면이 목록 위 줄 + [다시 불러오기]로 알린다.
// 보낸 뒤 다시 불러왔으면(순번이 바뀜) 늦게 온 409 로 줄을 다시 켜지 않는다
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset · 저장이 올린다),
// 저장은 세션 세대(authStore.generation)로 가린다 (부재중 스토어와 같은 결)
/**
* 저장 결과 — 실패면 그 오류(코드로 가른다). error 가 null 이면 보내지 않았다(저장하는 중 · 받은 목록이 없음 · 세션이 바뀜).
* full 이면 50개를 넘어 보내지 않았다 — 되돌리기 · 그사이 다른 곳에서 지워진 규칙의 저장 (화면이 fullNote 로 알린다)
*/
export type MailFilterSaveOutcome =
| { ok: true; filterSet: MailFilterSet }
| { ok: false; error: ApiError | null; full?: boolean }
/** 지우기 결과 — 되돌리기가 지운 규칙을 지운 자리(point — 앞 · 뒤 규칙)에 다시 넣는다 */
export type MailFilterRemoveOutcome =
| { ok: true; point: MailFilterRestorePoint }
| { ok: false; error: ApiError | null }
const NOT_SENT = { ok: false, error: null } as const
const FULL = { ok: false, error: null, full: true } as const
/** 모든 저장에서 알림을 끄는 코드 — 그사이 다른 곳에서 바뀜(목록 위 줄이 알린다) */
const SAVE_SILENT: readonly ErrorCode[] = ['MAIL_012']
/** 고치기 창의 [저장]이 칸 안에 알리는 거절 — 형식(VAL_001) · 엔진 한도(MAIL_013) */
const DIALOG_SILENT: readonly ErrorCode[] = ['VAL_001', 'MAIL_013']
export const useMailFilterStore = defineStore('mail-filter', () => {
const mailApi = useMail()
const authStore = useAuthStore()
/** 보이는 목록 — 켜기 · 순서는 저장이 끝나기 전에 먼저 바뀐다. 늘 통째로 바꾼다(shallowRef) */
const filterSet = shallowRef<MailFilterSet | null>(null)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
/** 409 MAIL_012 — 다른 곳에서 바뀌어 저장하지 못했다 (목록 위 줄 + [다시 불러오기]) */
const conflict = ref<boolean>(false)
/** PUT 을 기다리는 중 — 한 번에 하나 */
const saving = ref<boolean>(false)
/** 50개를 다 만들었다 — [새 필터] · 「이런 메일로 필터 만들기」를 끈다 */
const isFull = computed<boolean>(() => (filterSet.value?.rules.length ?? 0) >= MAIL_FILTER_MAX_RULES)
/**
* 설정 › 필터 화면이 떠 있는 수 (attachPage) — 화면 밖에서 [되돌리기]가 그사이 바뀜(409)으로 실패하면
* 목록 위 줄을 볼 곳이 없으니 알림으로 알린다 (useMailFilterActions)
*/
const pageCount = ref<number>(0)
const pageVisible = computed<boolean>(() => pageCount.value > 0)
let seq = 0
/** 기다리는 저장 — 되돌리기가 끝나기를 기다린다 (실패해도 거부하지 않는 약속) */
let pending: Promise<MailFilterSaveOutcome> | null = null
/** 되돌리기 차례 — 알림 여럿의 [되돌리기]를 누른 차례대로 하나씩 보낸다 (거부하지 않는 약속) */
let restoreQueue: Promise<unknown> = Promise.resolve()
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 화면이 사전 문구와 "다시 시도"를 그린다) */
async function load(): Promise<MailFilterSet | null> {
seq += 1
const current = seq
status.value = 'loading'
error.value = null
try {
const result = await mailApi.getFilters({ silent: true })
if (current !== seq) return filterSet.value
filterSet.value = result
status.value = 'ready'
conflict.value = false
return result
} catch (caught) {
if (current !== seq) return filterSet.value
status.value = 'error'
error.value = toApiError(caught)
return null
}
}
/**
* 읽기 화면 ⋯ — 50개를 다 만들었는지. 아직 받지 않았거나 못 받았으면 받고, 받아 둔 목록이 50개면 다시 받아 본다
* (다른 곳에서 지웠으면 「이런 메일로 필터 만들기」가 다시 켜지게 — 알리지 않는다)
*/
function ensureLoaded(): void {
if (status.value === 'idle' || status.value === 'error') void load()
else if (status.value === 'ready' && isFull.value) void reload({ silent: true })
}
/**
* [다시 불러오기] — 보이는 목록은 둔 채 새로 받는다. 못 받으면 인터셉터가 알린다(목록은 그대로).
* silent — 알리지 않고 받아 본다 (읽기 화면 ⋯ 의 다시 보기)
*/
async function reload(options: { silent?: boolean } = {}): Promise<boolean> {
seq += 1
const current = seq
try {
const result = await mailApi.getFilters(options.silent ? { silent: true } : undefined)
if (current !== seq) return false
filterSet.value = result
status.value = 'ready'
error.value = null
conflict.value = false
return true
} catch {
return false
}
}
/** 목록 전체를 보낸다 — 저장하는 중이거나 받은 목록이 없으면 보내지 않는다 */
function send(rules: MailFilterRule[], silent: readonly ErrorCode[]): Promise<MailFilterSaveOutcome> {
const current = filterSet.value
if (saving.value || !current) return Promise.resolve(NOT_SENT)
const session = authStore.generation
const sentSeq = seq
saving.value = true
const request: Promise<MailFilterSaveOutcome> = mailApi
.saveFilters({ rules, version: current.version }, { silent: [...SAVE_SILENT, ...silent] })
.then(
(result): MailFilterSaveOutcome => {
if (authStore.generation !== session) return NOT_SENT
// 그 전에 시작한 불러오기가 늦게 와도 저장한 목록을 덮지 않는다
seq += 1
filterSet.value = result
status.value = 'ready'
error.value = null
conflict.value = false
return { ok: true, filterSet: result }
},
(caught: unknown): MailFilterSaveOutcome => {
if (authStore.generation !== session) return NOT_SENT
const apiError = toApiError(caught)
// 그사이 다시 불러왔으면(순번이 바뀜) 늦게 온 409 로 목록 위 줄을 다시 켜지 않는다
if (apiError.code === 'MAIL_012' && seq === sentSeq) conflict.value = true
return { ok: false, error: apiError }
},
)
.finally(() => {
if (authStore.generation === session) saving.value = false
if (pending === request) pending = null
})
pending = request
return request
}
/** 화면을 먼저 바꾸고 보낸다 — 실패하면(그사이 목록을 다른 것이 바꾸지 않았으면) 되돌린다 */
async function sendOptimistic(build: (rules: readonly MailFilterRule[]) => MailFilterRule[] | null): Promise<boolean> {
const before = filterSet.value
if (saving.value || !before) return false
const rules = build(before.rules)
if (!rules) return false
const optimistic: MailFilterSet = { ...before, rules }
filterSet.value = optimistic
const outcome = await send(rules, [])
if (!outcome.ok && filterSet.value === optimistic) filterSet.value = before
return outcome.ok
}
/** 켜기 · 끄기 */
function setRuleEnabled(id: string, isEnabled: boolean): Promise<boolean> {
return sendOptimistic((rules) => withRuleEnabled(rules, id, isEnabled))
}
/** 순서 바꾸기 — 한 칸 위(-1) · 아래(1). 끝이면 아무것도 하지 않는다 */
function moveRule(id: string, offset: -1 | 1): Promise<boolean> {
return sendOptimistic((rules) => withRuleMoved(rules, id, offset))
}
/**
* 고치기 창의 [저장] — 지금 목록(다시 불러왔으면 그 목록 · version) 위에 넣는다. 칸 안에 알릴 거절은 알림을 끈다.
* 있는 규칙은 같은 id 자리에서 바꾸고, 켜짐은 저장하는 순간 목록의 값을 쓴다 — 창은 켜기를 고치지 않는다
* (창을 연 뒤 켜기가 실패해 되돌려졌으면 그 값이 맞다). 새 규칙 · 그사이 다른 곳에서 지워진 규칙은 맨 아래 — 50개를 넘으면 보내지 않는다(full)
*/
function saveRule(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
const current = filterSet.value
if (!current) return Promise.resolve(NOT_SENT)
const existing = current.rules.find((item) => item.id === rule.id)
if (existing) return send(withRuleSaved(current.rules, { ...rule, isEnabled: existing.isEnabled }), DIALOG_SILENT)
if (current.rules.length >= MAIL_FILTER_MAX_RULES) return Promise.resolve(FULL)
return send(withRuleSaved(current.rules, rule), DIALOG_SILENT)
}
/** 지우기 — 확인 없이 한다 (화면이 알림의 [되돌리기]를 준다) */
async function removeRule(id: string): Promise<MailFilterRemoveOutcome> {
const current = filterSet.value
const removal = current ? withoutRule(current.rules, id) : null
if (!removal) return NOT_SENT
const outcome = await send(removal.rules, [])
return outcome.ok ? { ok: true, point: removal.point } : outcome
}
/**
* 되돌리기 — 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 넣어 저장한다(지운 뒤 바꾼 다른 규칙은 그대로). 이미 있으면 그대로 성공이다.
* 누른 차례대로 하나씩 보내고(restoreQueue), 다른 저장이 돌고 있으면 끝난 뒤에 보낸다. 그사이 세션이 바뀌었으면 보내지 않고,
* 다시 넣으면 50개를 넘을 때도 보내지 않는다(full)
*/
function restoreRule(point: MailFilterRestorePoint): Promise<MailFilterSaveOutcome> {
const session = authStore.generation
const run = restoreQueue.then(() => restoreNow(point, session))
restoreQueue = run.catch(() => undefined)
return run
}
async function restoreNow(point: MailFilterRestorePoint, session: number): Promise<MailFilterSaveOutcome> {
// 앞 저장이 끝날 때까지 — 기다림을 마친 뒤 보내기(send)까지는 await 가 없어야 그사이 다른 저장이 끼어들지 않는다
while (pending) await pending
const current = filterSet.value
if (!current || authStore.generation !== session) return NOT_SENT
const rules = withRuleRestored(current.rules, point)
if (!rules) return { ok: true, filterSet: current }
if (current.rules.length >= MAIL_FILTER_MAX_RULES) return FULL
return send(rules, [])
}
/** [다시 켜기] (isActive: false) — 지금 목록 그대로 저장한다. 저장하면 엔진에서 다시 켜진다 */
function reactivate(): Promise<MailFilterSaveOutcome> {
const current = filterSet.value
if (!current) return Promise.resolve(NOT_SENT)
return send([...current.rules], [])
}
/** 설정 › 필터 화면이 떴다 — 돌려받은 함수로 내린다 (두 번 불러도 한 번만 센다) */
function attachPage(): () => void {
pageCount.value += 1
let attached = true
return () => {
if (!attached) return
attached = false
pageCount.value -= 1
}
}
function reset() {
seq += 1
pending = null
restoreQueue = Promise.resolve()
filterSet.value = null
status.value = 'idle'
error.value = null
conflict.value = false
saving.value = false
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 필터가 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return {
filterSet,
status,
error,
conflict,
saving,
isFull,
pageVisible,
attachPage,
load,
ensureLoaded,
reload,
setRuleEnabled,
moveRule,
saveRule,
removeRule,
restoreRule,
reactivate,
reset,
}
})
+11
View File
@@ -80,6 +80,7 @@
--color-danger-border: #fbd7d3;
--color-warning-surface: #fff7e6;
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
--color-warning-hover: #fdebc8; /* 노란 줄 안의 단추에 마우스를 올렸을 때 — 메일 필터 [다시 불러오기] · [다시 켜기] (디자인 원본 MailFilterStates) */
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
--color-star: #b7791f;
@@ -126,6 +127,7 @@
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
--shadow-segment: 0 0.0625rem 0.125rem rgba(16, 24, 28, 0.14); /* 전환 단추의 고른 칸 (디자인 원본 Settings) */
--shadow-switch-knob: 0 0.0625rem 0.125rem rgba(16, 24, 40, 0.24); /* 켜기 스위치의 동그라미 (디자인 원본 SettingsFilter) */
--color-backdrop: rgba(11, 54, 64, 0.32);
/* ── 크기 ───────────────────────────────── */
@@ -160,6 +162,15 @@
* 목록 위 권하기 줄 높이 40(--size-notice-bar — 새 메일 알림 줄과 같다) · 줄 안의 [알림 켜기] 높이 36 · [✕ 닫기] 36×36
*/
--size-notice-action: 2.25rem; /* 36px — 알림 권하기 줄 안의 단추 · 밀도 예외 */
/*
* 6단계(메일 필터) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "6단계 — 메일 필터", 사용자 확정 캔버스).
* 목록 행의 켜기 스위치 누르는 곳 40×44(--size-icon-button × --size-touch — 그림은 36×20) ·
* 목록 행의 ▲ ▼ 32×40(--size-mailbox-more × --size-row-min — 메일함 ⋯ 와 같다) ·
* 목록 위 노란 줄 · 고치기 창 안 [다시 불러오기] 높이 36(--size-notice-action) ·
* 고치기 창의 조건 줄(어디에서 · 포함 · 찾을 글) · 옮길 메일함 · 전달 주소 칸과 [조건 더하기] 높이 40 · 조건 ✕ 36×40
*/
--size-filter-control: 2.5rem; /* 40px — 고치기 창의 조건 줄 · 옮길 메일함 · 전달 주소 칸 · [조건 더하기] 높이 · 밀도 예외 */
--size-filter-remove: 2.25rem; /* 36px — 조건 ✕ 폭 (높이는 --size-filter-control) · 밀도 예외 */
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
+6
View File
@@ -54,6 +54,12 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/filters',
name: 'settings-mail-filters',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/settings/mail/notify',
name: 'settings-mail-notify',