feat(frontend): 실시간 새 메일 · 바탕화면 알림 · 설정 › 알림

- 로그인한 동안 앱 전체에서 실시간 연결 — 브라우저마다 하나(Web Locks 잠금 + BroadcastChannel 로 다른 탭에 나눔), fetch 로 읽기,
  401 은 세션 갱신 한 번, 자리 잡은 뒤에만 다시 잇기 간격을 처음부터(열리자마자 끝나는 연결에서 갱신 폭주 막기), 탭을 닫으면 이어받기
- 메일 화면: 연결이 살아 있는 동안 1분 확인을 멈추고, 바뀌면 1초 모아 맞추기 · 숨은 탭은 탭 제목 (N) 만
- 바탕화면 알림: 웹메일을 보고 있지 않을 때만(보이고 초점이 있는 탭 없음), 한 통 · 여러 통 · 제목 숨김 문구, 누르면 그 메일로, 돌아오면 닫기
- 설정 › 메일 › 알림(허락 상태 · 제목 숨김 · 시험 알림 · 연결 상태)과 목록 위 한 번 권하기 줄, 알림 아이콘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 13:32:09 +09:00
parent f725908053
commit 50165bd3b7
52 changed files with 5234 additions and 38 deletions
+5 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
import AppShell from '@/components/shell/AppShell.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
@@ -24,13 +25,15 @@ void useMailSignatureStore().ensureSignature()
// 메일함 패널 아래의 부재중 안내 — 못 받으면 알리지 않고 그리지 않는다 (지난 값은 둔다)
void useMailVacationStore().load()
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다).
// 숨은 탭은 받은편지함의 안 읽은 수만 고쳐 탭 제목 "(N) " 을 맞추고, 다시 보일 때 맞춘다
useMailStatePolling({
clock: useClock(),
onChange: () => {
mailStore.syncCounts()
void mailStore.syncVisibleList()
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
useMailTabTitle()
</script>
@@ -0,0 +1,247 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
import { FakeNotification } from '@/test-utils/notification'
import { createTestRouter } from '@/test-utils/router'
import { installFakeLocks, uninstallFakeLocks } from '@/test-utils/web-locks'
import MailNotifyPage from './MailNotifyPage.vue'
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때" · README "5단계")
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
window.localStorage.clear()
installFakeLocks()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('Notification', FakeNotification)
FakeNotification.reset('default')
useAuthStore().markAuthenticated(USER)
})
afterEach(() => {
uninstallFakeLocks()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function mountPage() {
const router = createTestRouter()
await router.push('/settings/mail/notify')
await router.isReady()
const wrapper = mount(MailNotifyPage, { global: { plugins: [pinia, router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
function enableInput(wrapper: Awaited<ReturnType<typeof mountPage>>) {
return wrapper.get('#notify-enabled').element as HTMLInputElement
}
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('알림')
expect(wrapper.get('main').attributes('aria-labelledby')).toBe('notify-page-title')
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
expect(enableInput(wrapper).disabled).toBe(false)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
})
it('켜기 → 브라우저가 묻고 허용 → 체크 · 초록 "허용되어 있습니다" · 시험 알림은 지금 탭에서 바로', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'granted'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
expect(enableInput(wrapper).checked).toBe(true)
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
expect(note.classes()).toContain('notify-form__note--success')
expect(wrapper.get('fieldset').attributes('disabled')).toBeUndefined()
expect(useMailNotifyStore().settings.enabled).toBe(true)
await wrapper.get('#notify-test').trigger('click')
expect(FakeNotification.shown.map((shown) => [shown.title, shown.options.body])).toEqual([
['ARA 웹메일', '알림이 잘 옵니다. 새 메일이 오면 이렇게 알려 드립니다.'],
])
})
it('켜기 → 막음 → 칸을 끄고(누를 수 없다) 빨간 안내로 푸는 방법 — 초점은 그 안내 글로', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'denied'
enableInput(wrapper).focus()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(false)
expect(enableInput(wrapper).disabled).toBe(true)
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe(
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
)
expect(note.classes()).toContain('notify-form__note--danger')
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(document.activeElement).toBe(note.element)
})
it('허락을 묻는 동안 칸을 끄지 않고 aria-disabled 로만 막는다 — 초점이 남고, 다시 눌러도 다시 묻지 않는다', async () => {
const wrapper = await mountPage()
const answer = FakeNotification.holdAnswer()
enableInput(wrapper).focus()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).disabled).toBe(false)
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(enableInput(wrapper))
// 묻는 창이 떠 있는 동안 다시 누른다 — 되돌리고 묻지 않는다
await wrapper.get('#notify-enabled').setValue(false)
await flushPromises()
expect(FakeNotification.requestCount).toBe(1)
answer('granted')
await flushPromises()
expect(enableInput(wrapper).checked).toBe(true)
expect(enableInput(wrapper).getAttribute('aria-disabled')).toBeNull()
expect(useMailNotifyStore().settings.enabled).toBe(true)
expect(document.activeElement).toBe(enableInput(wrapper))
})
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
FakeNotification.constructorThrows = true
await wrapper.get('#notify-test').trigger('click')
await flushPromises()
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(document.activeElement).toBe(note.element)
})
it('켜기 → 그냥 닫음 → 칸은 비운 채 그대로 (다시 누르면 또 묻는다)', async () => {
const wrapper = await mountPage()
FakeNotification.nextAnswer = 'default'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(false)
expect(wrapper.get('#notify-enabled-note').attributes('data-note')).toBe('ask')
FakeNotification.nextAnswer = 'granted'
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(FakeNotification.requestCount).toBe(2)
expect(enableInput(wrapper).checked).toBe(true)
})
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
expect(enableInput(wrapper).checked).toBe(true)
await wrapper.get('#notify-enabled').setValue(false)
await flushPromises()
expect(useMailNotifyStore().settings.enabled).toBe(false)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
})
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
vi.stubGlobal('Notification', undefined)
const wrapper = await mountPage()
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
)
})
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
await flushPromises()
const subject = wrapper.get('#notify-show-subject')
expect((subject.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.')
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
})
it('실시간 연결 — 연결됨(초록 점) · 끊김 "연결할 수 없어 1분마다 확인합니다." · 잇는 중', async () => {
const realtime = useMailRealtimeStore()
realtime.setStatus('live')
const wrapper = await mountPage()
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
realtime.setStatus('down')
await flushPromises()
expect(status().text()).toBe('연결할 수 없어 1분마다 확인합니다.')
expect(status().find('.realtime-link__dot').classes()).not.toContain('is-live')
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
realtime.setStatus('connecting')
await flushPromises()
expect(status().text()).toBe('연결하는 중입니다…')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
})
it('아래 안내 상자 — 이 브라우저에만 · 탭이 하나라도 열려 있어야 · 보고 있을 때는 보내지 않음', async () => {
const wrapper = await mountPage()
const notes = wrapper.get('[data-notify-notes]')
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
})
@@ -0,0 +1,92 @@
<script setup lang="ts">
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
import { onBeforeUnmount, onMounted } from 'vue'
import NotifySettingsForm from '@/components/settings/NotifySettingsForm.vue'
import RealtimeLinkStatus from '@/components/settings/RealtimeLinkStatus.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
const notifyStore = useMailNotifyStore()
function refreshPermission() {
if (document.visibilityState === 'visible') notifyStore.refreshPermission()
}
onMounted(() => {
notifyStore.refreshPermission()
window.addEventListener('focus', refreshPermission)
document.addEventListener('visibilitychange', refreshPermission)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', refreshPermission)
document.removeEventListener('visibilitychange', refreshPermission)
})
</script>
<template>
<SettingsPageFrame
title-id="notify-page-title"
:title="MAIL_NOTIFY_TEXT.title"
:description="MAIL_NOTIFY_TEXT.description"
>
<NotifySettingsForm />
<hr class="notify-page__rule">
<RealtimeLinkStatus />
<div
class="notify-page__notes"
data-notify-notes
>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteBrowserBefore }}<strong class="notify-page__strong">{{
MAIL_NOTIFY_TEXT.noteBrowserStrong
}}</strong>{{ MAIL_NOTIFY_TEXT.noteBrowserAfter }}</span>
</p>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteOpenTab }}</span>
</p>
<p class="notify-page__note">
<span aria-hidden="true">·</span>
<span>{{ MAIL_NOTIFY_TEXT.noteVisible }}</span>
</p>
</div>
</SettingsPageFrame>
</template>
<style scoped>
.notify-page__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
.notify-page__notes {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: var(--note-max-width);
padding: var(--space-14) var(--space-16);
border-radius: var(--radius-lg);
background: var(--color-brand-tint);
color: var(--color-text-strong);
}
.notify-page__note {
display: flex;
gap: var(--space-8);
margin: 0;
}
.notify-page__strong {
font-weight: 600;
}
</style>
@@ -88,10 +88,13 @@ reload()
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
}
/* 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다 — 다른 설정 화면(SettingsPageFrame)과 같다. 서명 칸 · 미리 보기는 보내는 모양 그대로 둔다 */
.signature-page__intro {
display: flex;
flex-direction: column;
gap: var(--space-4);
word-break: keep-all;
overflow-wrap: anywhere;
}
.signature-page__title {
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
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/notify'],
],
])
})
@@ -66,5 +67,6 @@ describe('SettingsLayout', () => {
expect(router.currentRoute.value.fullPath).toBe('/settings/account/password')
expect(router.currentRoute.value.meta.title).toBe('비밀번호')
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 (디자인 README "4단계 › 설정 메뉴", 목록은 settings-menu.ts).
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'