feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기

- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만
- [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두),
  같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름"
- 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시
- 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님
- 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창
- 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나
- 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다
- 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:37:55 +09:00
parent 1ca210dec4
commit d02a983d57
89 changed files with 7105 additions and 371 deletions
+8
View File
@@ -11,10 +11,13 @@
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
// 메일 화면에 파일을 잘못 놓아도 아무 일 없게 한다(15단계 — 브라우저가 파일을 열어 웹메일을 떠나지 않게). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다
// (useIgnoredWindowFileDrop — 받는 화면이 떠 있으면 그쪽에 맡긴다)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import MailDragChip from '@/components/mail/MailDragChip.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
@@ -24,6 +27,7 @@ import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
import { useMailBlockStore } from '@/stores/mail-block.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -68,6 +72,8 @@ useMailStatePolling({
useMailTabTitle()
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
usePendingSendRefresh({ clock })
// 목록 · 읽기 화면에 놓은 파일은 받지 않는다 (쓰기 화면이 떠 있으면 그 화면이 첨부로 받는다)
useIgnoredWindowFileDrop()
</script>
<template>
@@ -82,4 +88,6 @@ usePendingSendRefresh({ clock })
<RouterView />
</AppShell>
<MailContactDialogHost />
<!-- 목록 줄을 메일함 패널로 끄는 동안 마우스를 따라오는 표 (15단계 — 화면 밖에 그려 둔다) -->
<MailDragChip />
</template>
@@ -17,6 +17,7 @@ import type {
MessageDetail,
} from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import {
@@ -953,7 +954,8 @@ describe('MailFilterPage', () => {
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
// 차단 창의 새 칸과 같은 글 (15단계에 "다른 필터로 …"에서 바꿨다)
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
await click(wrapper.get('#mail-filter-cancel'))
// 다른 화면으로 가도 이어 한다
@@ -969,6 +971,21 @@ describe('MailFilterPage', () => {
expect(applyBox(again).attributes('disabled')).toBeUndefined()
})
it('이 탭에서 차단할 때 받은 메일을 옮기는 동안(15단계)도 칸을 끄고 같은 까닭 — 끝나면 다시 고를 수 있다', async () => {
const { wrapper } = await mountPage()
const blockRun = useMailBlockRunStore()
blockRun.running = true
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
blockRun.running = false
await flushPromises()
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyHelp(wrapper)).toBe(MAIL_FILTER_APPLY_TEXT.help)
})
it('「적용」을 고르고 저장하는 중에 창을 닫거나 화면을 떠나도 — 저장이 끝나면 적용한다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise, run: () => step(8412, 37, 0, null) })
@@ -13,7 +13,8 @@
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
// 이 탭에서 받은편지함을 정리하는 중이면(필터 적용 · 차단할 때 받은 메일 옮기기 — 15단계) 창의 그 칸을 끈다
// (mail-block-run.store 의 inboxBusy — 차단 창의 새 칸과 같은 규칙, 화면을 떠나도 정리는 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -32,12 +33,13 @@ import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mai
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const filterRunStore = useMailFilterRunStore()
/** 이 탭에서 받은편지함을 정리하는 중인지 — 필터 적용 · 차단 정리 (inboxBusy) */
const inboxCleanup = useMailBlockRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
@@ -282,7 +284,7 @@ watch(
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
:apply-busy="filterRunStore.running"
:apply-busy="inboxCleanup.inboxBusy"
@save="(rule, apply) => settleDialog(saveDialog(rule, { apply }))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"