chore: 앱 서버 브랜치(14단계 · 문구 다듬기 · main 을 합친 판)를 앱 첫 판 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m39s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m20s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m12s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m15s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m53s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m13s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m56s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m48s

이 PR 이 앱 서버 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
계약 1절의 앱 절은 앱 서버 쪽이 들고 있고 이 브랜치는 2절 줄만 더했다 — 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-11-08 12:39:17 +09:00
222 changed files with 11922 additions and 925 deletions
@@ -18,7 +18,7 @@ interface Props {
tone?: 'neutral' | 'danger'
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
traceId?: string | null
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "고른 메일이 없습니다") */
fill?: boolean
}
@@ -2,6 +2,7 @@
// "같은 대화" 칸에서 펼친 줄의 안 (14단계 — 디자인 원본 Thread14 "하나 펼침")
// 받는 사람 · 제목 한 줄씩, 본문(MailBodyFrame — 검색 쿼리 없이 받은 본문이라 강조가 없다), [답장] · [이 메일만 열기](36 — 밀도 예외 --size-notice-action 재사용).
// - 본문이 다 보이지 않으면(서버의 isHtmlBodyOmitted · isBodyTruncated) 읽기 화면과 같은 안내 줄(MAIL_BODY_NOTICES)을 본문 위에 — 읽기 화면도 같은 본문이다.
// 앞 문장만 보이고 원문 내려받기(할 일)는 붙이지 않는다 — 이 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 [이 메일만 열기]로 연 읽기 화면에서 받는다.
// 외부 이미지 안내 · 첨부 목록은 두지 않는다 — [이 메일만 열기]로 연 읽기 화면에 있다. 첨부가 있으면 본문 아래 · [답장] 위에
// "첨부 파일 N개는 [이 메일만 열기]를 누르면 볼 수 있습니다." (수는 읽기 화면의 "첨부 파일 N개"와 같다 — 본문 그림 빼고 · 대용량 첨부 더해)
// - 본문을 받는 동안은 뼈대(aria-busy), 못 받으면 사전 문구 + [다시 시도]. 그사이 지워진 메일(RES_001)이면 [다시 시도] · [답장] · [이 메일만 열기] 없이 —
@@ -49,7 +50,7 @@ const ready = computed(() => (props.detail?.status === 'ready' ? props.detail.de
const failure = computed(() => (props.detail?.status === 'error' ? props.detail.error : null))
/** 그사이 지워진 메일 — 다시 해도 같고 그 메일로 갈 곳도 없다 */
const missing = computed(() => failure.value?.code === 'RES_001')
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value) : []))
const bodyNotices = computed(() => (ready.value ? mailBodyNotices(ready.value, { withAction: false }) : []))
/** 읽기 화면에서 볼 수 있는 첨부 수 — 본문 그림(isInline)은 빼고 대용량 첨부는 더한다 (MessageReader 의 첨부 칸과 같은 수) */
const attachmentCount = computed(() =>
ready.value ? listedAttachments(ready.value.attachments).length + (ready.value.largeFiles?.length ?? 0) : 0,
@@ -4,11 +4,14 @@
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
@@ -40,7 +43,7 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
<template>
<li
class="conversation-row"
:class="['conversation-row', { 'is-unread': row.unread }]"
:data-conversation-row="row.message.id"
>
<div
@@ -52,7 +55,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
@@ -78,7 +86,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
@@ -200,8 +213,21 @@ button.conversation-row__line:focus-visible {
font-variant-numeric: tabular-nums;
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) */
.conversation-row__time.is-pending {
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
.conversation-row.is-unread .conversation-row__who {
font-weight: 650;
color: var(--color-text);
}
.conversation-row.is-unread .conversation-row__preview,
.conversation-row.is-unread .conversation-row__time {
font-weight: 600;
color: var(--color-text);
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
.conversation-row__time.is-pending,
.conversation-row.is-unread .conversation-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
@@ -37,7 +37,10 @@ const segments = computed(() => highlightSegments(props.text, props.ranges))
</template>
<style scoped>
/* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14) */
/*
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
*/
.search-mark {
padding: 0 var(--space-1);
border-radius: calc(var(--radius-xs) / 2);
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -239,7 +240,9 @@ describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)
expect(wrapper.find('[data-retention-notice]').exists()).toBe(false)
expect(wrapper.text()).toContain('휴지통이 비어 있습니다')
expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.')
expect(wrapper.text()).toContain(MAILBOX_EMPTY_HINTS.trash)
// 줄 대신 이 글이 30일을 알린다
expect(MAILBOX_EMPTY_HINTS.trash).toContain('30일')
})
it('다른 메일함 · 검색 결과에는 없다', async () => {
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { SEARCH_CONDITIONS_TEXT } from '@/shared/constants/mail-search'
import type { Mailbox } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -245,9 +246,7 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
'최근 1년',
'날짜 고르기',
])
expect(panel.text()).toContain(
'검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
)
expect(panel.text()).toContain(SEARCH_CONDITIONS_TEXT.hint)
// 칸 안내 — 내용 칸은 칸 이름과 같은 말로 (2026-11-06 실사용 시험)
expect(['from', 'to', 'subject', 'body'].map((name) => field(wrapper, name).attributes('placeholder'))).toEqual([
'이름이나 메일 주소',
@@ -465,22 +464,109 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
expect(push).not.toHaveBeenCalled()
})
it('패널의 [조건 지우기] — 걸린 조건을 빼고 다시 찾는다. 검색어도 없으면 검색을 닫는다', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
const textValues = (wrapper: VueWrapper) =>
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
const { wrapper, router, input } = await mountAt(path)
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
clear.element.focus()
await clear.trigger('click')
await flushPromises()
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
expect(document.activeElement).toBe(clear.element)
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
expect(router.currentRoute.value.fullPath).toBe(path)
expect(push).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(input.element.value).toBe('회의')
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
// 다시 눌러도 그대로다 (비운 칸)
await clear.trigger('click')
await flushPromises()
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect(document.activeElement).toBe(clear.element)
})
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
const { wrapper } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
await field(wrapper, 'since').setValue('2026-11-05')
await field(wrapper, 'until').setValue('2026-10-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
const { wrapper, router } = await mountAt(path)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe(path)
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
})
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
})
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await input.setValue('')
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
})
})
+7 -10
View File
@@ -6,7 +6,8 @@
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -20,7 +21,7 @@ import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-
const PANEL_ID = 'mail-search-conditions'
const { isSearching, routeSearch, startSearch, clearConditions, closeSearch } = useMailSearchNavigation()
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const text = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
@@ -68,7 +69,10 @@ function onKeydown(event: KeyboardEvent) {
}
}
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
/**
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
*/
async function onApply(conditions: SearchConditions) {
if (!(await startSearch(text.value, conditions))) return
closeConditions({ restoreFocus: false })
@@ -76,12 +80,6 @@ async function onApply(conditions: SearchConditions) {
inputRef.value?.focus()
}
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
function onClear() {
closeConditions()
if (conditionsApplied.value) void clearConditions()
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
watch(
() => (routeSearch.value ? routeSearch.value.q : null),
@@ -145,7 +143,6 @@ watch(
:panel-id="PANEL_ID"
:applied="appliedConditions"
@apply="onApply"
@clear="onClear"
@close="closeConditions()"
/>
</form>
@@ -2,7 +2,8 @@
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
import { nextTick, onMounted, ref } from 'vue'
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
@@ -27,7 +28,7 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { draft, periodProblem, fill, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
@@ -58,6 +59,8 @@ async function takeConditions(): Promise<SearchConditions | null> {
defineExpose({
take: takeConditions,
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
clear,
/** 첫 칸(보낸 사람)으로 */
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
})
@@ -3,7 +3,10 @@
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
import { computed, ref } from 'vue'
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
@@ -20,7 +23,6 @@ interface Props {
interface Emits {
(e: 'apply', conditions: SearchConditions): void
(e: 'clear'): void
(e: 'close'): void
}
@@ -36,6 +38,11 @@ async function apply() {
if (conditions) emit('apply', conditions)
}
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
function clearFields() {
fieldsRef.value?.clear()
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || isImeComposing(event)) return
event.preventDefault()
@@ -79,7 +86,7 @@ defineExpose({
type="button"
class="search-conditions__button"
data-search-conditions-clear
@click="emit('clear')"
@click="clearFields"
>
{{ SEARCH_CONDITIONS_TEXT.clear }}
</button>
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 검색 결과 없음 (디자인 원본 Search · Search14 "결과 없음") — 더 찾을 것이 없을 때(hasNext false)만 보인다(이어 찾을 것이 남았으면 MailSearchMore)
// 검색 아이콘 · "‘계약’에 맞는 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색 아이콘 · "‘계약’이 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 —
// 검색어와 조건이 함께면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", 걸린 조건이 없으면 "다른 말로 찾아 보세요."
// 지난 메일을 검색할 수 있게 준비하는 동안(엔진 방식 — 낱말 단위)은 그 한계를 한 줄 더한다 — 준비가 끝나면 뺀다 (14단계)
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
import { endOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
@@ -433,7 +434,7 @@ describe('MailboxPanel — 메일함 관리', () => {
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'],
['error', MAILBOX_OPERATION_TEXT.failed('500', '1,234')],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK, type Clock } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_PAGE_SIZE, MAIL_CONVERSATION_REFRESH_GAP_MS } from '@/shared/constants/mail-conversation'
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { MailListSource } from '@/shared/utils/mail-list-source'
@@ -104,6 +104,8 @@ function deferred() {
interface Server {
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
@@ -132,7 +134,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
function serve(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true })
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
if (server.detail) return server.detail(request)
@@ -218,8 +220,9 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
const other = rowOf(wrapper, 'm2')
expect(other.get('.conversation-row__who').text()).toBe('이도윤')
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
@@ -441,7 +444,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
await rowOf(wrapper, 'm2').get('button').trigger('click')
await flushPromises()
const error = rowOf(wrapper, 'm2').get('[data-conversation-detail-error]')
expect(error.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(error.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
// 읽지 못했으니 읽음으로 바꾸지 않는다
expect(requests.some((request) => request.method === 'PATCH')).toBe(false)
@@ -453,6 +456,102 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
})
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
const draft = summary('d1', {
mailboxIds: ['drafts'],
from: { name: '김아라', email: USER.email },
isRead: false,
receivedAt: at(2026, 10, 6, 11, 0),
})
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
const { wrapper } = await mountReader()
const unread = rowOf(wrapper, 'm2')
expect(unread.classes()).toContain('is-unread')
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
for (const id of ['m1', 'm3', 'd1']) {
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
}
})
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
const reply = deferred()
serve({ patch: () => reply.promise })
const { wrapper } = await mountReader()
const row = () => rowOf(wrapper, 'm2')
await row().get('button').trigger('click')
await flushPromises()
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
'/messages/m2',
])
expect(row().classes()).not.toContain('is-unread')
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
reply.resolve(fail(503, 'MAIL_001'))
await flushPromises()
expect(row().classes()).toContain('is-unread')
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
const first = deferred()
let firstBulk: Promise<MockResult> | null = first.promise
serve({
patch: (request) => {
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
firstBulk = null
return reply
},
})
const { wrapper, store } = await mountReader()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
await marking
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
store.clearChecked()
store.toggleCheck('m3')
await store.patchChecked({ isRead: false })
await flushPromises()
const current = rowOf(wrapper, 'm3')
expect(current.classes()).toContain('is-unread')
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
await store.refreshConversation()
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
})
})
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
let failing = true
@@ -758,7 +857,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다 — 앞 문장만(원문은 [이 메일만 열기]로 연 읽기 화면에서)', async () => {
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
const { wrapper } = await mountReader()
@@ -770,6 +869,8 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
['htmlOmitted', MAIL_BODY_NOTICES.htmlOmitted],
['truncated', MAIL_BODY_NOTICES.truncated],
])
// 이 화면의 ⋯ 는 위에 연 메일의 것이라 할 일(⋯ → 원문 내려받기)은 붙이지 않는다
expect(row.text()).not.toContain(MAIL_BODY_NOTICE_ACTIONS.truncated)
expect(row.find('iframe').exists()).toBe(true)
})
@@ -2,6 +2,7 @@
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
@@ -337,6 +337,14 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-text-muted);
}
/*
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
*/
.message-row__preview :deep(.search-mark) {
color: var(--color-text-secondary);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
@@ -7,7 +7,7 @@ import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { Contact } from '@/shared/types/contacts'
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import type { PaginatedResult } from '@/shared/types/pagination'
@@ -94,12 +94,12 @@ describe('MessageReader 도구 모음', () => {
expect(linkHref(wrapper, '전달')).toBe('/mail/compose?forward=m1')
})
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 메일 동작 버튼은 숨긴다', async () => {
it('목록 보기에서는 메일을 못 불러와도 앞쪽 이동 도구(목록으로 등)가 남고, 이 메일로 할 일 단추는 숨긴다', async () => {
const store = useMailStore()
store.selected = null
store.selectedId = 'm1'
store.selectedStatus = 'error'
store.selectedError = { code: 'RES_001', message: '요청하신 정보나 페이지를 찾을 수 없습니다.' }
store.selectedError = { code: 'RES_001', message: ERROR_CODE_MESSAGES.RES_001 }
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
@@ -110,7 +110,7 @@ describe('MessageReader 도구 모음', () => {
})
expect(wrapper.find('.test-nav').exists()).toBe(true)
expect(wrapper.text()).toContain('요청하신 정보나 페이지를 찾을 수 없습니다.')
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.RES_001)
expect(wrapper.findAll('.tool-button')).toHaveLength(0)
})
@@ -159,7 +159,10 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
// 읽기 화면은 앞 문장 뒤에 할 일(⋯ → 원문 내려받기)까지
expect(notices(withoutText)).toEqual([
{ kind: 'unavailable', text: `${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}` },
])
})
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
@@ -172,7 +175,7 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
})
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
expect(notices(wrapper)[0]?.text).toBe(`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`)
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_IMAGES_TEXT } from '@/shared/constants/mail'
import type { BlockedSender, Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
import { formatScheduleTime } from '@/shared/utils/format'
import type { MailListSource } from '@/shared/utils/mail-list-source'
@@ -179,7 +180,7 @@ describe('MessageReader — 9단계', () => {
extra: (request) => (request.url === '/messages/m1' ? ok(SHOWN) : undefined),
})
const notice = wrapper.get('[data-notice="blockedImages"]')
expect(notice.text()).toContain('외부 이미지를 차단했습니다.')
expect(notice.text()).toContain(MAIL_IMAGES_TEXT.blocked)
const button = notice.get('[data-show-images]')
expect(button.text()).toBe('이미지 보기')
@@ -130,7 +130,7 @@ onBeforeUnmount(() => {
<div
class="reader__toolbar"
role="group"
aria-label="메일 동작"
aria-label="이 메일로 할 일"
>
<slot name="toolbar-start" />
<template v-if="message">
@@ -301,11 +301,11 @@ onBeforeUnmount(() => {
<StateMessage
v-else
icon="mail"
title="메일을 선택해 주세요"
title="고른 메일이 없습니다"
:hint="
mailStore.search
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
? '읽을 메일을 검색 결과에서 골라 주세요.'
: '읽을 메일을 목록에서 골라 주세요.'
"
fill
/>
@@ -54,7 +54,8 @@ const senderBlockEntry = computed(() => {
if (!junkId || !props.message.mailboxIds.includes(junkId)) return null
return blockStore.blockOf(props.message.from?.email)
})
const bodyNotices = computed(() => mailBodyNotices(props.message))
/** 읽기 화면은 할 일까지 — "… [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다." (이 화면의 ⋯ 는 이 메일의 것이다) */
const bodyNotices = computed(() => mailBodyNotices(props.message, { withAction: true }))
const imagesLoading = computed(() => mailStore.imagesLoadingId === props.message.id)
async function showImages() {
@@ -286,7 +286,7 @@ describe('RecipientField', () => {
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
expect(activeOption(wrapper)).toBeNull()
expect(input.attributes('aria-activedescendant')).toBeUndefined()
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter를 누르면 친 주소를 그대로 넣습니다')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
@@ -72,7 +72,7 @@ const emit = defineEmits<Emits>()
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
const HINT_TYPED_ADDRESS = 'Enter를 누르면 친 주소를 그대로 넣습니다'
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
const draft = ref<string>('')
@@ -354,7 +354,7 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
<span
v-if="!token.valid"
class="visually-hidden"
>(주소 형식 오류)</span>
>(주소가 올바르지 않음)</span>
<button
type="button"
class="recipient-chip__remove"
@@ -117,7 +117,7 @@ function onAddressInput() {
v-model="text"
type="text"
class="link-form__input"
placeholder="비우면 주소를 그대로 보여 줍니다"
placeholder="비워 두면 주소를 그대로 씁니다"
autocomplete="off"
@keydown.enter="onEnter"
>
@@ -2,7 +2,7 @@
// 앱 비밀번호를 만든 결과 창 — 모달 620(wide) · 처음 초점 [복사] (디자인 원본 SettingsAppPasswords "만든 결과 창" · 계약 5절 "앱 비밀번호")
// "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요."
// - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다(조각 사이를 여백으로 — 골라 복사해도 값 그대로) — [복사]는 값 그대로 복사한다.
// "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." — 글자 수는 받은 값의 길이로 쓴다.
// "40자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다." — 글자 수는 받은 값의 길이로 쓴다.
// 묶음은 줄바꿈 없는 inline-block 덩어리 — 넓으면 한 줄, 좁으면 묶음 사이에서만 줄이 바뀐다(묶음이 둘로 갈리지 않는다 — 2026-10-31 브라우저 점검)
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
@@ -1,6 +1,7 @@
<script setup lang="ts">
// [다른 곳 모두 로그아웃] 확인 — alertdialog 400(medium) · 처음 초점 「취소」, Esc 도 취소 (디자인 원본 SettingsLoginHistory "다른 곳 모두 로그아웃 — 확인 창")
// "다른 곳의 로그인을 모두 끊을까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. …" + 작은 글 "메일 프로그램은 끊기지 않습니다 — …"
// "다른 곳에서 모두 로그아웃할까요?" / "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. …"(웹메일 · 앱 세션을 함께 끊는다)
// + 작은 글 "메일 프로그램은 로그아웃되지 않습니다. …"
// [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소]. 끊는 동안 [모두 로그아웃]은 aria-disabled · aria-busy(초점을 지킨다)
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 제목 · 글이 "이 브라우저도 로그아웃된다"로 바뀐다 — 부모가 넘긴다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
@@ -1,9 +1,9 @@
<script setup lang="ts">
// 설정 › 로그인 기록 › 최근 로그인 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인 기록")
// 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
// 「최근 로그인」 + "최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램"(실패만이면 "최근 90일 · 실패 4번") · 오른쪽 전환 [전체 · 실패만](36 — .segmented 밀도 예외)
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
// - 방식: "웹메일" / "ARA SYSTEM 앱"(계약 1절 "앱 로그인" — method app) / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 앱은 운영체제 "Android"(방식 칸이 앱 이름이라), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
// - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」 — 실패는 빨간 굵은 글자만(뱃지 없음)
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
@@ -120,7 +120,7 @@ function timeText(event: LoginEvent): string {
{{ LOGIN_HISTORY_TEXT.columnIp }}
</th>
<th scope="col">
{{ LOGIN_HISTORY_TEXT.columnKind }}
{{ LOGIN_HISTORY_TEXT.columnDevice }}
</th>
<th scope="col">
{{ LOGIN_HISTORY_TEXT.columnResult }}
@@ -1,11 +1,12 @@
<script setup lang="ts">
// 설정 › 로그인 기록 › 로그인된 브라우저 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저")
// 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
// 설정 › 로그인 기록 › 로그인된 기기 (디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · 1절 "앱 로그인")
// 웹메일 세션과 ARA SYSTEM 앱 세션을 함께 보인다 — 웹 · 앱은 세션의 client 로만 가른다(device 의 이름으로 짐작하지 않는다)
// 「로그인된 기기」 + "웹메일 3곳"(앱 세션이 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳") · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16 — 앱 세션은 늘 휴대폰) · 기기 "Edge · Windows" · "ARA SYSTEM 앱 · Android" / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
// 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 브라우저가 없습니다.")
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
// 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(앱 비밀번호는 링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 기기가 없습니다.")
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
// 끊은 뒤에는 목록을 다시 받는다(스토어 — 이 브라우저도 끊겼으면 세션 만료 창으로 이어진다)
// - 세션이 바뀌면 확인 창을 닫는다
@@ -18,7 +19,13 @@ import { useToast } from '@/composables/useToast'
import { END_OTHER_SESSIONS_TEXT, LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { AuthSession } from '@/shared/types/account-security'
import { deviceLabel, endOtherSessionsMessage, isMobileDevice, otherSessions } from '@/shared/utils/account-security'
import {
endOtherSessionsMessage,
isMobileSession,
otherSessions,
sessionCountText,
sessionDeviceLabel,
} from '@/shared/utils/account-security'
import { formatCount, formatRelativeDayTime } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useLoginHistoryStore } from '@/stores/login-history.store'
@@ -46,8 +53,9 @@ const others = computed(() => otherSessions(props.sessions))
/** 서버가 이 브라우저를 알아봤다(지금 이 브라우저 줄이 있다) — 없으면 [다른 곳 모두 로그아웃]이 이 브라우저까지 끊는다 */
const hasCurrent = computed(() => props.sessions.some((session) => session.isCurrent))
const confirmTitle = computed(() => (hasCurrent.value ? END_OTHER_SESSIONS_TEXT.title : END_OTHER_SESSIONS_TEXT.titleAll))
const countText = computed(() => LOGIN_HISTORY_TEXT.sessionsCount(formatCount(props.sessions.length)))
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 브라우저가 없습니다." 를 앞에 */
/** "웹메일 3곳" · "웹메일 2곳 · ARA SYSTEM 앱 1곳" — 세션의 client 로 센다 */
const countText = computed(() => sessionCountText(props.sessions))
/** 아래 안내의 앞 글 — 다른 곳이 없으면 "다른 곳에 로그인된 기기가 없습니다." 를 앞에 */
const noteBefore = computed(() =>
others.value.length === 0
? `${LOGIN_HISTORY_TEXT.noOthers}${LOGIN_HISTORY_TEXT.sessionsNoteBefore}`
@@ -77,7 +85,7 @@ async function endSession(session: AuthSession) {
endingId.value = session.id
try {
await loginHistoryStore.endSession(session.id)
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(deviceLabel(session.device)))
toast.success(LOGIN_HISTORY_TEXT.sessionEnded(sessionDeviceLabel(session)))
await focusAfterRemoval(index)
} catch {
// 인터셉터가 알렸다 — 줄은 그대로
@@ -188,15 +196,15 @@ watch(
>
<td class="sessions__icon">
<AppIcon
:name="isMobileDevice(session.device) ? 'smartphone' : 'monitor'"
:label="isMobileDevice(session.device) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
:name="isMobileSession(session) ? 'smartphone' : 'monitor'"
:label="isMobileSession(session) ? LOGIN_HISTORY_TEXT.kindMobile : LOGIN_HISTORY_TEXT.kindPc"
/>
</td>
<td
class="sessions__device"
data-session-device
>
{{ deviceLabel(session.device) }}
{{ sessionDeviceLabel(session) }}
</td>
<td class="sessions__ip">
<template v-if="session.ip">
@@ -218,7 +226,7 @@ watch(
v-if="!session.isCurrent"
type="button"
class="sessions__end"
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(deviceLabel(session.device))"
:aria-label="LOGIN_HISTORY_TEXT.endSessionLabel(sessionDeviceLabel(session))"
:aria-disabled="endingId || endingOthers ? 'true' : undefined"
:aria-busy="endingId === session.id ? 'true' : undefined"
data-session-end
@@ -321,7 +329,7 @@ watch(
overflow: hidden;
}
/* 칸: 아이콘 16 · 브라우저(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
/* 칸: 아이콘 16 · 기기(나머지) · IP 112 · 마지막 활동 150 · 단추 84, 칸 사이 12 · 줄 앞 14 · 뒤 8 (디자인 원본 .ss-grid) */
.sessions__table {
width: 100%;
border-collapse: collapse;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
// 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일도 정리하기」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
@@ -11,7 +11,7 @@
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
// - 「받은편지함에 이미 있는 메일도 정리하기」는 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
@@ -84,7 +84,7 @@ interface Props {
}
interface Emits {
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
/** apply — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 (저장한 뒤 이번에 한 번) */
(e: 'save', rule: MailFilterRule, apply: boolean): void
(e: 'remove'): void
(e: 'cancel'): void
@@ -124,7 +124,7 @@ const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.lengt
let base = baseOf()
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
/** 「받은편지함에 이미 있는 메일도 정리하기」를 골랐다 — 열 때마다 꺼진다 */
const applyChosen = ref<boolean>(false)
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// 바탕화면 알림 켜기 · 알림 내용 · 시험 알림 (디자인 원본 SettingsNotify · MailRealtime "막혔을 때 · 지원하지 않을 때")
// - [x] 새 메일이 오면 바탕화면 알림 보내기(44) — 켜면 브라우저가 허락을 묻는다(누른 뒤에만). 칸 아래 안내(12.5px):
// 묻기 전(회색) · 허용됨(초록 + 체크) · 막힘(칸을 끄고 빨강 + 경고) · 지원하지 않음(칸을 끄고 회색) — notifyEnableState
// 허락하지 않았으면(막음 · 그냥 닫음) 칸은 비운 채 돌아간다. 묻는 동안은 칸을 끄지 않고 aria-disabled 로만 막는다(초점이 그대로 남게).
// 막혀서(또는 지원하지 않아서) 칸이 꺼지면 초점을 안내 글로 옮긴다 — 왜 꺼졌는지 바로 읽게
// - 알림에 보일 것: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - 알림 내용: [x] 메일 제목도 보이기 — 알림을 끄면 흐리게(누를 수 없다)
// - [시험 알림 보내기](보조 단추 44 · 종 아이콘) — 지금 탭에서 바로 "ARA 웹메일" / "알림이 잘 옵니다. …" (알림을 끄면 누를 수 없다).
// 못 띄우면 조용히 넘어가지 않는다 — 만들 수 없는 브라우저(휴대폰 Chrome 등)면 "지원하지 않음", 허락이 사라졌으면 그 안내로 바꾸고 초점을 옮긴다
// 고정 id — #notify-enabled · #notify-enabled-note · #notify-show-subject · #notify-test
@@ -6,7 +6,7 @@
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다."(서버가 웹메일 · ARA SYSTEM 앱 세션을 끊는다) — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
// - 2단계 인증을 켠 직원(12단계 — 계약 5절 "비밀번호 바꾸기")은 맨 아래 「인증 앱 코드」 칸(코드가 30초마다 바뀌어 다른 칸을 다 쓴 뒤 넣는다).
// 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007). 코드 틀림(AUTH_006)은 그 칸 아래 · 칸을 비운다.
// 다른 탭에서 켜서 코드 없이 보냈으면(AUTH_007) 2단계 상태를 다시 받고 칸을 보인다 — 못 받아도 칸은 보인다
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 알림의 "실시간 연결" (디자인 원본 SettingsNotify) — 이 탭이 보는 연결 상태 (stores/mail-realtime.store.ts)
// ● 초록 "연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// ● 초록 "연결되어 있어 새 메일이 오면 바로 알 수 있습니다." / 끊겼을 때 "연결할 수 없어 1분마다 확인합니다." (저절로 다시 잇는다)
// 끊긴 동안은 상태 줄 아래에 "연결될 때까지 바탕화면 알림은 오지 않습니다." (2026-10-28 화면 점검 반영)
// 메일 화면에서는 끊겨도 알리지 않는다 — 여기서만 볼 수 있다 (디자인 README "5단계 › 실시간 반영")
import { computed } from 'vue'
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
// "시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
import { ref } from 'vue'
@@ -84,7 +84,7 @@ export function useAccountSecurity() {
return api.delete<null, null>(`/app-passwords/${segment(id)}`, options)
}
/** 로그인된 브라우저 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
/** 로그인된 기기(웹메일 · 앱 세션 — 항목의 client) — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
function listAuthSessions(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AuthSession>> {
return api.get<PaginatedResult<AuthSession>, PaginatedResult<AuthSession>>('/auth/sessions', {
params: { limit: AUTH_SESSION_LIST_LIMIT },
@@ -97,7 +97,7 @@ export function useAccountSecurity() {
return api.delete<null, null>(`/auth/sessions/${segment(id)}`, options)
}
/** 이 요청의 세션만 남기고 모두 끊는다 */
/** 이 요청의 세션만 남기고 모두 끊는다 (앱 세션도) */
function endOtherAuthSessions(options?: AccountSecurityRequestOptions): Promise<EndedSessions> {
return api.delete<EndedSessions, EndedSessions>('/auth/sessions', options)
}
+4 -2
View File
@@ -26,8 +26,10 @@ import api, {
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
expect(getErrorMessage('AUTH_001')).toBe(
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
// 문구는 사전에서만 온다 — 글을 적지 않고 사전을 본다 (문구를 고칠 때마다 이 시험을 고치지 않게)
expect(getErrorMessage('AUTH_001')).toBe(ERROR_CODE_MESSAGES.AUTH_001)
expect(getErrorMessage('AUTH_001')).not.toBe(
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
)
})
+1 -1
View File
@@ -202,7 +202,7 @@ function handle(
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
const message = getErrorMessage(code)
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 · 403 AUTH_010 — 사용자가 끝낸 세션이라 알리지 않는다
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
// (로그인 화면에 "로그인이 필요합니다" 가 뜨지 않게)
const endedBySignOut = (code === 'AUTH_001' || code === RESTRICTED_CODE) && sentWithSession && authStore.signedOut
if (notify && !endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
@@ -339,7 +339,7 @@ describe('useLargeFileUploads', () => {
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
['setup.exe', 'rejected', ERROR_CODE_MESSAGES.FILE_001],
])
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
expect(uploads.uploadIds.value).toEqual(['u1'])
@@ -11,7 +11,7 @@ import {
} from '@/stores/mail-filter.store'
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계) 그 대신
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계) 그 대신
// 저장한 그 규칙(응답 목록의 같은 id — 서버가 앞뒤 공백을 지운 값)으로 받은편지함에 적용하는 진행 알림 (useMailFilterRun)
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
@@ -20,7 +20,7 @@ import {
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
export interface MailFilterSaveOptions {
/** 「받은편지함에 이미 있는 메일에도 적용」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
/** 「받은편지함에 이미 있는 메일도 정리하기」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
apply?: boolean
}
@@ -14,7 +14,7 @@ import { useMailFilterStore } from '@/stores/mail-filter.store'
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
// - [저장]이 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
// - [저장]이 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
// 창을 그사이 닫았어도 저장했으면 적용한다(고른 사람이 저장을 보냈다)
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
@@ -149,7 +150,7 @@ describe('useMailFilterRun', () => {
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'],
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터로 받은편지함의 메일 3통을 정리했습니다.'],
['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
['걸리는 메일이 없는 옮기는 필터', rule(), 0, MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’')],
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
replies(step(8412, matched, 0, null))
+2 -2
View File
@@ -15,11 +15,11 @@ import { useMailStore } from '@/stores/mail.store'
import type { ToastAction } from '@/stores/toast.store'
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 (디자인 README "7단계 › 저장한 뒤 — 진행 · 결과 알림" · 디자인 원본 MailFilterApply · MailFilterApplyStates)
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면 "필터를 저장했습니다." 대신 진행 알림
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일도 정리하기」를 골랐으면 "필터를 저장했습니다." 대신 진행 알림
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)이
// 남았다가 끝나면 그 자리에서 결과로 바뀐다 (4단계 오래 걸리는 처리 — useMailboxActions 의 runOperation 과 같은 결)
// - 끝: "‘…’ 필터를 받은편지함의 메일 N통에 적용했습니다." + 옮기는 필터이고 1통 이상이면 [{메일함} 보기](filterRunTarget — 받은편지함 ·
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 맞는 메일이 없으면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다."
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 걸리는 메일이 없으면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다."
// - 멈춤(이어 할 자리가 있다): "‘…’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." +
// [다시 시도](그 cursor 로 이어서 — 수는 이어 센다)
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
@@ -33,7 +33,8 @@ function paramOf(value: unknown): string | null {
/**
* 검색 화면 오가기 (계약 2절 "검색" · "검색 — 14단계" · 디자인 README "3단계 › 검색" · "14단계 › 검색 · 조건 검색 · 강조")
* - 상단 검색 칸의 Enter · 「조건으로 찾기」의 [찾기]: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서),
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다
* 검색 화면에서는 지금 범위 그대로. 검색 칸이 비어도 조건만으로 찾는다. Enter 는 걸려 있는 조건을 그대로 둔다.
* 검색어도 패널의 조건도 없는 [찾기]는 조건이 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다 (조건은 그대로)
* - 조건 줄의 [조건 지우기]: 조건만 빼고 다시 찾는다 — 검색어도 없으면 검색을 닫는다
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
@@ -81,14 +82,20 @@ export function useMailSearchNavigation() {
/**
* Enter · [찾기] — 검색어도 조건도 없으면 아무것도 하지 않는다. 같은 검색으로 다시 누르면 보이는 채로 다시 찾는다.
* conditions 를 주면(「조건으로 찾기」) 그 조건으로, 주지 않으면(검색 칸의 Enter) 걸려 있는 조건 그대로 찾는다.
* 조건이 걸린 검색에서 패널의 칸을 모두 비우고 검색어도 없이 [찾기]를 누르면 조건 줄의 [조건 지우기]처럼 검색을 닫는다
* (2026-11-07 — 패널의 [조건 지우기]가 칸만 비우게 되어, 조건만 걸린 검색은 [찾기]로도 조건을 뺄 수 있게).
* 검색어와 조건 넷을 합친 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로 둔다.
* 찾았으면 true (패널이 닫힌다)
* 찾았으면(검색을 닫았으면) true (패널이 닫힌다)
*/
async function startSearch(text: string, conditions?: Partial<SearchConditions> | null): Promise<boolean> {
const q = normalizeSearchText(text)
const current = routeSearch.value
const nextConditions = conditions === undefined ? (current?.conditions ?? null) : normalizeSearchConditions(conditions)
if (!q && !hasSearchConditions(nextConditions)) return false
if (!q && !hasSearchConditions(nextConditions)) {
if (!current || !hasSearchConditions(current.conditions)) return false
await closeSearch()
return true
}
if (exceedsCombinedSearchWords(q, nextConditions)) {
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
return false
@@ -27,7 +27,7 @@ export interface SearchConditionsDraft {
}
/**
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take).
* 「조건으로 찾기」 패널의 칸 값 (14단계 — 디자인 원본 Search14). 걸린 조건으로 채우고(fill), 넘길 조건을 만든다(take), 모두 비운다(clear — [조건 지우기]).
* - 기간은 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기로 채운다. 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다
* - 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다 — 조금만 고치면 되게
* - 날짜 고르기의 시작이 끝보다 늦거나 고를 수 없는 날짜면 넘기지 않고 periodProblem 을 둔다 (칸 아래에 알린다)
@@ -67,6 +67,14 @@ export function useSearchConditionsDraft(clock: Clock) {
periodProblem.value = null
}
/**
* 칸을 모두 비운다 — 패널의 [조건 지우기] (2026-11-07): 글 칸 · 기간(전체 기간) · 날짜 · 첨부 체크 · 칸 아래 알림까지.
* 찾지는 않는다 — 걸린 조건은 [찾기]를 누를 때까지 그대로다
*/
function clear() {
fill(null)
}
/** 기간 고르기를 바꾼다 */
function changePeriod(period: SearchPeriod) {
draft.period = period
@@ -115,5 +123,5 @@ export function useSearchConditionsDraft(clock: Clock) {
})
}
return { draft, periodProblem, fill, changePeriod, changeDay, take }
return { draft, periodProblem, fill, clear, changePeriod, changeDay, take }
}
@@ -5,7 +5,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
import { RESTRICTED_SECRET_WINDOW_MS, SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction'
import type { TwoFactorEnrollment, TwoFactorStatus } from '@/shared/types/account-security'
import type { SessionRestriction } from '@/shared/types/auth'
import { useAuthStore } from '@/stores/auth.store'
@@ -602,7 +602,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
it('정하지 않았거나 받지 못하면 지금 글 그대로 — 알리지 않는다', async () => {
const { wrapper } = await mountLogin({ supportContact: () => fail(503, 'SYS_001') })
expect(wrapper.get('[data-support-line]').text()).toBe('비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.')
expect(wrapper.get('[data-support-line]').text()).toBe(SUPPORT_CONTACT_TEXT.passwordFallback)
expect(useToastStore().toasts).toEqual([])
})
})
+1 -1
View File
@@ -376,7 +376,7 @@ void supportContactStore.ensureLoaded()
사내 업무를<br>한 계정으로
</p>
<p class="login__lead">
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.
메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.
</p>
</div>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
<p>찾는 페이지가 없습니다. 주소가 맞는지 확인해 주세요.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
@@ -5,7 +5,12 @@ import { h, type FunctionalComponent } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { CONTACTS_SEARCH_DELAY_MS } from '@/shared/constants/contacts'
import {
CONTACT_DETAIL_TEXT,
CONTACTS_EMPTY_TEXT,
CONTACTS_LIST_TEXT,
CONTACTS_SEARCH_DELAY_MS,
} from '@/shared/constants/contacts'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
Contact,
@@ -387,7 +392,7 @@ describe('ContactsPage (주소록 모듈)', () => {
const row = app.get('[data-contacts-row="c2"]')
expect(row.get('.contacts-row__meta').text()).toBe('(주)한빛상사 · 영업팀')
expect(row.get('.contacts-row__email').text()).toBe('jihoon.park@hanbit.example')
expect(app.get('[data-contact-none]').text()).toContain('연락처를 골라 주세요')
expect(app.get('[data-contact-none]').text()).toContain(CONTACT_DETAIL_TEXT.noneTitle)
await click(row.get('a'))
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
@@ -624,11 +629,11 @@ describe('ContactsPage (주소록 모듈)', () => {
const { app } = await mountApp('/contacts/directory/people/s5')
expect(app.get('#contacts-list-title').text()).toBe('직원')
expect(app.get('.contacts-list__note').text()).toBe('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.')
expect(app.get('.contacts-list__note').text()).toBe(CONTACTS_LIST_TEXT.directoryNote.person)
const detail = app.get('[data-directory-detail="s5"]')
expect(detail.get('.contact-detail__subtitle').text()).toBe('사내 직원')
expect(detail.findAll('.tool-button').map((button) => button.text())).toEqual(['메일 쓰기', '내 주소록에 추가'])
expect(detail.get('.contact-detail__hint').text()).toContain('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다')
expect(detail.get('.contact-detail__hint').text()).toContain(CONTACT_DETAIL_TEXT.directoryHint)
await click(detail.get('[data-contact-add-mine]'))
expect(isOpen(app, '#contact-dialog')).toBe(true)
@@ -720,7 +725,7 @@ describe('ContactsPage (주소록 모듈)', () => {
clock.advance(CONTACTS_SEARCH_DELAY_MS)
await flushPromises()
const empty = app.get('[data-contacts-empty="search"]')
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
expect(empty.text()).toContain(CONTACTS_EMPTY_TEXT.searchTitle('없는사람', false))
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
+1 -1
View File
@@ -1195,7 +1195,7 @@ watch(hasMissingLarge, (missing) => {
v-model="to"
label="받는 사람"
input-id="compose-to"
placeholder="메일 주소 입력 후 Enter"
placeholder="메일 주소를 쓰고 Enter를 눌러 주세요"
:error-id="RECIPIENT_ERROR_ID"
:rejected="rejectedByServer !== null"
:disabled="preparing"
@@ -3,6 +3,8 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
import { SEARCH_NOTICE_TEXT } from '@/shared/constants/mail-search'
import type { Mailbox, MessageDetail, MessageListItem, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
@@ -19,6 +21,8 @@ import {
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import highlightedTextSource from '@/components/mail/HighlightedText.vue?raw'
import messageListItemSource from '@/components/mail/MessageListItem.vue?raw'
import MailboxPage from './MailboxPage.vue'
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
@@ -126,7 +130,7 @@ describe('MailboxPage — 검색 결과', () => {
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
const push = vi.spyOn(router, 'push')
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
// 걸린 조건이 없으면 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
expect(wrapper.text()).toContain('다른 말로 찾아 보세요.')
expect(wrapper.text()).not.toContain('조건을 줄여')
@@ -333,7 +337,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
url === '/search-index' ? indexState('ready') : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
expect(wrapper.text()).toContain('다른 말로 찾거나 조건을 줄여 보세요.')
})
@@ -369,6 +373,22 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(row.find('[aria-label="첨부 있음"]').exists()).toBe(true)
})
it('미리 보기 줄의 맞은 말만 글자색을 진하게(--color-text-secondary) — 흐린 글자 위 노랑은 대비가 모자라다, 제목 줄의 강조는 글자색 그대로 (2026-11-07)', () => {
// jsdom 은 컴포넌트의 style 을 적용하지 않는다 — 규칙을 원본에서 읽는다 (주석은 뺀다)
const rulesOf = (source: string) =>
[...source.slice(source.indexOf('<style')).replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^}]*)\}/g)].map(
([, selector = '', body = '']) => ({ selector: selector.replace('<style scoped>', '').trim(), body }),
)
// 강조 자체는 글자색을 물려받는다 — 제목 줄 · 읽기 화면 · 첨부 이름은 진한 글자 위다
const mark = rulesOf(highlightedTextSource).find((rule) => rule.selector === '.search-mark')
expect(mark?.body).toMatch(/color:\s*inherit;/)
// 목록 줄은 미리 보기 줄 안의 강조만 바꾼다 (제목 줄의 강조를 바꾸는 규칙은 없다)
const overrides = rulesOf(messageListItemSource).filter((rule) => rule.selector.includes('search-mark'))
expect(overrides.map((rule) => rule.selector)).toEqual(['.message-row__preview :deep(.search-mark)'])
expect(overrides[0]?.body).toMatch(/color:\s*var\(--color-text-secondary\);/)
})
it('강조가 없으면(엔진 방식 — 색인을 준비하는 중) 글 그대로 — 미리 보기는 메일의 미리 보기, 결과 머리 아래 "검색 준비 중" 줄', async () => {
handler = ({ url }) => {
if (url === '/search-index') return indexState('building')
@@ -384,9 +404,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(row.get('[data-row-preview]').text()).toBe('지난번 말씀하신 계약서 초안을 첨부했습니다.')
const notice = wrapper.get('[data-search-notice="indexing"]')
// 색인은 밤에만 채운다 — 낮 내내 같은 글이어도 멈춘 줄 알지 않게 (2026-11-06 실사용 시험)
expect(notice.text()).toBe(
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
)
expect(notice.text()).toBe(SEARCH_NOTICE_TEXT.indexing)
expect(notice.element.closest('[role="status"]')).not.toBeNull()
})
@@ -398,7 +416,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(wrapper.get('[data-search-word-limit]').text()).toBe(
'준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('통째로')
expect(wrapper.get('[data-search-word-limit]').text()).not.toContain('끝까지 다 써야')
})
it('색인 상태를 못 받으면 안내 줄 · 한계 안내 없이 지금처럼 — 알리지 않는다', async () => {
@@ -466,6 +484,16 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(document.activeElement).toBe(wrapper.get('section.mail-list-frame').element)
})
it('조건만 걸린 검색에서 조건 줄의 [조건 지우기]는 검색을 닫는다 — 검색 전 메일함으로 (패널의 [조건 지우기]는 칸만 비운다 — 2026-11-07)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?in=inbox&${CONDITIONS_QUERY}`)
await wrapper.get('[data-search-condition-line-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('조건 줄의 [조건 지우기]를 누르면 줄이 사라진다 — 초점은 목록 칸으로', async () => {
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox&${CONDITIONS_QUERY}`, { attach: true })
const clear = wrapper.get('[data-search-condition-line-clear]')
@@ -486,9 +514,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests()).toEqual([])
const empty = wrapper.get('[data-search-too-many-words]')
expect(empty.text()).toContain('찾을 말이 너무 많습니다')
expect(empty.text()).toContain(
'검색어와 조건에 넣은 낱말은 모두 합쳐 8개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.',
)
expect(empty.text()).toContain(SEARCH_NOTICE_TEXT.tooManyWordsHint(8))
// 다른 메일함에서 찾아도 같다 — [전체 메일함에서 찾기]를 권하지 않는다. 조건은 조건 줄에서 지울 수 있다
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.find('[data-search-condition-line-clear]').exists()).toBe(true)
@@ -533,9 +559,9 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
}
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
expect(wrapper.text()).toContain('아직 맞는 메일을 찾지 못했습니다')
expect(wrapper.text()).toContain(SEARCH_NOTICE_TEXT.partialTitle)
expect(wrapper.text()).toContain('메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.')
expect(wrapper.text()).not.toContain('에 맞는 메일이 없습니다')
expect(wrapper.text()).not.toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
const sent = listRequests().length
exhausted = true
@@ -544,7 +570,7 @@ describe('MailboxPage — 14단계 검색 (조건 · 강조 · 이어 찾기 ·
expect(listRequests().length).toBe(sent + 1)
expect(wrapper.find('[data-search-more]').exists()).toBe(false)
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(MAIL_SEARCH_TEXT.emptyTitle('회의'))
})
it('결과에서 연 메일은 검색어와 글 조건 넷만 넘겨 강조를 받는다 — 제목 · 첨부 이름 · 글 본문의 맞은 말', async () => {
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { APP_PASSWORD_RESULT_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AppPassword, CreatedAppPassword } from '@/shared/types/account-security'
import { formatBlockedDay, formatRelativeDayTime } from '@/shared/utils/format'
@@ -252,7 +253,7 @@ describe('AccountAppPasswordsPage', () => {
'uk0n',
])
expect(wrapper.get('[data-app-password-value]').text()).toBe(VALUE)
expect(wrapper.get('.result__length').text()).toBe('40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.')
expect(wrapper.get('.result__length').text()).toBe(APP_PASSWORD_RESULT_TEXT.lengthNote('40'))
expect(document.activeElement?.hasAttribute('data-app-password-copy')).toBe(true)
expect(wrapper.get('.result__warning').text()).toContain('이 창을 닫으면 다시 볼 수 없습니다.')
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { LOGIN_HISTORY_TEXT } from '@/shared/constants/account-security'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { AuthSession, LoginEvent } from '@/shared/types/account-security'
import { formatRelativeDayTime } from '@/shared/utils/format'
@@ -14,8 +15,13 @@ import { createTestRouter } from '@/test-utils/router'
import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
// 로그인된 기기 · 최근 로그인에는 ARA SYSTEM 앱의 세션 · 로그인도 섞여 온다 (계약 1절 "앱 로그인" — 세션의 client · 기록의 method 가 app)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 서버가 앱 세션 · 앱 로그인 기록에 싣는 기기 */
const APP_ANDROID = { browser: 'ARA SYSTEM 앱', os: 'Android' }
/** 로그인된 기기 아래 안내 — 가운데 [앱 비밀번호]가 링크다 */
const SESSIONS_NOTE = `${LOGIN_HISTORY_TEXT.sessionsNoteBefore}${LOGIN_HISTORY_TEXT.sessionsNoteLink}${LOGIN_HISTORY_TEXT.sessionsNoteAfter}`
/**
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
@@ -32,6 +38,7 @@ const SESSIONS: AuthSession[] = [
lastActiveAt: local(10, 31, 11, 50),
ip: '211.186.95.252',
device: { browser: 'Chrome', os: 'Windows' },
client: 'web',
isCurrent: true,
},
{
@@ -40,6 +47,7 @@ const SESSIONS: AuthSession[] = [
lastActiveAt: local(10, 31, 9, 5),
ip: '211.186.95.252',
device: { browser: 'Edge', os: 'Windows' },
client: 'web',
isCurrent: false,
},
{
@@ -48,6 +56,16 @@ const SESSIONS: AuthSession[] = [
lastActiveAt: local(10, 30, 22, 31),
ip: null,
device: { browser: 'Safari', os: 'iOS' },
client: 'web',
isCurrent: false,
},
{
id: 'app',
createdAt: local(10, 28, 8),
lastActiveAt: local(10, 30, 20, 14),
ip: '58.29.11.4',
device: APP_ANDROID,
client: 'app',
isCurrent: false,
},
]
@@ -76,6 +94,13 @@ const EVENTS: LoginEvent[] = [
device: null,
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
}),
loginEvent('app1', {
occurredAt: local(10, 31, 8, 47),
method: 'app',
device: APP_ANDROID,
ip: '58.29.11.4',
isTwoFactor: true,
}),
loginEvent('e4', {
occurredAt: local(10, 30, 23, 58),
method: 'smtp',
@@ -124,7 +149,7 @@ describe('AccountLoginHistoryPage', () => {
if (request.url === '/auth/sessions' && request.method === 'GET') {
return server.sessions ? server.sessions() : ok(pageOf(SESSIONS))
}
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 2 })
if (request.url === '/auth/sessions') return server.endOthers ? server.endOthers() : ok({ ended: 3 })
if (request.url.startsWith('/auth/sessions/')) return server.endSession ? server.endSession(request) : ok(null)
if (request.url === '/login-events') return server.events ? server.events(request) : ok(pageOf(EVENTS))
return ok(null)
@@ -166,15 +191,16 @@ describe('AccountLoginHistoryPage', () => {
])
})
describe('로그인된 브라우저', () => {
it('"웹메일 3곳" · 줄마다 PC · 휴대폰 아이콘 · 브라우저 · 운영체제 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
describe('로그인된 기기', () => {
it('"웹메일 3곳 · ARA SYSTEM 앱 1곳" · 줄마다 PC · 휴대폰 아이콘 · 기기 / IP / 마지막 활동 — 지금 이 브라우저는 단추 없이 글자만', async () => {
const wrapper = await mountPage()
const nowDate = new Date(NOW)
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
expect(wrapper.get('#login-sessions-title').text()).toBe('로그인된 기기')
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳 · ARA SYSTEM 앱 1곳')
expect(wrapper.findAll('thead').at(0)?.findAll('th').map((cell) => cell.text())).toEqual([
'종류',
'기기',
'브라우저 · 운영체제',
'IP',
'마지막 활동',
'로그아웃',
@@ -183,20 +209,62 @@ describe('AccountLoginHistoryPage', () => {
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
['Edge · Windows', '211.186.95.252', formatRelativeDayTime(SESSIONS[1]?.lastActiveAt ?? '', nowDate)],
['Safari · iOS', '—알 수 없음', formatRelativeDayTime(SESSIONS[2]?.lastActiveAt ?? '', nowDate)],
['ARA SYSTEM 앱 · Android', '58.29.11.4', formatRelativeDayTime(SESSIONS[3]?.lastActiveAt ?? '', nowDate)],
])
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
expect(sessionRows(wrapper)[3]?.[2]).toBe('어제 오후 8:14')
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual([
'PC',
'PC',
'휴대폰',
'휴대폰',
])
expect(wrapper.get('[data-session="now"]').find('[data-session-end]').exists()).toBe(false)
expect(wrapper.findAll('[data-session-end]').map((button) => button.attributes('aria-label'))).toEqual([
'‘Edge · Windows’ 로그아웃',
'‘Safari · iOS’ 로그아웃',
'‘ARA SYSTEM 앱 · Android’ 로그아웃',
])
expect(wrapper.get('[data-sessions-end-others]').text()).toBe('다른 곳 모두 로그아웃')
const note = wrapper.get('[data-sessions-note]')
expect(note.text()).toBe('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
expect(note.text()).toBe(SESSIONS_NOTE)
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
})
it('앱 줄은 client 로 정한다 — 운영체제를 몰라도 휴대폰 · 앱 이름, 앱 꼴 웹 세션은 웹메일로 센다', async () => {
const wrapper = await mountPage({
sessions: () =>
ok(
pageOf<AuthSession>([
SESSIONS[0] as AuthSession,
{ ...(SESSIONS[3] as AuthSession), id: 'app-unknown-os', device: { browser: 'ARA SYSTEM 앱', os: null } },
// 웹 세션이 앱 꼴 User-Agent 를 보냈다 — 서버는 브라우저를 모른다(null)고 준다
{ ...(SESSIONS[1] as AuthSession), id: 'web-android', device: { browser: null, os: 'Android' } },
]),
),
})
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳 · ARA SYSTEM 앱 1곳')
expect(wrapper.get('[data-session="app-unknown-os"] [data-session-device]').text()).toBe('ARA SYSTEM 앱')
expect(wrapper.get('[data-session="app-unknown-os"] svg').attributes('aria-label')).toBe('휴대폰')
expect(wrapper.get('[data-session="web-android"] [data-session-device]').text()).toBe('알 수 없는 브라우저 · Android')
})
it('지금 이 브라우저와 앱 세션 하나면 "웹메일 1곳 · ARA SYSTEM 앱 1곳" — 앱 줄을 끊으면 "웹메일 1곳" · 다른 곳에 로그인된 기기가 없다는 안내', async () => {
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, SESSIONS[3] as AuthSession])) })
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳 · ARA SYSTEM 앱 1곳')
await wrapper.get('[data-session="app"] [data-session-end]').trigger('click')
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/auth/sessions/app',
])
expect(toastMessages()).toEqual(['‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳')
expect(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
})
it('모르는 브라우저는 "알 수 없는 브라우저"', async () => {
const unknown: AuthSession = { ...SESSIONS[1], id: 'old', ip: null, device: null } as AuthSession
const wrapper = await mountPage({ sessions: () => ok(pageOf([SESSIONS[0] as AuthSession, unknown])) })
@@ -215,8 +283,24 @@ describe('AccountLoginHistoryPage', () => {
'/auth/sessions/edge',
])
expect(toastMessages()).toEqual(['‘Edge · Windows’에서 로그아웃했습니다.'])
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
expect(sessionRows(wrapper).map((row) => row[0])).toEqual([
'Chrome · Windows',
'Safari · iOS',
'ARA SYSTEM 앱 · Android',
])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳 · ARA SYSTEM 앱 1곳')
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
})
it('맨 아래 앱 줄을 끊으면 초점은 앞 줄 — 「‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.」', async () => {
const wrapper = await mountPage()
const button = wrapper.get('[data-session="app"] [data-session-end]')
;(button.element as HTMLButtonElement).focus()
await button.trigger('click')
await flushPromises()
expect(toastMessages()).toEqual(['‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 3곳')
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
})
@@ -226,10 +310,10 @@ describe('AccountLoginHistoryPage', () => {
await flushPromises()
expect(toastMessages()).toEqual(['‘Safari · iOS’에서 로그아웃했습니다.'])
expect(sessionRows(wrapper)).toHaveLength(2)
expect(sessionRows(wrapper)).toHaveLength(3)
})
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름 — 앱 포함) → 「다른 3곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
const wrapper = await mountPage()
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
@@ -238,7 +322,7 @@ describe('AccountLoginHistoryPage', () => {
expect(dialog?.hasAttribute('open')).toBe(true)
expect(dialog?.getAttribute('role')).toBe('alertdialog')
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
'이 브라우저를 뺀 3곳(Edge · Windows, Safari · iOS, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
expect(dialog?.textContent).toContain('메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
@@ -249,13 +333,12 @@ describe('AccountLoginHistoryPage', () => {
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
expect(toastMessages()).toEqual(['다른 2곳에서 로그아웃했습니다.'])
expect(toastMessages()).toEqual(['다른 3곳에서 로그아웃했습니다.'])
expect(dialog?.hasAttribute('open')).toBe(false)
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳')
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
expect(wrapper.get('[data-sessions-note]').text()).toBe(
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
)
expect(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
expect(document.activeElement?.id).toBe('login-sessions-title')
})
@@ -265,18 +348,18 @@ describe('AccountLoginHistoryPage', () => {
// 끊은 뒤 — 이 브라우저의 세션도 끝났다: 다시 받으면 401, 갱신도 401
restoreApiAdapter()
requests = installMockApi((request) => {
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 3 })
if (request.url === '/auth/sessions' && request.method === 'DELETE') return ok({ ended: 4 })
return fail(401, 'AUTH_001')
})
expect(wrapper.findAll('[data-session-end]')).toHaveLength(3)
expect(wrapper.findAll('[data-session-end]')).toHaveLength(4)
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
'이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
)
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 4곳(Chrome · Windows, Edge · Windows, Safari · iOS 외 1곳)에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
)
await wrapper.get('[data-end-others-confirm]').trigger('click')
@@ -288,7 +371,7 @@ describe('AccountLoginHistoryPage', () => {
'GET /auth/sessions',
'POST /auth/refresh',
])
expect(toastMessages()).toContain('이 브라우저를 포함해 3곳에서 로그아웃했습니다.')
expect(toastMessages()).toContain('이 브라우저를 포함해 4곳에서 로그아웃했습니다.')
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
expect(wrapper.find('[data-session]').exists()).toBe(false)
expect(useAuthStore().sessionExpired).toBe(true)
@@ -317,35 +400,57 @@ describe('AccountLoginHistoryPage', () => {
reply = ok(pageOf(SESSIONS))
await wrapper.get('[data-sessions-error] .mail-load-error__action').trigger('click')
await flushPromises()
expect(sessionRows(wrapper)).toHaveLength(3)
expect(sessionRows(wrapper)).toHaveLength(4)
})
})
describe('최근 로그인', () => {
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
it('시각 · 방식(앱 비밀번호 이름 · ARA SYSTEM 앱) · IP · 기기(앱은 운영체제, 메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
const wrapper = await mountPage()
const nowDate = new Date(NOW)
const time = (index: number) => formatRelativeDayTime(EVENTS[index]?.occurredAt ?? '', nowDate)
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램')
expect(wrapper.findAll('thead').at(1)?.findAll('th').map((cell) => cell.text())).toEqual([
'시각',
'방식',
'IP',
'기기',
'결과',
])
expect(eventRows(wrapper)).toEqual([
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '2단계 인증으로 성공'],
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '코드가 틀려 실패'],
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
[time(3), 'ARA SYSTEM 앱', '58.29.11.4', 'Android', '2단계 인증으로 성공'],
[time(4), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
[time(5), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
])
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
expect(eventRows(wrapper)[3]?.[0]).toBe('오늘 오전 8:47')
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
false,
true,
false,
false,
true,
true,
])
expect(wrapper.find('[data-events-more]').exists()).toBe(false)
})
it('운영체제를 모르는 앱 줄의 기기는 —', async () => {
const unknownOs = loginEvent('app2', {
method: 'app',
device: { browser: 'ARA SYSTEM 앱', os: null },
isSuccess: false,
failureReason: 'wrongPassword',
})
const wrapper = await mountPage({ events: () => ok(pageOf([unknownOs])) })
expect(eventRows(wrapper)[0]?.slice(1)).toEqual(['ARA SYSTEM 앱', '211.186.95.252', '—알 수 없음', '비밀번호가 틀려 실패'])
})
it('[실패만] — isSuccess=false 로 처음부터 받는다 · "최근 90일 · 실패 4번"(서버의 total) · 전환은 aria-pressed', async () => {
const failures = EVENTS.filter((event) => !event.isSuccess)
const wrapper = await mountPage({
@@ -398,7 +503,7 @@ describe('AccountLoginHistoryPage', () => {
},
})
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
expect(wrapper.text()).toContain(LOGIN_HISTORY_TEXT.moreHint)
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
@@ -435,14 +540,14 @@ describe('AccountLoginHistoryPage', () => {
await flushPromises()
expect(gates).toHaveLength(2)
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBe('true')
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
expect(wrapper.findAll('[data-login-event]')).toHaveLength(6)
// 늦게 온 「실패만」 응답은 버린다
gates[1]?.(ok(pageOf([EVENTS[0] as LoginEvent])))
gates[0]?.(ok(pageOf([EVENTS[1] as LoginEvent], null, 1)))
await flushPromises()
expect(wrapper.findAll('[data-login-event]').map((row) => row.attributes('data-login-event'))).toEqual(['e1'])
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일, ARA SYSTEM 앱, 메일 프로그램')
expect(wrapper.get('.events__table-box').attributes('aria-busy')).toBeUndefined()
})
@@ -469,7 +574,7 @@ describe('AccountLoginHistoryPage', () => {
release(ok(pageOf(EVENTS)))
await flushPromises()
expect(again.get('[data-events-filter="all"]').attributes('aria-pressed')).toBe('true')
expect(again.findAll('[data-login-event]')).toHaveLength(5)
expect(again.findAll('[data-login-event]')).toHaveLength(6)
})
it('기록을 받지 못하면 그 묶음만 사전 문구 + [다시 시도]', async () => {
@@ -477,12 +582,12 @@ describe('AccountLoginHistoryPage', () => {
const wrapper = await mountPage({ events: () => reply })
expect(wrapper.get('[data-events-error]').text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(sessionRows(wrapper)).toHaveLength(3)
expect(sessionRows(wrapper)).toHaveLength(4)
reply = ok(pageOf(EVENTS))
await wrapper.get('[data-events-error] .mail-load-error__action').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-login-event]')).toHaveLength(5)
expect(wrapper.findAll('[data-login-event]')).toHaveLength(6)
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 설정 › 계정 › 로그인 기록 (디자인 원본 SettingsLoginHistory · README "12단계 › 설정 › 계정 › 로그인 기록" · 계약 5절 "로그인된 브라우저" · "로그인 기록")
// — /settings/account/login-history · 탭 제목 "로그인 기록"
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 브라우저(LoginSessionList) → 최근 로그인(LoginEventTable)
// 머리 "로그인 기록" + 설명(「비밀번호를 바꾸고」 · 「2단계 인증」 링크) → 로그인된 기기(LoginSessionList — 웹메일 · ARA SYSTEM 앱 세션) → 최근 로그인(LoginEventTable)
// - 들어올 때마다 둘 다 새로 받는다(login-history.store). 묶음마다 받는 동안은 자리만, 못 받으면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도])
// - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(서버가 끊은 세션 id 를 막는다 — 계약 5절)
import { computed } from 'vue'
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_CLIENTS_TEXT } from '@/shared/constants/mail-clients'
import type { TwoFactorStatus } from '@/shared/types/account-security'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -91,9 +92,7 @@ describe('AccountMailClientsPage', () => {
it('프로그램별 차례 — 새 Outlook 주의 줄 · 다섯 프로그램, 처음에는 모두 접힘', async () => {
const wrapper = await mountPage()
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
)
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(MAIL_CLIENTS_TEXT.cloudWarning)
const buttons = wrapper.findAll('[data-mail-client] h3 button')
expect(buttons.map((button) => button.text())).toEqual([
expect.stringContaining('아웃룩 (Windows)'),
@@ -81,10 +81,11 @@ describe('AccountPasswordPage', () => {
expect(wrapper.get('#password-next-hint').text()).toBe(
'8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
)
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
// 모든 직원 — 다른 곳 로그인(웹메일 · ARA SYSTEM 앱)이 끊기고 앱 비밀번호도 모두 지워진다 (12단계 · 앱 1단계)
expect(wrapper.get('[data-password-note]').text()).toContain(
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저에서 모두 로그아웃됩니다.',
)
expect(wrapper.get('[data-password-note]').text()).not.toContain('웹메일에서 로그아웃')
expect(wrapper.get('[data-password-note]').text()).toContain(
'앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
)
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 설정 › 계정 › 비밀번호 (디자인 원본 SettingsAccount · README "4단계 › 비밀번호" · 12단계 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원")
// — /settings/account/password · 설정의 첫 화면
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기의 웹메일 로그인이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
// 바꾸는 폼(PasswordChangeForm) 아래에 안내 상자(바탕 #F3F8F8): 다른 기기와 브라우저의 로그인(웹메일 · ARA SYSTEM 앱)이 끊기는 것 · 앱 비밀번호도 모두 지워지는 것(12단계 — 모든 직원)
// 2단계 인증 상태는 들어올 때마다 새로 받는다(two-factor.store) — 켠 직원이면 머리 설명이 바뀌고 양식 맨 아래에 「인증 앱 코드」 칸이 생긴다.
// 못 받아도 그대로 쓸 수 있다 — 켰는데 코드 없이 보내면 서버가 AUTH_007 로 알려 와 양식이 상태를 다시 받고 칸을 보인다
import { computed } from 'vue'
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
import type { BlockedSender } from '@/shared/types/mail'
import { formatBlockedDay } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
@@ -247,7 +248,7 @@ describe('MailBlocksPage', () => {
})
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
const INACTIVE = MAIL_BLOCK_TEXT.inactive
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
const off = await mountPage({ isActive: false })
@@ -6,6 +6,7 @@ import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_APPLY_TEXT, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type {
Mailbox,
MailFilterRule,
@@ -246,16 +247,16 @@ describe('MailFilterPage', () => {
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').map((note) => note.text())).toEqual([
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
`·${MAIL_FILTER_TEXT.noteOrderStrong}${MAIL_FILTER_TEXT.noteOrderAfter}`,
`·${MAIL_FILTER_TEXT.noteApplyBefore}${MAIL_FILTER_TEXT.noteApplyStrong}${MAIL_FILTER_TEXT.noteApplyAfter}`,
`·${MAIL_FILTER_TEXT.noteSpam}`,
`·${MAIL_FILTER_TEXT.noteForward}`,
`·${MAIL_FILTER_TEXT.noteVacation}`,
])
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
expect(wrapper.get('.settings-page__description').text()).toBe(
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
)
// 안내 상자의 [ ]는 고치기 창의 칸 이름 그대로다 — 칸 이름을 바꾸면 함께 바뀐다
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(MAIL_FILTER_TEXT.noteApplyStrong).toBe(`[${MAIL_FILTER_APPLY_TEXT.label}]`)
expect(wrapper.get('.settings-page__description').text()).toBe(MAIL_FILTER_TEXT.description)
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)
@@ -542,7 +543,7 @@ describe('MailFilterPage', () => {
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(bar.text()).toContain(ERROR_CODE_MESSAGES.MAIL_012)
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
@@ -721,7 +722,7 @@ describe('MailFilterPage', () => {
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
const bar = wrapper.get('[data-filter-inactive]')
expect(bar.text()).toContain('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.')
expect(bar.text()).toContain(MAIL_FILTER_TEXT.inactive)
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
@@ -814,7 +815,7 @@ describe('MailFilterPage', () => {
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
describe('받은편지함에 이미 있는 메일도 정리하기 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
@@ -829,9 +830,9 @@ describe('MailFilterPage', () => {
expect(applyInput(wrapper).checked).toBe(false)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
expect(wrapper.get('[data-filter-apply] label').text()).toBe(MAIL_FILTER_APPLY_TEXT.label)
expect(applyHelp(wrapper)).toBe(HELP)
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe(MAIL_FILTER_APPLY_TEXT.spam)
// 6단계의 안내 띠는 없다
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
@@ -12,7 +12,7 @@
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useLargeFilesStore } from '@/stores/large-files.store'
@@ -198,9 +199,7 @@ describe('MailLargeFilesPage', () => {
await view.get('[data-unsent-delete]').trigger('click')
await flushPromises()
expect(view.get('#large-delete-message').text()).toBe(
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
)
expect(view.get('#large-delete-message').text()).toBe(LARGE_FILE_SETTINGS_TEXT.deleteMessage('시공도면_v4.dwg', '620 MB'))
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
await view.get('[data-delete-confirm]').trigger('click')
@@ -283,7 +282,7 @@ describe('MailLargeFilesPage', () => {
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
const { wrapper: view } = await mountPage({ failLinks: true })
expect(view.text()).toContain('메일 서비스에 연결할 수 없습니다.')
expect(view.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(useToastStore().toasts).toEqual([])
expect(useLargeFilesStore().status).toBe('error')
})
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import { useAuthStore } from '@/stores/auth.store'
import { useMailNotifyStore } from '@/stores/mail-notify.store'
import { useMailRealtimeStore } from '@/stores/mail-realtime.store'
@@ -48,15 +49,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
return wrapper.get('#notify-enabled').element as HTMLInputElement
}
it('제목 · 설명 — 새 메일은 1분을 기다리지 않고 바로 목록에 나타난다', async () => {
it('제목 · 설명 — 새 메일이 오면 바로 목록에서 볼 수 있다', 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분을 기다리지 않고 바로 목록에 나타납니다.')
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.description)
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 알림 내용 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
@@ -132,7 +133,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(document.activeElement).toBe(enableInput(wrapper))
})
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "지원하지 않습니다"로 바꾼다 — 초점은 안내 글로', async () => {
it('시험 알림을 만들 수 없는 브라우저(휴대폰 Chrome 등)면 조용히 넘어가지 않고 "쓸 수 없습니다"로 바꾼다 — 초점은 안내 글로', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -143,7 +144,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
await flushPromises()
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe('이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.')
expect(note.text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
expect(document.activeElement).toBe(note.element)
@@ -166,7 +167,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(enableInput(wrapper).checked).toBe(true)
})
it('끄기 — 체크를 풀면 꺼지고 보일 것 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
it('끄기 — 체크를 풀면 꺼지고 알림 내용 · 시험 알림이 흐려진다 (허락은 그대로)', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -182,17 +183,15 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(wrapper.get('#notify-enabled-note').text()).toBe('이 브라우저에서 알림이 허용되어 있습니다.')
})
it('지원하지 않는 브라우저 — 칸을 끄고 "지원하지 않습니다. 새 메일은 그래도 바로 목록에"', async () => {
it('지원하지 않는 브라우저 — 칸을 끄고 "쓸 수 없습니다. 새 메일은 그래도 목록에서 바로"', async () => {
vi.stubGlobal('Notification', undefined)
const wrapper = await mountPage()
expect(enableInput(wrapper).disabled).toBe(true)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
)
expect(wrapper.get('#notify-enabled-note').text()).toBe(MAIL_NOTIFY_TEXT.noteUnsupported)
})
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 보낸 사람 이름만 보입니다")', async () => {
it('메일 제목도 보이기 — 끄면 저장한다 ("끄면 알림에 보낸 사람 이름만 나옵니다")', async () => {
FakeNotification.reset('granted')
const wrapper = await mountPage()
await wrapper.get('#notify-enabled').setValue(true)
@@ -200,7 +199,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const subject = wrapper.get('#notify-show-subject')
expect((subject.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.text()).toContain('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.')
expect(wrapper.text()).toContain(MAIL_NOTIFY_TEXT.showSubjectHint)
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
@@ -213,7 +212,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().text()).toBe(MAIL_NOTIFY_TEXT.realtimeLive)
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
@@ -240,7 +239,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
MAIL_NOTIFY_TEXT.noteVisible,
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 메일 › 알림 (디자인 원본 SettingsNotify · README "5단계 › 설정 › 메일 › 알림") — /settings/mail/notify
// 위에서부터 바탕화면 알림 켜기 · 알림에 보일 것 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 위에서부터 바탕화면 알림 켜기 · 알림 내용 · 시험 알림(NotifySettingsForm) — 가로줄 — 실시간 연결(RealtimeLinkStatus) — 안내 상자(바탕 #F3F8F8):
// 이 브라우저에만 적용 · 웹메일 탭이 하나라도 열려 있어야 · 웹메일 화면을 보고 있을 때는 보내지 않음.
// 설정은 이 브라우저 · 사용자마다 저장한다(mail-notify.store). 들어올 때 · 탭으로 돌아올 때 브라우저의 허락을 다시 읽는다 — 사이트 설정에서 바꿨을 수 있다.
import { onBeforeUnmount, onMounted } from 'vue'
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_SETTINGS_TEXT } from '@/shared/constants/mail-settings'
import type { MailSettings } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
@@ -86,7 +87,7 @@ describe('MailSendReadPage', () => {
'늘 보기',
])
expect(checkedImages(wrapper)).toEqual(['trusted'])
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
expect(wrapper.get('[data-images-note]').text()).toContain(MAIL_SETTINGS_TEXT.imagesNote)
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
@@ -128,9 +129,7 @@ describe('MailSendReadPage', () => {
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
)
expect(wrapper.get('#send-read-group-hint').text()).toBe(MAIL_SETTINGS_TEXT.groupHint)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
@@ -3,6 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
@@ -111,9 +112,7 @@ describe('MailVacationPage', () => {
expect((wrapper.get('#vacation-period-range').element as HTMLInputElement).checked).toBe(true)
expect(value(wrapper, '#vacation-from')).toBe('2026-10-30')
expect(value(wrapper, '#vacation-to')).toBe('2026-11-03')
expect(wrapper.get('#vacation-period-hint').text()).toBe(
'시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
)
expect(wrapper.get('#vacation-period-hint').text()).toBe(VACATION_TEXT.rangeHint)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
expect(preview.text()).toContain('김아라 <ara.kim@arasystem.it.kr>')
@@ -2,6 +2,15 @@
// (로그인 코드 단계 LoginTwoFactor · 설정 › 계정 › 2단계 인증 SettingsTwoFactor · 앱 비밀번호 SettingsAppPasswords · 로그인 기록 SettingsLoginHistory)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 서버가 거절한 까닭의 문구는 에러 코드 사전(AUTH_006 ~ AUTH_009)에서만 온다.
import { APP_NAME } from '@/shared/constants/brand'
/**
* 우리 모바일 앱의 화면 이름 — 로그인된 기기의 앱 세션 줄 · 개수, 로그인 기록의 방식 (계약 1절 "앱 로그인").
* 서버가 앱 세션 · 앱 로그인 기록의 device.browser 로 보내는 글과 같다 — 짝: backend shared/utils/user-agent.ts 의 APP_BROWSER_NAME.
* 화면은 세션의 client · 기록의 method 가 app 일 때 이 글을 쓴다(device 의 이름으로 앱을 짐작하지 않는다)
*/
export const MOBILE_APP_NAME = `${APP_NAME} 앱`
/** 인증 앱 코드의 자릿수 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙") */
export const OTP_CODE_LENGTH = 6
@@ -30,7 +39,7 @@ export const APP_PASSWORD_NAME_MAX_LENGTH = 40
*/
export const TWO_FACTOR_KEY_GROUPS_PER_LINE = 4
/** 로그인된 브라우저를 한 번에 받는 수 — 서버 최대(100). 직원 한 사람의 웹메일 세션은 이보다 적다 */
/** 로그인된 기기를 한 번에 받는 수 — 서버 최대(100). 직원 한 사람의 웹메일 · 앱 세션은 이보다 적다 */
export const AUTH_SESSION_LIST_LIMIT = 100
/**
@@ -112,7 +121,7 @@ export const TWO_FACTOR_ENABLE_TEXT = {
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
savedAs: (email: string) => `앱에는 ‘ARA SYSTEM (${email})’로 저장됩니다.`,
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 인증 앱에 먼저 추가했다면 앱에서 그 계정을 지워 주세요.',
loading: 'QR 코드를 만드는 중…',
codeLabel: '앱에 표시된 6자리 코드',
passwordLabel: '지금 비밀번호',
@@ -190,7 +199,7 @@ export const APP_PASSWORD_RESULT_TEXT = {
message: (name: string) => `‘${name}’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
copy: '복사',
copyLabel: '앱 비밀번호 복사',
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
lengthNote: (length: string) => `${length}자입니다. 보기 편하게 4자씩 띄어 놓았지만, 실제로는 띄어쓰기 없이 넣으면 됩니다.`,
warningStrong: '이 창을 닫으면 다시 볼 수 없습니다.',
warningAfter: ' 잊어버리면 지우고 새로 만들어 주세요.',
copied: '앱 비밀번호를 복사했습니다.',
@@ -218,24 +227,33 @@ export const LOGIN_HISTORY_TEXT = {
/** 화면 틀의 description(글만) — 링크를 그릴 수 없는 곳에서 */
description:
'최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.',
sessionsTitle: '로그인된 브라우저',
sessionsCount: (count: string) => `웹메일 ${count}곳`,
/** 웹메일 세션과 ARA SYSTEM 앱 세션을 함께 보인다 (계약 1절 "앱 로그인" · 5절) */
sessionsTitle: '로그인된 기기',
/**
* 개수 — 세션의 client 로 나눠 센다(sessionCountText). 앱 세션이 없으면 "웹메일 3곳",
* 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳" — 0곳인 쪽은 쓰지 않는다
*/
sessionsCountWeb: (count: string) => `웹메일 ${count}곳`,
sessionsCountApp: (count: string) => `${MOBILE_APP_NAME} ${count}곳`,
endOthers: '다른 곳 모두 로그아웃',
columnDevice: '브라우저 · 운영체제',
/** 기기 칸 — "Edge · Windows" · "ARA SYSTEM 앱 · Android" (로그인 기록 표의 기기 칸과 같은 이름) */
columnDevice: '기기',
columnIp: 'IP',
columnLastActive: '마지막 활동',
columnKind: '기기',
/** 줄 앞 아이콘의 이름 — PC · 휴대폰 */
/** 줄 앞 아이콘 칸의 이름 — 화면 낭독기만 읽는다(아이콘 이름이 PC · 휴대폰) */
columnKind: '종류',
/** 줄 앞 아이콘의 이름 — PC · 휴대폰(앱 세션은 운영체제를 몰라도 휴대폰) */
kindPc: 'PC',
kindMobile: '휴대폰',
columnActions: '로그아웃',
/** 지금 이 웹 브라우저 자신 — 웹 화면이라 지금 세션은 늘 브라우저다 */
current: '지금 이 브라우저',
endSession: '로그아웃',
endSessionLabel: (device: string) => `‘${device}’ 로그아웃`,
unknownBrowser: '알 수 없는 브라우저',
unknownValue: '알 수 없음',
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
noOthers: '다른 곳에 로그인된 기기가 없습니다. ',
sessionsNoteBefore: '메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 ',
sessionsNoteLink: '앱 비밀번호',
sessionsNoteAfter: '를 지워 주세요.',
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
@@ -243,16 +261,18 @@ export const LOGIN_HISTORY_TEXT = {
/** 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음) — 이 브라우저까지 끊었다 */
allEnded: (count: string) => `이 브라우저를 포함해 ${count}곳에서 로그아웃했습니다.`,
eventsTitle: '최근 로그인',
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
eventsHintAll: `최근 90일 · 웹메일, ${MOBILE_APP_NAME}, 메일 프로그램`,
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
eventsHintFailuresUnknown: '최근 90일 · 실패만',
filterLabel: '보일 기록',
filterLabel: '볼 기록',
filterAll: '전체',
filterFailures: '실패만',
columnTime: '시각',
columnMethod: '방식',
columnResult: '결과',
methodWebmail: '웹메일',
/** 앱 로그인(method app) — 앱 줄의 기기 칸은 운영체제만 쓴다(이 칸이 앱 이름이라) */
methodApp: MOBILE_APP_NAME,
methodImap: '메일 프로그램 · IMAP',
methodSmtp: '메일 프로그램 · SMTP',
appPassword: (name: string) => `앱 비밀번호 ‘${name}’`,
@@ -263,26 +283,26 @@ export const LOGIN_HISTORY_TEXT = {
failureWrongCode: '코드가 틀려 실패',
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
more: '더 보기',
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
moreHint: '최근 기록부터 20개씩 볼 수 있습니다. 90일이 지난 기록은 지워집니다.',
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
emptyAllHint: '이 기능이 생긴 날부터 기록이 쌓입니다.',
} as const
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) */
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) — 웹메일과 ARA SYSTEM 앱 세션을 함께 끊는다 */
export const END_OTHER_SESSIONS_TEXT = {
title: '다른 곳에서 모두 로그아웃할까요?',
/** "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
/** "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
message: (count: string, names: string) =>
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
`이 브라우저를 뺀 ${count}곳(${names})에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
/**
* 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음 — 서버는 이 요청의 세션을 몰라 모두 끊는다, 계약 5절) —
* 이 브라우저도 로그아웃된다고 알린다
*/
titleAll: '이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
messageAll: (count: string, names: string) =>
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
moreNames: (count: string) => `외 ${count}곳`,
note: '메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
confirm: '모두 로그아웃',
+2 -1
View File
@@ -31,7 +31,8 @@ export const PASSWORD_TEXT = {
codeHint: '코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.',
submit: '비밀번호 바꾸기',
done: '비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.',
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
/** 서버는 이 요청의 세션만 남기고 그 직원의 웹메일 · ARA SYSTEM 앱 세션을 모두 끊는다 (계약 1절 "앱 로그인" · 4단계) */
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저에서 모두 로그아웃됩니다.',
/** 모든 직원 (12단계) — 비밀번호를 바꾸면 서버가 그 직원의 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") */
noteAppPasswords: '앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
} as const
+10 -8
View File
@@ -2,6 +2,7 @@
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
import { directionParticle } from '@/shared/utils/korean'
/**
* 주소록 라우트 이름 — 라우트 표(router/routes.ts) · 레일(app-modules.ts) · 화면 안의 링크 · 읽기 화면 ⋯ 「주소록에서 보기」가 함께 쓴다.
@@ -126,7 +127,7 @@ export const CONTACTS_LIST_TEXT = {
count: (count: string, shared: boolean) => `${count}${shared ? '개' : '명'}`,
found: (count: string, shared: boolean) => `찾은 결과 ${count}${shared ? '개' : '명'}`,
directoryNote: {
person: '회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.',
person: '회사 메일을 쓰는 직원입니다. 사내 목록에는 이름과 메일 주소만 있습니다.',
shared: '여러 사람이 함께 받는 사내 주소입니다.',
} satisfies Record<DirectoryKind, string>,
addMembers: '구성원 넣기',
@@ -141,7 +142,8 @@ export const CONTACTS_LIST_TEXT = {
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
export const CONTACTS_EMPTY_TEXT = {
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchTitle: (query: string, directory: boolean) =>
`‘${query}’${directionParticle(query)} 찾은 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchHint: '이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.',
directorySearchHint: '이름이나 주소의 일부, 이름의 초성으로 찾을 수 있습니다.',
firstTitle: '아직 연락처가 없습니다',
@@ -164,7 +166,7 @@ export const CONTACT_DETAIL_TEXT = {
more: '더 보기',
menu: '연락처 메뉴',
/** 상세 머리 줄(단추 묶음)의 이름 */
actions: '연락처 동작',
actions: '이 연락처로 할 일',
addToMine: '내 주소록에 추가',
noOrg: '소속 없음',
noName: '(이름 없음)',
@@ -183,9 +185,9 @@ export const CONTACT_DETAIL_TEXT = {
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
directoryHint:
'사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
noneTitle: '연락처를 골라 주세요',
noneHint: '목록에서 고르면 주소와 전화번호가 여기에 보입니다.',
'사내 목록에는 이름과 주소만 있고, 여기서는 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
noneTitle: '고른 연락처가 없습니다',
noneHint: '자세히 볼 연락처를 목록에서 골라 주세요.',
leaveToList: '목록으로',
loading: '연락처 불러오는 중',
} as const
@@ -274,7 +276,7 @@ export const CONTACT_MEMBERS_TEXT = {
staff: '사내 직원',
search: '구성원으로 넣을 연락처 찾기',
searchPlaceholder: '이름, 주소, 초성으로 찾기',
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. [사내 직원]에서 고르면 그 직원의 이름과 주소를 내 주소록에도 추가합니다.',
note: '이 그룹에 이미 있는 사람은 목록에 없습니다. [사내 직원]에서 고른 사람은 이름과 주소가 내 주소록에도 함께 들어갑니다.',
picked: (count: string) => `${count}명 고름`,
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
add: '넣기',
@@ -283,7 +285,7 @@ export const CONTACT_MEMBERS_TEXT = {
loading: '불러오는 중',
emptyMine: '넣을 수 있는 연락처가 없습니다',
emptyStaff: '넣을 수 있는 직원이 없습니다',
searchEmpty: (query: string) => `‘${query}’에 맞는 사람이 없습니다`,
searchEmpty: (query: string) => `‘${query}’${directionParticle(query)} 찾은 사람이 없습니다`,
} as const
/** 알림 (디자인 원본 ContactsEdit "알림" — 단추가 있으면 10초) */
@@ -1,35 +1,35 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
export const ERROR_CODE_MESSAGES = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.',
SYS_001: '문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
REQ_001: '너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
AUTH_001: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
AUTH_002: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
AUTH_003: '이 기능을 쓸 수 있는 권한이 없습니다.',
AUTH_004: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
AUTH_005: '지금 비밀번호가 올바르지 않습니다.',
AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 자동으로 맞춰지도록 설정되어 있는지 확인해 주세요.',
AUTH_006: '코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
AUTH_007: '인증 앱의 코드를 입력해 주세요.',
AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
VAL_001: '입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
FILE_003: '파일 상태가 그사이 바뀌어 처리하지 못했습니다. 다시 시도해 주세요.',
FILE_004: '파일을 올리는 중에 일부가 제대로 전달되지 않았습니다. 다시 시도해 주세요.',
RES_001: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
BIZ_001: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
FILE_001: '쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
FILE_002: '파일이 너무 커서 올릴 수 없습니다.',
FILE_003: '그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
FILE_004: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
FILE_005: '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
FILE_006: '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
FILE_006: '회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
FILE_007: '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
FILE_008: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
MAIL_001: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
MAIL_002: '받는 사람 주소를 다시 확인해 주세요.',
MAIL_003: '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
MAIL_004: '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_003: '메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
MAIL_004: '첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_005: '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
MAIL_006: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
MAIL_006: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
MAIL_007: '본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
MAIL_008: '임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
MAIL_009: '같은 이름의 메일함이 이미 있습니다.',
@@ -39,7 +39,7 @@ export const ERROR_CODE_MESSAGES = {
MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.',
MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
MAIL_016: '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
MAIL_016: '보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
} as const
+2 -2
View File
@@ -319,9 +319,9 @@ export const ICONS = {
],
/** 설정 메뉴의 로그인 기록 (되감는 시계) */
history: [path('M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'), path('M3 3v5h5'), path('M12 7v5l4 2')],
/** 로그인된 브라우저 — PC */
/** 로그인된 기기 — PC */
monitor: [rect(2, 3, 20, 14, 2), line(8, 21, 16, 21), line(12, 17, 12, 21)],
/** 로그인된 브라우저 — 휴대폰 */
/** 로그인된 기기 — 휴대폰 (휴대폰 브라우저 · ARA SYSTEM 앱) */
smartphone: [rect(5, 2, 14, 20, 2), line(12, 18, 12.01, 18)],
/** 설정 메뉴의 메일 프로그램 연결 — PC · 휴대폰 (13단계, lucide monitor-smartphone) */
'monitor-smartphone': [
+5 -5
View File
@@ -176,7 +176,7 @@ export const MESSAGE_LARGE_FILE_TEXT = {
downloads: (count: string) => `${count}회 받음`,
notDownloaded: '아직 받지 않음',
scanning: '보안 검사가 끝나면 받을 수 있습니다',
infected: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
infected: '보안 검사에서 위험한 내용이 나와 받을 수 없습니다',
unscannable: '암호가 걸린 압축 파일처럼 검사하지 못한 파일',
expired: (date: string) => `받을 수 있는 기간이 ${date}에 끝났습니다`,
revoked: '보낸 사람이 링크를 끊어 받을 수 없습니다',
@@ -225,7 +225,7 @@ export const LARGE_FILE_ALERT_TEXT = {
export const LARGE_FILE_SETTINGS_TEXT = {
title: '대용량 첨부',
description: (days: string) =>
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있고, 링크를 끊으면 그때부터 받을 수 없습니다.`,
usageLabel: '보관 사용량',
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent}, 임시 저장했거나 올리다 멈춘 파일 ${unsent}입니다.`,
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
@@ -244,7 +244,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
deleteLabel: (name: string) => `${name} 지우기`,
deleteTitle: '보내지 않은 파일을 지울까요?',
deleteMessage: (name: string, size: string) =>
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.`,
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 넣어 둔 임시 저장본이 있으면 그 메일에서 파일을 빼고 다시 올려야 합니다. 지운 파일은 되돌릴 수 없습니다.`,
deleteConfirm: '지우기',
deleteCancel: '취소',
deleted: (name: string) => `‘${name}’ 파일을 지웠습니다.`,
@@ -272,7 +272,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
notDownloaded: '아직 받지 않음',
lastDownloaded: (when: string) => `마지막 ${when}`,
more: '더 보기',
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
moreHint: '최근에 보낸 것부터 20개씩 볼 수 있습니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
/** max 는 늘 "20MB" 꼴(formatMegabytes — 받침이 없다)이라 조사를 '가'로 붙여 쓴다 */
emptyHint: (max: string, days: string) =>
@@ -326,7 +326,7 @@ export const SHARE_PAGE_TEXT = {
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
revokedEnglish: 'The sender has stopped sharing these files.',
notFoundTitle: '찾을 수 없는 링크입니다',
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
notFoundMessage: '메일에 있던 주소를 끝까지 다 복사했는지 확인해 주세요.',
notFoundEnglish: 'Link not found. Please check that the full address was copied.',
tooManyTitle: '요청이 너무 많습니다',
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
+1 -1
View File
@@ -108,5 +108,5 @@ export const MAIL_BLOCK_TEXT = {
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
*/
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 꺼져 있습니다. 주소를 차단하거나 풀면 차단이 다시 켜집니다.',
} as const
@@ -35,10 +35,10 @@ export const MAIL_CLIENTS_TEXT = {
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
createAppPassword: '앱 비밀번호 만들기',
stepsTitle: '프로그램별 연결 방법',
stepsHint: '쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다.',
stepsHint: '쓰는 프로그램을 누르면 연결 방법을 볼 수 있습니다.',
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
cloudWarning:
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 되도록 다른 메일 프로그램을 써 주세요.',
listLabel: '메일 프로그램',
notes: [
'연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요.',
@@ -114,7 +114,7 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
{
id: 'android',
name: '안드로이드',
description: 'Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다',
description: 'Gmail 앱으로 설명하며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣으면 됩니다',
icon: 'smartphone',
steps: [
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
+11 -11
View File
@@ -84,7 +84,7 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
export const MAIL_FILTER_TEXT = {
title: '필터',
description:
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
'새로 오는 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리할 수 있습니다.',
create: '새 필터',
/**
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
@@ -105,17 +105,17 @@ export const MAIL_FILTER_TEXT = {
'보낸 사람, 받는 사람, 제목, 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다.',
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
reload: '다시 불러오기',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 꺼져 있습니다.',
reactivate: '다시 켜기',
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
noteOrderStrong: '위에 있는 필터부터 차례로',
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 걸린 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼로 바꿀 수 있습니다.',
noteApplyBefore: '이미 받은 메일도 정리하려면 필터를 저장할 때 ',
noteApplyStrong: '[받은편지함에 이미 있는 메일에도 적용]',
noteApplyAfter: '을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
noteSpam: '스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
noteApplyStrong: '[받은편지함에 이미 있는 메일도 정리하기]',
noteApplyAfter: '를 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
noteSpam: '스팸 메일은 필터를 거치지 않고 바로 스팸메일함으로 갑니다.',
noteForward:
'전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'전달은 새로 오는 메일만 사내 주소로 보냅니다. 메일 한 통은 한 곳으로만 전달할 수 있어서, 전달하는 필터가 여럿이면 가장 위에 있는 필터의 주소로 갑니다. 전달한 메일도 내 메일함에 그대로 남습니다.',
noteVacation: '필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
saved: '필터를 저장했습니다.',
@@ -167,12 +167,12 @@ export const MAIL_FILTER_DIALOG_TEXT = {
} as const
/**
* 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
* 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
*/
export const MAIL_FILTER_APPLY_TEXT = {
label: '받은편지함에 이미 있는 메일에도 적용',
label: '받은편지함에 이미 있는 메일도 정리하기',
help: '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.',
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
@@ -183,7 +183,7 @@ export const MAIL_FILTER_APPLY_TEXT = {
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
/** 상자 아래 한 줄 */
spam: '스팸으로 분류된 메일은 필터를 거치지 않습니다.',
spam: '스팸 메일은 필터를 거치지 않습니다.',
} as const
/**
@@ -196,7 +196,7 @@ export const MAIL_FILTER_RUN_TEXT = {
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
done: (quoted: string, matched: string) => `${quoted} 필터로 받은편지함의 메일 ${matched}통을 정리했습니다.`,
/** 맞는 메일이 없었다 */
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
none: (quoted: string) => `받은편지함에는 ${quoted} 필터에 걸리는 메일이 없습니다.`,
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
+7 -7
View File
@@ -53,20 +53,20 @@ export const MAIL_NOTIFY_TEXT = {
menu: '알림',
title: '알림',
description:
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
'새 메일이 오면 바로 목록에서 볼 수 있습니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
enable: '새 메일이 오면 바탕화면 알림 보내기',
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
noteBlocked:
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
contentLegend: '알림에 보일 것',
noteUnsupported: '이 브라우저에서는 바탕화면 알림을 쓸 수 없습니다. 새 메일은 그래도 목록에서 바로 볼 수 있습니다.',
contentLegend: '알림 내용',
showSubject: '메일 제목도 보이기',
showSubjectHint: '끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.',
showSubjectHint: '끄면 알림에 보낸 사람 이름만 나옵니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두시기를 권합니다.',
test: '시험 알림 보내기',
testHint: '바탕화면 오른쪽 아래에 알림이 뜨면 됩니다. 맥에서는 오른쪽 위에 뜹니다.',
realtimeTitle: '실시간 연결',
realtimeLive: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
realtimeLive: '연결되어 있어 새 메일이 오면 바로 알 수 있습니다.',
realtimeLiveHint:
'연결이 끊기면 1분마다 새 메일을 확인하면서 저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
@@ -76,13 +76,13 @@ export const MAIL_NOTIFY_TEXT = {
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeConnecting: '연결하는 중입니다…',
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeUnsupportedHint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
realtimeUnsupportedHint: '이 브라우저에서는 실시간 연결을 쓸 수 없습니다.',
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
noteBrowserBefore: '알림 설정은 ',
noteBrowserStrong: '이 브라우저에만',
noteBrowserAfter: ' 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 목록에서 새 메일을 바로 볼 수 있어 바탕화면 알림을 보내지 않습니다.',
// 시험 알림 — 지금 탭에서 바로 보낸다
testTitle: 'ARA 웹메일',
+4 -4
View File
@@ -86,7 +86,7 @@ export const SEARCH_CONDITIONS_TEXT = {
since: '시작 날짜',
until: '끝 날짜',
attachmentOnly: '첨부 파일이 있는 메일만',
hint: '검색 칸의 말과 이 조건에 모두 맞는 메일을 찾습니다. 받는 사람에는 참조로 받은 사람도 들어갑니다.',
hint: '검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.',
periodOrder: '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.',
periodRange: '날짜는 1970년 1월 1일부터 9999년 12월 30일 사이에서 골라 주세요.',
clear: '조건 지우기',
@@ -121,17 +121,17 @@ export const SEARCH_NOTICE_TEXT = {
* 낮 내내 같은 글이면 멈춘 줄 안다 (2026-11-06 실사용 시험)
*/
indexing:
'지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.',
'지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다.',
/** 한 글자 낱말뿐이라 본문은 찾지 않았을 때 (서버와 같은 낱말 나누기 — search-match.ts) */
singleChar:
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.',
/** 이어 찾을 것이 남았는데 아직 하나도 찾지 못했을 때 */
partialTitle: '아직 맞는 메일을 찾지 못했습니다',
partialTitle: '아직 찾은 메일이 없습니다',
partialHint: '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.',
more: '더 찾기',
moreBusy: '찾는 중…',
/** 검색어와 조건을 합친 낱말이 한도를 넘는 주소로 들어왔을 때 — 찾지 않고 이 안내만 (조건이 있으면 조건 줄의 [조건 지우기]) */
tooManyWordsTitle: '찾을 말이 너무 많습니다',
tooManyWordsHint: (max: number) =>
`검색어와 조건에 넣은 낱말은 모두 합쳐 ${max}개까지 찾을 수 있습니다. 검색 칸이나 [조건]에서 낱말을 줄여 주세요.`,
`검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${max}개까지입니다. 낱말을 몇 개 줄여 주세요.`,
} as const
@@ -34,12 +34,12 @@ export const MAIL_SETTINGS_TEXT = {
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
trusted: {
label: '주소록과 사내 직원의 메일은 늘 보기',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 묻지 않고 보여 드립니다',
},
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
'외부 이미지는 회사 쪽에서 대신 받아 오기 때문에 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
/**
* 대화로 묶기 (14단계 — 디자인 README "목록 — 대화로 묶어 보기 (ListThreads14)" 의 켬/끔 한 줄. 보드가 없어 글은 정했다).
@@ -50,5 +50,5 @@ export const MAIL_SETTINGS_TEXT = {
groupOff: '끔',
groupOn: '켬',
groupHint:
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
'켜면 답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶습니다. 메일함 목록 위의 말풍선 모양 [대화로 묶기] 단추로도 켜고 끌 수 있습니다.',
} as const
@@ -56,11 +56,11 @@ export const SIGNATURE_LEAVE_DIALOG = {
export const SIGNATURE_TEXT = {
title: '서명',
description:
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
'보내는 메일 끝에 넣는 글입니다. 메일을 쓰기 시작할 때 본문에 미리 넣어 두니, 그 메일에서만 고치거나 지울 수도 있습니다.',
fieldLabel: '서명 내용',
hint:
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내서 받는 사람 쪽에서 외부 이미지처럼 막히지 않습니다.',
usageLegend: '서명을 넣을 메일',
forNew: '새 메일',
forReply: '답장과 전달',
@@ -17,14 +17,14 @@ export const VACATION_NOTICE_REFRESH_MS = 60_000
export const VACATION_TEXT = {
title: '부재중 자동 응답',
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽의 메일함 목록 아래에 안내가 보입니다.',
description: '자리를 비운 동안 받은 메일에 정해 둔 글로 자동으로 답합니다. 켜 둔 동안에는 메일 화면 왼쪽, 메일함 목록 아래에서 켜져 있는지 확인할 수 있습니다.',
enable: '부재중 자동 응답 사용',
periodLegend: '기간',
untilOff: '끌 때까지 계속',
range: '기간 정하기',
from: '시작',
to: '끝',
rangeHint: '시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
rangeHint: '시작하는 날 0시부터 끝나는 날 밤 12시까지 답합니다. 시각은 이 PC의 시간으로 셉니다.',
subject: '제목',
body: '내용',
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
+28 -15
View File
@@ -3,6 +3,7 @@
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import type { IconName } from '@/shared/constants/icons'
import type { MailboxRole, RejectedRecipientReason } from '@/shared/types/mail'
import { subjectParticle } from '@/shared/utils/korean'
/**
* 메일함 정렬 순서 — 계약 2절 "메일함 정렬(확정 디자인 기준)".
@@ -33,14 +34,15 @@ export const MAILBOX_ICONS: Readonly<Record<MailboxKind, IconName>> = {
/** 빈 메일함 안내 (디자인 원본 States · Main) */
export const MAILBOX_EMPTY_HINTS: Readonly<Record<MailboxKind, string>> = {
inbox: '새 메일이 오면 여기에 표시됩니다.',
drafts: '임시 저장한 메일이 여기에 표시됩니다.',
sent: '보낸 메일이 여기에 표시됩니다.',
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글에 붙인다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
junk: '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
trash: '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.',
archive: '보관한 메일이 여기에 표시됩니다.',
custom: '이 메일함으로 옮긴 메일이 여기에 표시됩니다.',
inbox: '새 메일이 오면 여기로 들어옵니다.',
drafts: '쓰다가 임시 저장한 메일은 여기에 남습니다.',
sent: '보낸 메일은 여기에 남습니다.',
// 휴지통 · 스팸메일함은 메일 서버가 30일 뒤 지운다 — 메일이 없을 때는 목록 위 줄 대신 이 글이 30일을 알린다 (13단계 — 디자인 README "휴지통 · 스팸메일함 — 30일 줄")
junk: '스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
trash: '지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.',
archive: '오래 두고 볼 메일을 여기로 옮겨 보관할 수 있습니다.',
// 빈 메일함에서 보는 글이라 어디서 고르는지를 밝힌다 — [이동]은 고른 메일 도구 줄의 단추(MAIL_BULK_TEXT.move)
custom: '다른 메일함에서 메일을 고른 뒤 [이동]을 누르면 이 메일함으로 옮길 수 있습니다.',
}
/**
@@ -242,7 +244,8 @@ export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
export const MAIL_NO_SUBJECT = '(제목 없음)'
/**
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)이 쓴다.
* 목록 줄의 화면 낭독 · 아이콘 이름 — 대화 줄(14단계 — ConversationListItem · ConversationTableRow)과
* 같은 대화 칸의 줄(ConversationMessageRow — unreadPrefix, 2026-11-07)이 쓴다.
* 메일 줄(MessageListItem · MessageTableRow)도 같은 글이다 — 고칠 때는 함께
*/
export const MAIL_LIST_ROW_TEXT = {
@@ -290,7 +293,8 @@ export const MAIL_SEARCH_TEXT = {
scopeLabel: '찾을 곳',
scopeAll: '전체 메일함',
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
emptyTitle: (query: string) => (query ? `‘${query}’에 맞는 메일이 없습니다` : '조건에 맞는 메일이 없습니다'),
emptyTitle: (query: string) =>
query ? `‘${query}’${subjectParticle(query)} 들어간 메일이 없습니다` : '조건에 맞는 메일이 없습니다',
/**
* 결과 없음의 도움말 (14단계 디자인 원본 Search14) — 검색어와 조건으로 찾았으면 emptyHint, 조건만으로 찾았으면 emptyConditionsHint,
* 검색어만으로 찾았으면(걸린 조건이 없다) emptyWordsHint — 없는 조건을 줄이라고 하지 않는다 (2026-11-06 실사용 시험)
@@ -391,19 +395,28 @@ export const MAIL_IMAGES_SHOW = 'show'
export const MAIL_IMAGE_PROXY_PATH = '/api/mail-images/'
/** 읽기 화면의 노란 안내 줄 + [이미지 보기](36 — 밀도 예외 --size-notice-action) */
export const MAIL_IMAGES_TEXT = {
blocked: '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.',
blocked: '외부 이미지는 막아 두었습니다. 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.',
show: '이미지 보기',
} as const
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
/**
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내의 앞 문장 — "외부 이미지는 막아 두었습니다"와 같은 모양.
* - htmlOmitted · unavailable: 서식 본문을 정화 한도 때문에 비웠다(isHtmlBodyOmitted) — 글자 본문이 있을 때 · 없을 때
* - truncated: 보여 주는 본문이 읽기 한도(2MB)에서 잘렸다(isBodyTruncated)
* 읽기 화면은 뒤에 할 일(MAIL_BODY_NOTICE_ACTIONS)을 한 칸 띄워 잇고, 같은 대화 칸의 펼친 줄은 이 앞 문장만 보인다 (mail-body-notices.ts)
*/
export const MAIL_BODY_NOTICES = {
htmlOmitted: '본문이 복잡해 서식 없이 글자만 표시합니다.',
unavailable: '본문이 너무 크거나 복잡해 표시할 수 없습니다.',
truncated: '본문이 길어 앞부분만 표시합니다.',
htmlOmitted: '본문이 복잡해 서식 없이 글자만 보여 드립니다.',
unavailable: '본문이 너무 크거나 복잡해 여기서는 열 수 없습니다.',
truncated: '본문이 길어 앞부분만 보여 드립니다.',
} as const
export type MailBodyNoticeKind = keyof typeof MAIL_BODY_NOTICES
/**
* 본문 안내 뒤의 할 일 — 읽기 화면에서만. 읽기 화면 ⋯ 메뉴(MAIL_READER_MORE_TEXT)의 원문(.eml)에는 본문 전체가 있다.
* 같은 대화 칸의 펼친 줄에는 붙이지 않는다 — 그 화면의 ⋯ 는 위에 연 메일의 것이라, 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
*/
export const MAIL_BODY_NOTICE_ACTIONS = {
unavailable: '[⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
truncated: '전체는 [⋯] → [원문 내려받기 (.eml)]로 받아 볼 수 있습니다.',
} as const satisfies Partial<Record<MailBodyNoticeKind, string>>
@@ -81,5 +81,5 @@ export const MAILBOX_OPERATION_TEXT = {
deleteDoneMoved: (mailbox: string, count: string) =>
`‘${mailbox}’ 메일함을 지웠습니다. 안의 메일 ${count}통은 휴지통에 있습니다.`,
/** 도중에 실패 — 거기까지 처리한 수. 다시 하면 남은 것만 처리한다 */
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 처리하고 멈췄습니다. 다시 시도해 주세요.`,
failed: (done: string, total: string) => `${total}통 가운데 ${done}통까지 마치고 멈췄습니다. 다시 시도해 주세요.`,
} as const
@@ -43,7 +43,7 @@ export const LOGIN_PASSWORD_CHANGE_TEXT = {
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
title: '2단계 인증을 켜 주세요',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다.',
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
scanHint:
'인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.',
@@ -67,7 +67,7 @@ export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
export const SUPPORT_CONTACT_TEXT = {
/** 로그인 첫 화면 */
passwordQuestion: '비밀번호를 잊으셨나요?',
passwordFallback: '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.',
passwordFallback: '비밀번호를 잊으셨다면 관리자에게 문의해 주세요.',
/** 로그인 코드 단계 · 2단계 인증 켜기 */
authenticatorQuestion: '인증 앱을 쓸 수 없나요?',
authenticatorFallback: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
+19 -6
View File
@@ -1,5 +1,6 @@
// 계정 보안 API 공유 타입 — docs/mail/api-contract.md 5절 "계정 보안 — 12단계" 와 같은 구조
// (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록). 로그인 · 비밀번호 바꾸기에 더한 칸은 shared/types/auth.ts
// (2단계 인증 · 앱 비밀번호 · 로그인된 기기 · 로그인 기록). 로그인 · 비밀번호 바꾸기에 더한 칸은 shared/types/auth.ts
// 앱 세션 · 앱 로그인 기록(client · method 가 app)은 계약 1절 "앱 로그인"
/** GET /two-factor · 켜기 · 끄기의 응답 — 엔진이 기준이다 */
export interface TwoFactorStatus {
@@ -60,13 +61,22 @@ export interface CreatedAppPassword extends AppPassword {
password: string
}
/** 서버가 User-Agent 를 짧은 이름으로 바꾼 것 — 모르면 null */
/**
* 서버가 User-Agent 를 짧은 이름으로 바꾼 것 — 모르면 null.
* 앱 세션 · 앱 로그인 기록은 browser 가 "ARA SYSTEM 앱"이고 os 는 Android · iOS 또는 null 이다
*/
export interface LoginDevice {
browser: string | null
os: string | null
}
/** GET /auth/sessions 의 한 줄 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 */
/**
* 세션을 연 곳 — web(웹메일 · 브라우저) · app(ARA SYSTEM 앱). 앱 1단계 전에 연 세션은 web.
* 앱인지는 이 칸으로만 가른다 — device 의 이름(User-Agent)으로 짐작하지 않는다
*/
export type SessionClient = 'web' | 'app'
/** GET /auth/sessions 의 한 줄 (웹메일 · 앱 세션) — 지금 세션이 맨 앞, 그다음 최근 활동 차례 */
export interface AuthSession {
id: string
createdAt: string
@@ -75,6 +85,8 @@ export interface AuthSession {
/** 12단계 전에 연 세션은 null */
ip: string | null
device: LoginDevice | null
client: SessionClient
/** 이 요청의 세션인가 — 웹이 부르면 지금 이 브라우저 */
isCurrent: boolean
}
@@ -83,7 +95,8 @@ export interface EndedSessions {
ended: number
}
export type LoginMethod = 'webmail' | MailClientProtocol
/** 로그인 방식 — 웹메일 · ARA SYSTEM 앱(계약 1절 "앱 로그인") · 메일 프로그램(IMAP · SMTP) */
export type LoginMethod = 'webmail' | 'app' | MailClientProtocol
/** 실패한 까닭 — wrongPassword(웹메일에 넣은 앱 비밀번호 포함) · wrongCode(다시 쓴 코드 포함) · appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로) */
export type LoginFailureReason = 'wrongPassword' | 'wrongCode' | 'appPasswordRequired'
@@ -94,11 +107,11 @@ export interface LoginEvent {
occurredAt: string
method: LoginMethod
isSuccess: boolean
/** 웹메일 성공이 코드까지 거쳤는지 */
/** 웹메일 · 앱 성공이 코드까지 거쳤는지 */
isTwoFactor: boolean
failureReason: LoginFailureReason | null
ip: string | null
/** 웹메일만 — 메일 프로그램은 null */
/** 웹메일 · 앱만 — 메일 프로그램은 null */
device: LoginDevice | null
/** 메일 프로그램이 앱 비밀번호로 들어온 경우 — name 은 그때의 이름(지운 뒤에도 남는다) */
appPassword: { id: string; name: string } | null
@@ -12,18 +12,25 @@ import {
groupCharacters,
isCompleteOtp,
isMobileDevice,
isMobileSession,
loginAppPasswordLabel,
loginDeviceLabel,
loginMethodLabel,
loginResultLabel,
normalizeOtpInput,
otherSessions,
sessionCounts,
sessionCountText,
sessionDeviceLabel,
twoFactorStateLabel,
} from './account-security'
// 계정 보안 화면의 순수 함수 (계약 5절 · 디자인 README "12단계 — 계정 보안")
// 앱 세션 · 앱 로그인 기록(계약 1절 "앱 로그인")은 세션의 client · 기록의 method 로만 가른다
const NOW = Date.parse('2026-10-31T03:00:00Z')
/** 서버가 앱 세션 · 앱 로그인 기록에 싣는 기기 (backend user-agent.ts 의 describeAppUserAgent) */
const APP_ANDROID = { browser: 'ARA SYSTEM 앱', os: 'Android' }
function event(changes: Partial<LoginEvent>): LoginEvent {
return {
@@ -47,11 +54,16 @@ function session(changes: Partial<AuthSession>): AuthSession {
lastActiveAt: '2026-10-31T00:05:00Z',
ip: '211.186.95.252',
device: { browser: 'Edge', os: 'Windows' },
client: 'web',
isCurrent: false,
...changes,
}
}
function appSession(changes: Partial<AuthSession>): AuthSession {
return session({ device: APP_ANDROID, client: 'app', ...changes })
}
describe('인증 앱 코드 칸', () => {
it('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈 · 전각 숫자', () => {
expect(normalizeOtpInput('123 456')).toBe('123456')
@@ -108,6 +120,54 @@ describe('기기 이름', () => {
})
})
describe('로그인된 기기 — 웹메일 · ARA SYSTEM 앱 세션', () => {
it('줄 이름 — 웹 세션은 "브라우저 · 운영체제", 앱 세션은 "ARA SYSTEM 앱 · Android"(운영체제를 모르면 앱 이름만)', () => {
expect(sessionDeviceLabel(session({}))).toBe('Edge · Windows')
expect(sessionDeviceLabel(session({ device: null }))).toBe('알 수 없는 브라우저')
expect(sessionDeviceLabel(appSession({}))).toBe('ARA SYSTEM 앱 · Android')
expect(sessionDeviceLabel(appSession({ device: { browser: 'ARA SYSTEM 앱', os: 'iOS' } }))).toBe('ARA SYSTEM 앱 · iOS')
expect(sessionDeviceLabel(appSession({ device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBe('ARA SYSTEM 앱')
// 앱 이름은 client 로 붙인다 — 서버의 기기 이름이 비어 있어도 "알 수 없는 브라우저"가 아니다
expect(sessionDeviceLabel(appSession({ device: null }))).toBe('ARA SYSTEM 앱')
expect(sessionDeviceLabel(appSession({ device: { browser: null, os: 'Android' } }))).toBe('ARA SYSTEM 앱 · Android')
})
it('웹 세션이 앱 꼴 User-Agent 를 보냈어도 앱으로 보지 않는다 — 서버는 브라우저 null 로 준다', () => {
const spoofed = session({ device: { browser: null, os: 'Android' } })
expect(sessionDeviceLabel(spoofed)).toBe('알 수 없는 브라우저 · Android')
expect(sessionCounts([spoofed])).toEqual({ web: 1, app: 0 })
})
it('아이콘 — 앱 세션은 운영체제를 몰라도 휴대폰, 웹 세션은 운영체제로(모르면 PC)', () => {
expect(isMobileSession(appSession({}))).toBe(true)
expect(isMobileSession(appSession({ device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBe(true)
expect(isMobileSession(appSession({ device: null }))).toBe(true)
expect(isMobileSession(session({ device: { browser: 'Safari', os: 'iOS' } }))).toBe(true)
expect(isMobileSession(session({}))).toBe(false)
expect(isMobileSession(session({ device: null }))).toBe(false)
})
it('개수는 client 로 센다 — 기기 이름이 앱 이름이어도 web 세션은 웹메일 쪽', () => {
const sessions = [
session({ id: 'now', isCurrent: true }),
appSession({ id: 'phone' }),
session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } }),
// 이름만 보고 짐작하지 않는다
session({ id: 'c', device: { browser: 'ARA SYSTEM 앱', os: 'Android' } }),
appSession({ id: 'tablet', device: null }),
]
expect(sessionCounts(sessions)).toEqual({ web: 3, app: 2 })
expect(sessionCountText(sessions)).toBe('웹메일 3곳 · ARA SYSTEM 앱 2곳')
})
it('개수 글 — 앱 세션이 없으면 "웹메일 3곳", 0곳인 쪽은 쓰지 않는다', () => {
expect(sessionCountText([session({ id: 'a' }), session({ id: 'b' }), session({ id: 'c' })])).toBe('웹메일 3곳')
expect(sessionCountText([session({ id: 'a' }), appSession({ id: 'b' })])).toBe('웹메일 1곳 · ARA SYSTEM 앱 1곳')
expect(sessionCountText([appSession({ id: 'b' })])).toBe('ARA SYSTEM 앱 1곳')
expect(sessionCountText([])).toBe('웹메일 0곳')
})
})
describe('2단계 인증 상태 글', () => {
it('꺼짐 · 켜짐 — 웹메일에서 켠 때가 있으면 "2026년 10월 31일부터"', () => {
expect(twoFactorStateLabel({ isEnabled: false, enabledAt: null })).toBe('꺼짐')
@@ -146,8 +206,9 @@ describe('앱 비밀번호 이름', () => {
})
describe('로그인 기록', () => {
it('방식 — 웹메일 · 메일 프로그램 · IMAP · 메일 프로그램 · SMTP', () => {
it('방식 — 웹메일 · ARA SYSTEM 앱 · 메일 프로그램 · IMAP · 메일 프로그램 · SMTP', () => {
expect(loginMethodLabel('webmail')).toBe('웹메일')
expect(loginMethodLabel('app')).toBe('ARA SYSTEM 앱')
expect(loginMethodLabel('imap')).toBe('메일 프로그램 · IMAP')
expect(loginMethodLabel('smtp')).toBe('메일 프로그램 · SMTP')
})
@@ -165,6 +226,23 @@ describe('로그인 기록', () => {
expect(loginDeviceLabel(event({ method: 'smtp', device: null }))).toBeNull()
})
it('앱 줄의 기기 — 운영체제만(방식 칸이 앱 이름이다), 운영체제를 모르면 null', () => {
expect(loginDeviceLabel(event({ method: 'app', device: APP_ANDROID }))).toBe('Android')
expect(loginDeviceLabel(event({ method: 'app', device: { browser: 'ARA SYSTEM 앱', os: 'iOS' } }))).toBe('iOS')
expect(loginDeviceLabel(event({ method: 'app', device: { browser: 'ARA SYSTEM 앱', os: null } }))).toBeNull()
expect(loginDeviceLabel(event({ method: 'app', device: null }))).toBeNull()
})
it('앱 로그인의 결과 글도 웹메일과 같다 — 코드까지 거치면 「2단계 인증으로 성공」', () => {
expect(loginResultLabel(event({ method: 'app', device: APP_ANDROID, isTwoFactor: true }))).toEqual({
label: '2단계 인증으로 성공',
isFailure: false,
})
expect(
loginResultLabel(event({ method: 'app', device: APP_ANDROID, isSuccess: false, failureReason: 'wrongCode' })),
).toEqual({ label: '코드가 틀려 실패', isFailure: true })
})
it('결과 글 다섯 가지 — 실패는 isFailure', () => {
expect(loginResultLabel(event({}))).toEqual({ label: '성공', isFailure: false })
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '2단계 인증으로 성공', isFailure: false })
@@ -186,34 +264,46 @@ describe('로그인 기록', () => {
})
describe('다른 곳 모두 로그아웃', () => {
it('지금 이 브라우저를 뺀 세션 · 확인 창 글은 이름을 세 곳까지, 넘으면 "외 n곳"', () => {
it('지금 이 브라우저를 뺀 세션(앱 세션 포함) · 확인 창 글은 이름을 세 곳까지, 넘으면 "외 n곳"', () => {
const sessions = [
session({ id: 'now', isCurrent: true, device: { browser: 'Chrome', os: 'Windows' } }),
session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }),
session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } }),
appSession({ id: 'phone' }),
]
const others = otherSessions(sessions)
expect(others.map((item) => item.id)).toEqual(['a', 'b'])
expect(others.map((item) => item.id)).toEqual(['a', 'phone'])
expect(endOtherSessionsMessage(others)).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
'이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
const many = [
...others,
session({ id: 'c', device: { browser: 'Firefox', os: 'Linux' } }),
session({ id: 'd', device: null }),
session({ id: 'e', device: null }),
appSession({ id: 'e', device: null }),
]
expect(endOtherSessionsMessage(many)).toBe(
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
'이 브라우저를 뺀 5곳(Edge · Windows, ARA SYSTEM 앱 · Android, Firefox · Linux 외 2곳)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
})
it('웹 세션만 있어도 같은 글이다 — 웹메일만 가리키지 않는다', () => {
const others = [session({ id: 'a' }), session({ id: 'b', device: { browser: 'Safari', os: 'iOS' } })]
expect(endOtherSessionsMessage(others)).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
})
it('지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함) 이 브라우저도 로그아웃된다는 글', () => {
const unknown = [session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }), session({ id: 'b', device: null })]
const unknown = [
session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }),
session({ id: 'b', device: null }),
appSession({ id: 'phone', device: { browser: 'ARA SYSTEM 앱', os: null } }),
]
expect(endOtherSessionsMessage(unknown, true)).toBe(
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Edge · Windows, 알 수 없는 브라우저, ARA SYSTEM 앱)에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
)
})
})
+60 -7
View File
@@ -1,5 +1,6 @@
// 계정 보안 화면의 순수 함수 — 계약 5절 "계정 보안 — 12단계" · 디자인 README "12단계 — 계정 보안"
// 인증 앱 코드 칸 다듬기 · 4자씩 끊어 보이기 · 기기 이름 · 앱 비밀번호 이름 검사 · 로그인 기록의 방식 · 결과 글
// 앱 세션 · 앱 로그인 기록(계약 1절 "앱 로그인")은 세션의 client · 기록의 method 로만 가른다 — device 의 이름(User-Agent)으로 짐작하지 않는다
import {
APP_PASSWORD_NAME_MAX_LENGTH,
@@ -7,6 +8,7 @@ import {
END_OTHER_SESSIONS_NAME_LIMIT,
END_OTHER_SESSIONS_TEXT,
LOGIN_HISTORY_TEXT,
MOBILE_APP_NAME,
OTP_CODE_LENGTH,
TWO_FACTOR_TEXT,
} from '@/shared/constants/account-security'
@@ -18,6 +20,7 @@ import type {
LoginEvent,
LoginMethod,
MailClientProtocol,
SessionClient,
TwoFactorStatus,
} from '@/shared/types/account-security'
import { formatCount, formatLongDate, formatRelativeDayTime } from '@/shared/utils/format'
@@ -63,14 +66,52 @@ export function deviceLabel(device: LoginDevice | null): string {
return os ? `${browser} · ${os}` : browser
}
/**
* 로그인된 기기 줄의 이름 — 웹 세션은 "Edge · Windows"(deviceLabel), 앱 세션은 "ARA SYSTEM 앱 · Android"(운영체제를 모르면 "ARA SYSTEM 앱").
* 앱 이름은 client 로 붙인다 — 서버가 보낸 device 이름이 비어 있어도 "알 수 없는 브라우저"가 되지 않는다.
* 줄의 [로그아웃] 이름 · 끊은 뒤 알림 · [다른 곳 모두 로그아웃] 확인 글이 함께 쓴다
*/
export function sessionDeviceLabel(session: AuthSession): string {
if (session.client !== 'app') return deviceLabel(session.device)
const os = session.device?.os?.trim()
return os ? `${MOBILE_APP_NAME} · ${os}` : MOBILE_APP_NAME
}
/** 휴대폰 아이콘을 쓸 운영체제 — 서버의 짧은 이름 (계약 5절 "로그인된 브라우저") */
const MOBILE_OS = new Set(['iOS', 'Android'])
/** 로그인된 브라우저 줄의 아이콘 — 휴대폰 · PC (모르면 PC) */
/** 브라우저의 운영체제가 휴대폰인가 — iOS · Android (모르면 아니다) */
export function isMobileDevice(device: LoginDevice | null): boolean {
return MOBILE_OS.has(device?.os ?? '')
}
/** 로그인된 기기 줄의 아이콘 — 휴대폰 · PC. 앱 세션은 운영체제를 몰라도 휴대폰, 웹 세션은 운영체제로(모르면 PC) */
export function isMobileSession(session: AuthSession): boolean {
return session.client === 'app' || isMobileDevice(session.device)
}
/** 세션 수를 client 로 나눠 센다 — 웹메일 · ARA SYSTEM 앱 */
export function sessionCounts(sessions: readonly AuthSession[]): Record<SessionClient, number> {
const counts: Record<SessionClient, number> = { web: 0, app: 0 }
for (const session of sessions) {
if (session.client === 'app') counts.app += 1
else counts.web += 1
}
return counts
}
/**
* 로그인된 기기의 개수 글 — 앱 세션이 없으면 "웹메일 3곳"(앱 1단계 전과 같다), 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳".
* 0곳인 쪽은 쓰지 않는다(둘 다 0이면 "웹메일 0곳")
*/
export function sessionCountText(sessions: readonly AuthSession[]): string {
const counts = sessionCounts(sessions)
const parts: string[] = []
if (counts.web > 0 || counts.app === 0) parts.push(LOGIN_HISTORY_TEXT.sessionsCountWeb(formatCount(counts.web)))
if (counts.app > 0) parts.push(LOGIN_HISTORY_TEXT.sessionsCountApp(formatCount(counts.app)))
return parts.join(' · ')
}
/** 2단계 인증 상태 글 — 「꺼짐」 · 「2026년 10월 31일부터 켜짐」(켠 때를 모르면 「켜짐」) */
export function twoFactorStateLabel(status: TwoFactorStatus): string {
if (!status.isEnabled) return TWO_FACTOR_TEXT.off
@@ -107,11 +148,12 @@ export function appPasswordLastUsedText(lastUsed: AppPasswordLastUsed | null, no
const METHOD_LABEL: Readonly<Record<LoginMethod, string>> = {
webmail: LOGIN_HISTORY_TEXT.methodWebmail,
app: LOGIN_HISTORY_TEXT.methodApp,
imap: LOGIN_HISTORY_TEXT.methodImap,
smtp: LOGIN_HISTORY_TEXT.methodSmtp,
}
/** 로그인 기록의 방식 — "웹메일" · "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" */
/** 로그인 기록의 방식 — "웹메일" · "ARA SYSTEM 앱" · "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" (모르는 값은 그대로) */
export function loginMethodLabel(method: LoginMethod): string {
return METHOD_LABEL[method] ?? method
}
@@ -121,9 +163,20 @@ export function loginAppPasswordLabel(event: LoginEvent): string | null {
return event.appPassword ? LOGIN_HISTORY_TEXT.appPassword(event.appPassword.name) : null
}
/** 로그인 기록의 기기 — 웹메일은 "Chrome · Windows", 메일 프로그램은 기기를 몰라 null(화면은 「—」) */
/**
* 로그인 기록의 기기 — 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 앱은 운영체제 "Android"
* (방식 칸이 이미 "ARA SYSTEM 앱"이라 앱 이름을 되풀이하지 않는다 — 좁은 기기 칸에서 운영체제가 말줄임에 잘리지 않게).
* 메일 프로그램 · 운영체제를 모르는 앱 줄은 null(화면은 「—」)
*/
export function loginDeviceLabel(event: LoginEvent): string | null {
return event.method === 'webmail' ? deviceLabel(event.device) : null
switch (event.method) {
case 'webmail':
return deviceLabel(event.device)
case 'app':
return event.device?.os?.trim() || null
default:
return null
}
}
/**
@@ -146,19 +199,19 @@ export function loginResultLabel(event: LoginEvent): { label: string; isFailure:
}
}
/** [다른 곳 모두 로그아웃]이 끊을 곳 — 지금 이 브라우저를 뺀 세션 */
/** [다른 곳 모두 로그아웃]이 끊을 곳 — 지금 이 브라우저를 뺀 세션(웹메일 · 앱) */
export function otherSessions(sessions: readonly AuthSession[]): AuthSession[] {
return sessions.filter((session) => !session.isCurrent)
}
/**
* [다른 곳 모두 로그아웃] 확인 창 글 — "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …".
* [다른 곳 모두 로그아웃] 확인 창 글 — "이 브라우저를 뺀 2곳(Edge · Windows, ARA SYSTEM 앱 · Android)에서 …".
* 이름은 세 곳까지 늘어놓고 넘으면 "외 n곳".
* 지금 이 브라우저 줄이 없으면(includesThisBrowser — 서버가 이 세션을 알아보지 못해 모두 끊는다) 이 브라우저도 로그아웃된다는 글
*/
export function endOtherSessionsMessage(others: readonly AuthSession[], includesThisBrowser = false): string {
const count = others.length
const names = others.slice(0, END_OTHER_SESSIONS_NAME_LIMIT).map((session) => deviceLabel(session.device))
const names = others.slice(0, END_OTHER_SESSIONS_NAME_LIMIT).map((session) => sessionDeviceLabel(session))
const rest = count - names.length
const listed = rest > 0 ? `${names.join(', ')} ${END_OTHER_SESSIONS_TEXT.moreNames(formatCount(rest))}` : names.join(', ')
return includesThisBrowser
+82 -2
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { directionParticle, objectParticle, subjectParticle } from './korean'
// 규칙은 korean.ts 머리말 — 짝: 앱 `flutter/lib/core/utils/korean_text.dart` (한쪽을 고치면 둘 다)
describe('subjectParticle (주격 조사 이/가)', () => {
it.each([
['받은편지함', '이'],
@@ -27,9 +29,10 @@ describe('directionParticle (방향 조사 으로/로)', () => {
// ㄹ 받침 → 로
['서울', '로'],
['메일', '로'],
// 한글로 끝나지 않으면 발음을 알 수 없다
// 숫자는 읽는 소리로 — 2026 은 "육"
['2026', '으로'],
// 라틴 글자로 끝나면 소리를 알 수 없다
['Work', '(으)로'],
['2026', '(으)로'],
['', '(으)로'],
])('%s → %s', (word, particle) => {
expect(directionParticle(word)).toBe(particle)
@@ -49,3 +52,80 @@ describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습
expect(objectParticle(word)).toBe(particle)
})
})
describe('한글 호환 자모 (U+3131~U+3163 — 초성으로 찾은 말)', () => {
// 자음은 이름(기역 · 리을 · 미음 …)이 모두 받침으로 끝난다 — ㄹ(리을)만 ㄹ 받침이라 "로". 쌍자음 · 겹자음도 받침 있음(ㄹ 받침이 아니다)
it.each([
['ㄱ', '기역', '이', '을', '으로'],
['ㄹ', '리을', '이', '을', '로'],
['ㅁ', '미음', '이', '을', '으로'],
['ㅎ', '히읗', '이', '을', '으로'],
['ㄲ', '쌍기역', '이', '을', '으로'],
['ㄳ', '기역시옷', '이', '을', '으로'],
['ㄺ', '리을기역', '이', '을', '으로'],
['ㅏ', '아', '가', '를', '로'],
['ㅣ', '이', '가', '를', '로'],
])('%s(%s) → %s · %s · %s', (jamo, reading, subject, object, direction) => {
expect([subjectParticle(jamo), objectParticle(jamo), directionParticle(jamo)]).toEqual([subject, object, direction])
// 읽는 소리에 붙인 조사와 같다
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
subject,
object,
direction,
])
})
it('초성 검색어는 마지막 자모로 고른다 — 범위 밖의 옛 자모는 소리를 알 수 없는 꼴', () => {
expect(directionParticle('ㄱㅁ')).toBe('으로')
expect(directionParticle('박ㅈ')).toBe('으로')
expect(subjectParticle('김ㅇㄹ')).toBe('이')
expect(directionParticle('김ㅇㄹ')).toBe('로')
// ㆍ(아래아 U+318D) — 호환 자모지만 U+3163 밖
expect(subjectParticle('ㆍ')).toBe('이(가)')
})
})
describe('숫자 0~9 — 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침으로', () => {
it.each([
['0', '영', '이', '을', '으로'],
['1', '일', '이', '을', '로'],
['2', '이', '가', '를', '로'],
['3', '삼', '이', '을', '으로'],
['4', '사', '가', '를', '로'],
['5', '오', '가', '를', '로'],
['6', '육', '이', '을', '으로'],
['7', '칠', '이', '을', '로'],
['8', '팔', '이', '을', '로'],
['9', '구', '가', '를', '로'],
])('%s(%s) → %s · %s · %s', (digit, reading, subject, object, direction) => {
expect([subjectParticle(digit), objectParticle(digit), directionParticle(digit)]).toEqual([subject, object, direction])
// 읽는 소리에 붙인 조사와 같다
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
subject,
object,
direction,
])
})
it('여러 자리 수 · 글자 뒤의 숫자도 마지막 숫자로 본다', () => {
expect(subjectParticle('2026')).toBe('이')
expect(subjectParticle('프로젝트2')).toBe('가')
expect(objectParticle('3분기 보고서 v10')).toBe('을')
expect(directionParticle('회의실 7')).toBe('로')
})
})
describe('끝의 공백 · 닫는 따옴표 · 괄호 (지금 규칙 그대로)', () => {
it('앞뒤 공백은 지우고 본다 — 자모 · 숫자도 같다', () => {
expect(directionParticle('스팸메일함 ')).toBe('으로')
expect(directionParticle('ㄹ ')).toBe('로')
expect(subjectParticle(' 7 ')).toBe('이')
})
it('닫는 따옴표 · 괄호는 건너뛰지 않는다 — 기호로 끝나면 소리를 알 수 없는 꼴 (부르는 쪽이 따옴표 안의 말을 넘긴다)', () => {
expect(subjectParticle('보고서’')).toBe('이(가)')
expect(subjectParticle('"서버 이전"')).toBe('이(가)')
expect(objectParticle('휴지통)')).toBe('을(를)')
expect(directionParticle('회의(2)')).toBe('(으)로')
})
})
+64 -21
View File
@@ -1,4 +1,17 @@
// 한국어 문구 보조 — 순수 함수만 둔다
//
// 조사는 앞말의 마지막 글자를 읽는 소리의 받침으로 고른다. 앞뒤 공백은 지우고 보며, 닫는 따옴표 · 괄호 같은 기호는 건너뛰지 않는다
// (기호로 끝나면 소리를 알 수 없는 것으로 본다 — 부르는 쪽이 따옴표 안의 말을 넘긴다).
// - 한글 음절: 그 글자의 받침
// - 한글 호환 자모(U+3131~U+3163 — 초성으로 찾을 때 흔하다): 자음 ㄱ~ㅎ은 이름(기역 · 니은 · 디귿 · 리을 · 미음 …)이 모두
// 받침으로 끝나 받침 있음이고, ㄹ(리을)만 ㄹ 받침이다. 쌍자음 · 겹자음(ㄲ · ㄳ · ㄺ …)도 받침 있음(ㄹ 받침이 아니다). 모음 ㅏ~ㅣ는 받침 없음
// - 숫자 0~9: 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침 — 받침 있음 0 · 1 · 3 · 6 · 7 · 8(그 가운데 ㄹ 받침 1 · 7 · 8),
// 받침 없음 2 · 4 · 5 · 9. 여러 자리 수도 마지막 숫자로 본다("2026" → 육)
// - 그 밖(라틴 글자 · 기호 · 빈 글자)은 소리를 알 수 없어 두 꼴을 함께 쓴다 — "이(가)" · "을(를)" · "(으)로"
// 짝: flutter `lib/core/utils/korean_text.dart` — 규칙을 바꾸면 둘 다
/** 마지막 소리의 받침 — 없음 · ㄹ 받침 · 그 밖의 받침 */
type FinalSound = 'none' | 'rieul' | 'other'
const HANGUL_SYLLABLE_START = 0xac00
const HANGUL_SYLLABLE_END = 0xd7a3
@@ -7,43 +20,73 @@ const JONGSEONG_COUNT = 28
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
const NO_JONGSEONG = 0
const JONGSEONG_RIEUL = 8
/** 한글 호환 자모 — 자음 ㄱ(U+3131)~ㅎ(U+314E), 그 가운데 ㄹ(U+3139), 모음 ㅏ(U+314F)~ㅣ(U+3163) */
const JAMO_CONSONANT_START = 0x3131
const JAMO_CONSONANT_END = 0x314e
const JAMO_RIEUL = 0x3139
const JAMO_VOWEL_START = 0x314f
const JAMO_VOWEL_END = 0x3163
/** 숫자 0~9 */
const DIGIT_ZERO = 0x30
const DIGIT_NINE = 0x39
/** 숫자 0~9 를 읽는 소리의 받침 — 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 */
const DIGIT_FINAL_SOUNDS: readonly FinalSound[] = [
'other', // 0 영
'rieul', // 1 일
'none', // 2 이
'other', // 3 삼
'none', // 4 사
'none', // 5 오
'other', // 6 육
'rieul', // 7 칠
'rieul', // 8 팔
'none', // 9 구
]
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
function lastJongseong(word: string): number | null {
/** 마지막 글자를 읽는 소리의 받침 — 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글자) null */
function finalSound(word: string): FinalSound | null {
const last = Array.from(word.trim()).pop()
if (!last) return null
const code = last?.codePointAt(0)
if (code === undefined) return null
const code = last.charCodeAt(0)
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
if (code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END) {
const jongseong = (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
if (jongseong === NO_JONGSEONG) return 'none'
return jongseong === JONGSEONG_RIEUL ? 'rieul' : 'other'
}
if (code >= JAMO_CONSONANT_START && code <= JAMO_CONSONANT_END) return code === JAMO_RIEUL ? 'rieul' : 'other'
if (code >= JAMO_VOWEL_START && code <= JAMO_VOWEL_END) return 'none'
if (code >= DIGIT_ZERO && code <= DIGIT_NINE) return DIGIT_FINAL_SOUNDS[code - DIGIT_ZERO] ?? null
return null
}
/**
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
* 주격 조사 "이/가" — 마지막 소리에 받침이 있으면 "이", 없으면 "가" (휴지통이 · 프로젝트가 · ㄱ이 · 2가).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "이(가)" 를 쓴다.
*/
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '이(가)'
return jongseong === NO_JONGSEONG ? '가' : '이'
const sound = finalSound(word)
if (sound === null) return '이(가)'
return sound === 'none' ? '가' : '이'
}
/**
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다.
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를 · 3을 · ㅏ를).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "을(를)" 을 쓴다.
*/
export function objectParticle(word: string): '을' | '를' | '을(를)' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '을(를)'
return jongseong === NO_JONGSEONG ? '를' : '을'
const sound = finalSound(word)
if (sound === null) return '을(를)'
return sound === 'none' ? '를' : '을'
}
/**
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로"
* (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로 · ㄱ으로 · ㄹ로 · 6으로 · 7로).
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "(으)로" 를 쓴다.
*/
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
const jongseong = lastJongseong(word)
if (jongseong === null) return '(으)로'
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
const sound = finalSound(word)
if (sound === null) return '(으)로'
return sound === 'other' ? '으로' : '로'
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { isDownloadable, isEndedLinkStatus, messageLargeFileView, sharedFileStatusView } from './large-file-status'
@@ -41,7 +42,7 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
meta: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
meta: MESSAGE_LARGE_FILE_TEXT.infected,
tone: 'danger',
download: 'none',
})
@@ -1,14 +1,21 @@
import { describe, expect, it } from 'vitest'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import { mailBodyNotices } from './mail-body-notices'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
import { mailBodyNotices, type MailBodyNoticeOptions } from './mail-body-notices'
const texts = (source: Parameters<typeof mailBodyNotices>[0]) => mailBodyNotices(source).map((item) => item.text)
/** 읽기 화면 — 앞 문장 뒤에 할 일까지 */
const READER: MailBodyNoticeOptions = { withAction: true }
/** 같은 대화 칸의 펼친 줄 — 앞 문장만 */
const CONVERSATION: MailBodyNoticeOptions = { withAction: false }
const texts = (source: Parameters<typeof mailBodyNotices>[0], options: MailBodyNoticeOptions = CONVERSATION) =>
mailBodyNotices(source, options).map((item) => item.text)
describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽기 한도")', () => {
it('서버가 다 준 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 메일도 같다', () => {
expect(texts({ textBody: '본문', isHtmlBodyOmitted: false, isBodyTruncated: false })).toEqual([])
expect(texts({ textBody: '본문' })).toEqual([])
expect(texts({ textBody: '본문' }, READER)).toEqual([])
})
it('서식 본문을 비웠는데 글자 본문이 있으면 "서식 없이 글자만", 없으면(공백뿐 포함) "표시할 수 없음"', () => {
@@ -18,7 +25,7 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
})
it('보여 주는 본문이 잘렸으면 "앞부분만 표시"', () => {
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true })).toEqual([
expect(mailBodyNotices({ textBody: null, isBodyTruncated: true }, CONVERSATION)).toEqual([
{ kind: 'truncated', text: MAIL_BODY_NOTICES.truncated },
])
})
@@ -32,4 +39,21 @@ describe('mailBodyNotices — 읽기 화면의 본문 안내 (계약 2절 "읽
MAIL_BODY_NOTICES.unavailable,
])
})
it('읽기 화면은 앞 문장 뒤에 할 일(원문 내려받기)을 한 칸 띄워 잇는다 — 할 일이 없는 "서식 없이 글자만"은 앞 문장 그대로', () => {
expect(texts({ textBody: null, isHtmlBodyOmitted: true }, READER)).toEqual([
`${MAIL_BODY_NOTICES.unavailable} ${MAIL_BODY_NOTICE_ACTIONS.unavailable}`,
])
expect(texts({ textBody: '긴 글자 본문', isHtmlBodyOmitted: true, isBodyTruncated: true }, READER)).toEqual([
MAIL_BODY_NOTICES.htmlOmitted,
`${MAIL_BODY_NOTICES.truncated} ${MAIL_BODY_NOTICE_ACTIONS.truncated}`,
])
// 할 일은 읽기 화면 ⋯ 메뉴의 원문 내려받기 — 펼친 줄(앞 문장만)에는 없다
expect(MAIL_BODY_NOTICE_ACTIONS.unavailable).toContain(MAIL_READER_MORE_TEXT.downloadSource)
expect(MAIL_BODY_NOTICE_ACTIONS.truncated).toContain(MAIL_READER_MORE_TEXT.downloadSource)
for (const text of texts({ textBody: null, isHtmlBodyOmitted: true, isBodyTruncated: true })) {
expect(text).not.toContain(MAIL_READER_MORE_TEXT.downloadSource)
}
expect(texts({ textBody: null, isBodyTruncated: true })).toEqual([MAIL_BODY_NOTICES.truncated])
})
})
+19 -6
View File
@@ -1,7 +1,7 @@
// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 MessageDetail · "읽기 한도") — 순수 함수만 둔다
// 순서는 생략(서식 없이 · 표시할 수 없음) → 잘림 (디자인 README "읽기 화면의 본문 안내").
import { MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import { MAIL_BODY_NOTICE_ACTIONS, MAIL_BODY_NOTICES, type MailBodyNoticeKind } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
export interface MailBodyNotice {
@@ -9,22 +9,35 @@ export interface MailBodyNotice {
text: string
}
export interface MailBodyNoticeOptions {
/**
* 앞 문장 뒤에 할 일(원문 내려받기 — MAIL_BODY_NOTICE_ACTIONS)까지 잇는다 — 읽기 화면만 true.
* 같은 대화 칸의 펼친 줄은 false(앞 문장만): 그 화면의 ⋯ 는 위에 연 메일의 것이라,
* 원문은 그 줄의 [이 메일만 열기]로 연 읽기 화면에서 받는다
*/
withAction: boolean
}
type NoticeSource = Pick<MessageDetail, 'textBody' | 'isHtmlBodyOmitted' | 'isBodyTruncated'>
function notice(kind: MailBodyNoticeKind): MailBodyNotice {
return { kind, text: MAIL_BODY_NOTICES[kind] }
/** 안내마다의 할 일 — 할 일이 없는 안내(서식 없이 글자만)는 앞 문장 그대로다 */
const ACTIONS: Readonly<Partial<Record<MailBodyNoticeKind, string>>> = MAIL_BODY_NOTICE_ACTIONS
function notice(kind: MailBodyNoticeKind, withAction: boolean): MailBodyNotice {
const action = withAction ? ACTIONS[kind] : undefined
return { kind, text: action ? `${MAIL_BODY_NOTICES[kind]} ${action}` : MAIL_BODY_NOTICES[kind] }
}
/**
* 본문 위에 둘 안내 — 없으면 빈 목록. 표시 값이 없는(이전 서버) 메일은 안내하지 않는다.
* 글자 본문은 공백뿐이면 없는 것으로 본다 — 화면(MailBodyFrame)에 보이는 것이 없다.
*/
export function mailBodyNotices(message: NoticeSource): MailBodyNotice[] {
export function mailBodyNotices(message: NoticeSource, options: MailBodyNoticeOptions): MailBodyNotice[] {
const notices: MailBodyNotice[] = []
const omitted = message.isHtmlBodyOmitted === true
const hasText = (message.textBody ?? '').trim() !== ''
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable'))
if (omitted) notices.push(notice(hasText ? 'htmlOmitted' : 'unavailable', options.withAction))
// 보여 줄 본문이 없으면("표시할 수 없음") 앞부분만 보인다는 안내는 맞지 않는다
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated'))
if (message.isBodyTruncated === true && !(omitted && !hasText)) notices.push(notice('truncated', options.withAction))
return notices
}
@@ -254,6 +254,27 @@ describe('conversationRowsOf — 칸의 줄', () => {
expect(rows[0]?.location).toEqual({ name: 'mail-message', params: { mailboxId: 'sent', messageId: 'm1' } })
})
it('안 읽음은 목록 줄과 같은 규칙 — 안 읽은 메일이면 지금 메일 · 보낸편지함의 메일도, 임시 저장본은 읽음 여부를 보이지 않는다 (2026-11-07)', () => {
const rows = conversationRowsOf(
[
summary('read'),
summary('unread', { isRead: false }),
summary('sent', { mailboxIds: ['sent'], isRead: false }),
summary('draft', { mailboxIds: ['drafts'], isRead: false }),
summary('m3', { isRead: false }),
],
context,
)
expect(rows.map((row) => [row.message.id, row.unread])).toEqual([
['read', false],
['unread', true],
['sent', true],
['draft', false],
['m3', true],
])
})
it('붙잡힌 메일은 시각 자리에 목록과 같은 "예약 · …" · "보내는 중" — <time> 은 보낼 시각, 받은 줄 뒤에 둔다', () => {
const sendAt = '2026-10-30T00:00:00Z'
const rows = conversationRowsOf(
@@ -8,6 +8,8 @@
// 남는 글이 없으면 제목. 다른 메일 프로그램의 인용 꼴은 손대지 않는다 (2026-11-06 실사용 시험)
// - 줄의 차례: 서버 차례(받은 시각 — 내 메일은 저장한 시각) 그대로, 보내기를 기다리는 메일(pendingSend)만 받은 줄 뒤에 보낼 시각 차례로 —
// 예약한 메일이 그 뒤에 온 답장보다 앞에 서지 않게 (2026-11-06 실사용 시험)
// - 안 읽음: 목록 줄과 같은 규칙(mail-list-row.ts 의 isRowUnread — 임시 저장본은 읽음 여부를 보이지 않는다). 지금 보는 메일의 줄도 같다
// (2026-11-07 사용자 관점 점검 — 가운데 메일을 안 읽었으면 어느 것인지 알 수 없었다)
import type { RouteLocationRaw } from 'vue-router'
@@ -19,7 +21,7 @@ import type { Address, ConversationBasis, ConversationPage, Mailbox, MessageSumm
import type { PageInfo } from '@/shared/types/pagination'
import { formatConversationTime, initialOf } from '@/shared/utils/format'
import { displayName, normalizeEmail } from '@/shared/utils/mail-address'
import { mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { isRowUnread, mailboxOfMessage, pendingSendLabel } from '@/shared/utils/mail-list-row'
import { mailboxListIdOf, messageLocationOf, type MailListSource } from '@/shared/utils/mail-list-source'
import { isScheduledMessage, messagePendingSend } from '@/shared/utils/pending-send'
@@ -214,6 +216,8 @@ export interface ConversationRow {
message: MessageSummary
/** 지금 읽기 화면에 연 메일 — "지금 보고 있는 메일", 누를 수 없다 */
current: boolean
/** 안 읽은 메일 — 목록 줄처럼 굵게, 화면 낭독은 이름 앞에 "안 읽음, " (임시 저장본은 늘 false) */
unread: boolean
sender: ConversationSender
/** 미리 보기 한 줄 — 비었으면 제목 */
preview: string
@@ -292,6 +296,7 @@ export function conversationRowsOf(items: readonly MessageSummary[], context: Co
return {
message,
current: message.id === context.currentId,
unread: isRowUnread(message, outgoingMailboxOf(message, context.mailboxes)?.role ?? null),
sender: conversationSenderOf(message, context),
preview: previewWithoutOwnQuote(message.preview).trim() || message.subject || MAIL_NO_SUBJECT,
time: pendingLabel ?? formatConversationTime(message.receivedAt, context.now, context.timeZone),
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
import { newFilterDraft, type MailFilterDraft } from './mail-filter'
import {
@@ -55,7 +56,7 @@ describe('이미 받은 메일에 할 일 — 칸과 시작이 함께 쓰는 판
})
})
describe('「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
describe('「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (고치기 창의 칸 값)', () => {
it('이 탭에서 적용하는 중이 먼저 — 그다음 지워진 메일함 · 받은편지함으로만 · 전달만', () => {
expect(filterApplyBlock(draft({ move: true }), MAILBOXES, true)).toBe('busy')
expect(filterApplyBlock(draft({ move: true, moveTarget: 'gone', forward: true }), MAILBOXES, false)).toBe('missingMailbox')
@@ -123,10 +124,10 @@ describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중')
})
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
it('끝 — N통에 적용, 0통이면 걸리는 메일이 없다', () => {
expect(copy.done(37)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.')
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 1,234통을 정리했습니다.')
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
expect(copy.done(0)).toBe(MAIL_FILTER_RUN_TEXT.none('‘거래처 메일’'))
})
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
+2 -2
View File
@@ -39,7 +39,7 @@ export function filterRunIdleReason(
}
/**
* 「받은편지함에 이미 있는 메일에도 적용」을 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
* 「받은편지함에 이미 있는 메일도 정리하기」를 고를 수 없는 까닭 (디자인 원본 MailFilterApplyStates — 7단계 리뷰로 넓혔다)
* busy: 이 탭에서 다른 필터를 적용하는 중(가장 먼저) · missingMailbox · inboxOnly · forwardOnly: 전달만 하는 필터
*/
export type MailFilterApplyBlock = 'busy' | 'missingMailbox' | 'inboxOnly' | 'forwardOnly'
@@ -99,7 +99,7 @@ export function filterRunTarget(
export interface MailFilterRunCopy {
/** 하는 중 — "‘…’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인" (전체를 모르면 수를 뺀다) */
progress: (tally: Pick<MailFilterRunTally, 'scanned' | 'total'>) => string
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다." */
/** 끝남 — 할 일을 한 수. 0 이면 "받은편지함에는 ‘…’ 필터에 걸리는 메일이 없습니다." */
done: (matched: number) => string
/** 도중에 멈춤 — 거기까지의 수 */
stopped: (tally: MailFilterRunTally) => string
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_NOTIFY_TEXT } from '@/shared/constants/mail-notify'
import {
mailEventsLockName,
packRealtimeMessage,
@@ -13,7 +14,7 @@ import {
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
expect(realtimeLinkText('live')).toMatchObject({
text: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
text: MAIL_NOTIFY_TEXT.realtimeLive,
notice: null,
live: true,
})
@@ -26,7 +27,7 @@ describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
expect(realtimeLinkText('unsupported')).toEqual({
text: '연결할 수 없어 1분마다 확인합니다.',
notice: null,
hint: '이 브라우저는 실시간 연결을 지원하지 않습니다.',
hint: MAIL_NOTIFY_TEXT.realtimeUnsupportedHint,
live: false,
})
expect(realtimeLinkText('connecting')).toMatchObject({ text: '연결하는 중입니다…', notice: null, live: false })
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
@@ -10,7 +11,7 @@ describe('mailboxOperationCopy', () => {
expect(copy.progress(600, 1234)).toBe('받은편지함의 메일을 읽음으로 표시하는 중 · 600 / 1,234통')
expect(copy.done(1234)).toBe('받은편지함의 메일 1,234통을 읽음으로 표시했습니다.')
expect(copy.failed(600, 1234)).toBe('1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
expect(copy.failed(600, 1234)).toBe(MAILBOX_OPERATION_TEXT.failed('600', '1,234'))
// 다른 탭이 먼저 했다
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
})
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
import type { Mailbox } from '@/shared/types/mail'
import {
emptyMailboxText,
@@ -84,7 +85,7 @@ describe('emptyMailboxText', () => {
it('메일함 이름과 받침에 맞는 조사로 안내한다', () => {
expect(emptyMailboxText({ name: '임시보관함', role: 'drafts' })).toEqual({
title: '임시보관함이 비어 있습니다',
hint: '임시 저장한 메일이 여기에 표시됩니다.',
hint: MAILBOX_EMPTY_HINTS.drafts,
})
expect(emptyMailboxText({ name: '프로젝트', role: null }).title).toBe('프로젝트가 비어 있습니다')
})
@@ -12,7 +12,15 @@ import { useLoginHistoryStore } from './login-history.store'
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function session(id: string, isCurrent = false): AuthSession {
return { id, createdAt: '2026-10-31T00:00:00Z', lastActiveAt: '2026-10-31T00:00:00Z', ip: null, device: null, isCurrent }
return {
id,
createdAt: '2026-10-31T00:00:00Z',
lastActiveAt: '2026-10-31T00:00:00Z',
ip: null,
device: null,
client: 'web',
isCurrent,
}
}
const pageOf = <T>(items: T[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
+4 -4
View File
@@ -11,10 +11,10 @@ import type { LoadStatus } from '@/stores/mail.store'
/** 최근 로그인의 보기 — 전체 · 실패만 */
export type LoginEventFilter = 'all' | 'failures'
// 로그인 기록 스토어 — 계약 5절 "로그인된 브라우저" · "로그인 기록" (12단계)
// 로그인 기록 스토어 — 계약 5절 "로그인된 브라우저" · "로그인 기록" (12단계), 앱 세션 · 앱 로그인 기록은 1절 "앱 로그인"
// - 설정 › 로그인 기록에 들어올 때마다 둘 다 새로 받는다(loadSessions · loadEvents). 오류는 묶음마다 화면이 직접 그린다(사전 문구 + [다시 시도])
// - 로그인된 브라우저: 줄의 [로그아웃]은 묻지 않고 바로 끊고(이미 끝난 세션 404 RES_001 도 끊긴 것과 같다) 줄을 뺀다.
// [다른 곳 모두 로그아웃]은 이 요청의 세션만 남긴다 — 지금 세션 줄만 남긴다
// - 로그인된 기기(웹메일 · 앱 세션): 줄의 [로그아웃]은 묻지 않고 바로 끊고(이미 끝난 세션 404 RES_001 도 끊긴 것과 같다) 줄을 뺀다.
// [다른 곳 모두 로그아웃]은 이 요청의 세션만 남긴다(앱 세션도 끊는다) — 지금 세션 줄만 남긴다
// - 최근 로그인: 20개씩 [더 보기](커서). [전체 · 실패만]을 바꾸면 그 보기로 처음부터 다시 받는다 — 받는 동안은 지난 목록을 그대로 두고(busy)
// 다 받으면 바꾼다. 보이는 목록의 보기는 loadedFilter(머리 글 "실패 n번" · 빈 상태가 이것을 본다)
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq), 끊기는 세션 세대(authStore.generation)
@@ -22,7 +22,7 @@ export const useLoginHistoryStore = defineStore('login-history', () => {
const api = useAccountSecurity()
const authStore = useAuthStore()
// ── 로그인된 브라우저 ─────────────
// ── 로그인된 기기 ─────────────
const sessions = ref<AuthSession[]>([])
const sessionsStatus = ref<LoadStatus>('idle')
const sessionsError = ref<ApiError | null>(null)
@@ -6,7 +6,7 @@ import {
MAIL_CONVERSATION_FILL_MAX_REQUESTS,
MAIL_CONVERSATION_PAGE_SIZE,
} from '@/shared/constants/mail-conversation'
import type { MessageDetail, MessageSummary } from '@/shared/types/mail'
import type { ConversationSummary, Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
@@ -715,4 +715,165 @@ describe('mail.store — 14단계 같은 대화', () => {
expect(store.mailboxes[0]?.unreadCount).toBe(1)
})
})
describe('이 탭이 바꾼 읽음 — 칸의 줄에 바로 (2026-11-07 사용자 관점 점검)', () => {
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 1 }
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
const readOf = (store: ReturnType<typeof useMailStore>) => store.conversationItems.map((item) => [item.id, item.isRead])
function row(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
it('받는 동안 이 탭이 바꾼 읽음은 받은 값보다 앞선다(받은 쪽은 바꾸기 전의 서버 값일 수 있다) — 그 뒤에 시작한 받기는 받은 값을 믿는다', async () => {
const unreadThread = () => [summary('m1'), summary('m2', { isRead: false }), summary('m3')]
let reply: MockResult | Promise<MockResult> = conversation(unreadThread())
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH') return ok({ ...summary('m2'), isRead: true })
return ok(null)
})
const store = useMailStore()
await store.loadConversation('m3')
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
await store.markRead({ id: 'm2', isRead: false, mailboxIds: ['inbox'] })
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
stale.resolve(conversation(unreadThread()))
await refreshing
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
// 바꾼 뒤에 시작한 받기 — 그사이 다른 탭에서 안 읽음으로 했다
reply = conversation(unreadThread())
await store.refreshConversation()
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('받기를 시작할 때 보내던 여러 통 처리의 읽음도 받은 값보다 앞선다 — 받는 사이에 끝나도', async () => {
let reply: MockResult | Promise<MockResult> = conversation([summary('m2', { isRead: false }), summary('m3')])
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return reply
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
const stale = deferred()
reply = stale.promise
const refreshing = store.refreshConversation()
bulk.resolve(ok({ updatedIds: ['m2'], failedIds: [] }))
await marking
stale.resolve(conversation([summary('m2', { isRead: false }), summary('m3')]))
await refreshing
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('대화 목록의 여러 통 처리가 바꾸지 못한 메일은 칸도 바꾸기 전 값으로 — 줄은 메일마다의 앞 값을 몰라 칸이 아는 값으로', async () => {
const bulk = deferred()
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'] }), summary('m2', { isRead: false }), summary('m3', { isRead: false })])
}
if (method === 'PATCH' && url === '/messages') return bulk.promise
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'conversations', mailboxId: 'inbox' }
store.conversations = [row('c1', ['m3', 'm2'], { isRead: false })]
await store.loadConversation('m3')
store.toggleCheck('c1')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
// 보내는 동안 — 줄의 메일은 읽은 모양, 보낸편지함의 m1 은 줄에 들지 않아 그대로
expect(readOf(store)).toEqual([['m1', true], ['m2', true], ['m3', true]])
bulk.resolve(ok({ updatedIds: ['m3'], failedIds: ['m2'] }))
await marking
expect(readOf(store)).toEqual([['m1', true], ['m2', false], ['m3', true]])
})
it('여러 통 처리의 중요 표시가 실패하면 중요 표시만 되돌린다 — 고른 때의 읽음으로 목록 · 칸을 덮지 않는다', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation([summary('m2'), summary('m3')])
if (method === 'PATCH' && url === '/messages') return ok({ updatedIds: [], failedIds: ['m2'] })
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
// 목록은 옛 값(안 읽음)이고 칸은 받은 값(읽음)이다 — 검색 결과처럼 저절로 맞추지 않는 목록
store.messages = [summary('m2', { isRead: false }), summary('m3')]
await store.loadConversation('m3')
store.toggleCheck('m2')
await store.patchChecked({ isFlagged: true })
expect(store.messages.find((message) => message.id === 'm2')).toMatchObject({ isRead: false, isFlagged: false })
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
it('메일함 ⋯ › 모두 읽음이 끝나면 칸에 든 그 메일함의 메일도 읽음 — 다른 메일함의 메일은 그대로', async () => {
serve(({ url, method }) => {
if (url.endsWith('/conversation')) {
return conversation([summary('m1', { mailboxIds: ['sent'], isRead: false }), summary('m2', { isRead: false }), summary('m3')])
}
if (method === 'PATCH' && url === '/mailboxes/inbox/messages') return ok({ processed: 1, remaining: 0 })
if (url === '/mailboxes') return ok({ items: [INBOX, SENT], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
return ok(null)
})
const store = useMailStore()
store.mailboxes = [INBOX, SENT]
await store.loadConversation('m3')
const outcome = await store.runMailboxOperation('read', INBOX, () => undefined)
expect(outcome.status).toBe('done')
expect(readOf(store)).toEqual([['m1', false], ['m2', true], ['m3', true]])
})
it('다른 탭 · 기기에서 바꾼 읽음(목록 맞추기로 받은 값)은 칸에 옮기지 않는다 — 다음에 칸을 받을 때 보인다', async () => {
let thread = [summary('m2', { isRead: false }), summary('m3')]
serve(({ url, method }) => {
if (url.endsWith('/conversation')) return conversation(thread)
if (url === '/messages' && method === 'GET') {
return ok({ items: [summary('m2'), summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
}
return ok(null)
})
const store = useMailStore()
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
store.messages = [summary('m2', { isRead: false }), summary('m3')]
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
store.listStatus = 'ready'
await store.loadConversation('m3')
await store.syncVisibleList()
expect(store.messages.find((message) => message.id === 'm2')?.isRead).toBe(true)
expect(readOf(store)).toEqual([['m2', false], ['m3', true]])
thread = [summary('m2'), summary('m3')]
await store.refreshConversation()
expect(readOf(store)).toEqual([['m2', true], ['m3', true]])
})
})
})
+2 -1
View File
@@ -88,7 +88,8 @@ export const useMailStore = defineStore('mail', () => {
.join('|')
})
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(칸은 읽음을 그리지 않는다).
// 통 수가 바뀌면 같은 대화 칸을 다시 받게 한다 — 읽음 · 중요 표시만 바뀐 소식으로는 받지 않는다(그러면 메일을 열 때마다 다시 받는다.
// 칸의 안 읽은 줄은 이 탭에서 바꾼 읽음을 바로 적고 — conversation.ts — 다른 탭 · 기기에서 바꾼 읽음은 다음에 받을 때 보인다).
// 처음 받은 메일함 목록 · 비울 때(로그아웃)는 견줄 앞 값이 없어 건너뛴다. 칸이 없으면 소식만 오르고 아무도 받지 않는다
watch(mailboxTotalsKey, (next, previous) => {
if (next && previous) conversation.requestConversationRefresh()
+10 -2
View File
@@ -90,6 +90,8 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
list.endPending(ids, changes)
if (isStale()) {
// 다른 목록이라 목록은 되돌리지 않는다 — 같은 대화 칸의 읽음만 바꾸기 전으로 (14단계. 같은 목록이면 아래의 되돌림이 칸에도 알린다)
if (changes.isRead !== undefined) list.restoreLocalReads(new Set([...result.skippedIds, ...result.unsentIds]))
settleStale()
return { ...result, stale: true }
}
@@ -97,7 +99,11 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
const original = before.get(id)
if (!original) continue
flip(original, -1)
list.setLocalFlags(id, { isRead: original.isRead, isFlagged: original.isFlagged })
// 바꾼 표시만 되돌린다 — 중요 표시가 실패했을 때 읽음까지 고른 때의 값으로 적으면 그사이 읽은 것 · 같은 대화 칸의 값을 덮는다 (14단계)
const revert: FlagChanges = {}
if (changes.isRead !== undefined) revert.isRead = original.isRead
if (changes.isFlagged !== undefined) revert.isFlagged = original.isFlagged
list.setLocalFlags(id, revert)
}
if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
return { ...result, stale: false }
@@ -129,12 +135,14 @@ export function createBulkActions({ mailApi, mailboxes, list, selection, opened
return { done: patched.updatedIds, skipped: patched.failedIds }
})
list.endPending(ids, changes)
const failed = new Set([...result.skippedIds, ...result.unsentIds])
// 같은 대화 칸의 읽음을 바꾸기 전으로 — 줄은 메일마다의 앞 값을 몰라 칸이 아는 값으로, 목록이 바뀌었어도 (14단계)
if (changes.isRead !== undefined) list.restoreLocalReads(failed)
if (isStale()) {
settleStale()
return { ...result, stale: true }
}
const failed = new Set([...result.skippedIds, ...result.unsentIds])
if (failed.size > 0) {
list.restoreConversationFlags(originals.filter((row) => conversationActionIds(row).some((id) => failed.has(id))))
const shown = opened.selected.value
+106 -3
View File
@@ -16,6 +16,10 @@
// 같은 길(markRead — 목록 · 메일함 개수도 맞춘다)로 읽음. 이 탭이 아는 값(열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것)을 먼저 본다 —
// 받아 둔 대화가 옛 값이어도 같은 메일을 두 번 읽음으로 바꾸지 않게. 받아 둔 본문은 접힌 줄의 것을 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만 둔다.
// 그사이 지워진 메일(본문이 404 RES_001)은 대화를 다시 받아 그 줄을 뺀다([더 보기]로 더 받은 대화는 그 줄만 뺀다)
// 이 탭이 바꾼 읽음 (2026-11-07 — 칸이 안 읽은 줄을 굵게 그린다): 목록 · 열린 메일에 읽음을 적을 때마다(list.ts 의 watchLocalReads — 펼친 줄 · 읽기 화면에서
// 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 실패해 되돌림 · 서버가 돌려준 요약 · 메일함 ⋯ › 모두 읽음) 칸의 줄에도 바로 적는다.
// 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다 — 받은 쪽은 그 바꿈이 서버에 닿기 전의 값일 수 있다.
// 다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 받을 때 보인다 (읽음만 바뀐 소식으로는 다시 받지 않는다 — mail.store.ts)
import { computed, ref, shallowRef } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
@@ -50,6 +54,19 @@ interface Gathered {
pageInfo: PageInfo
}
/** 이 탭이 바꾼 읽음 하나 — 마지막으로 적은 값 · 그때의 차례 · 바꾸기 전에 칸이 알던 값(모르면 null — 되돌릴 때 쓴다) */
interface LocalRead {
isRead: boolean
seq: number
before: boolean | null
}
/** 받기를 시작할 때 — 그때의 차례와 그때 읽음을 보내던 메일 */
interface LocalReadMark {
seq: number
sending: ReadonlySet<string>
}
interface ConversationDeps {
mailApi: MailApi
list: ListState
@@ -124,6 +141,12 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
let refreshAgain = false
/** 이 칸이 읽음으로 바꿨거나 읽기 화면에서 본 메일 — 받아 둔 대화가 옛 값이어도 읽음으로 본다 */
const markedRead = new Set<string>()
/**
* 이 탭이 바꾼 읽음 (머리말) — 화면에 그리지 않으므로 반응형이 아니다. 받기를 시작할 때 그때 보내던 것만 남기고 잊는다(그 뒤 받은 값이 담는다),
* 비울 때(reset) 모두 잊는다 — 메일 id 는 계정마다 겹칠 수 있다
*/
const localReads = new Map<string, LocalRead>()
let localReadSeq = 0
/** 받는 일 하나의 확인 — 그 뒤 다른 일이 시작됐거나 · 다른 대화 · 세션이 바뀌었으면 false */
function liveCheck(messageId: string): () => boolean {
@@ -147,9 +170,87 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
}
}
// ── 이 탭이 바꾼 읽음 (머리말) ─────────────────────────────
/** 칸의 줄에 읽음을 적는다 — 바뀌는 줄이 없으면 그대로 둔다 */
function writeReads(reads: ReadonlyMap<string, boolean>) {
const items = conversationItems.value
if (!items.some((item) => reads.has(item.id) && reads.get(item.id) !== item.isRead)) return
conversationItems.value = items.map((item) => {
const isRead = reads.get(item.id)
return isRead === undefined || isRead === item.isRead ? item : { ...item, isRead }
})
}
/** 목록 · 열린 메일에 읽음을 적었다 — 칸의 줄에도 적고, 바꾼 차례와 바꾸기 전 값을 둔다 */
function noteLocalRead(ids: Iterable<string>, isRead: boolean) {
localReadSeq += 1
const shown = new Map(conversationItems.value.map((item) => [item.id, item.isRead]))
const reads = new Map<string, boolean>()
for (const id of ids) {
localReads.set(id, { isRead, seq: localReadSeq, before: shown.get(id) ?? localReads.get(id)?.isRead ?? null })
reads.set(id, isRead)
}
writeReads(reads)
}
/** 바꾸지 못한 메일 — 칸이 알던 앞 값으로 되돌린다. 앞 값을 모르면 이 탭의 값을 잊는다(다음에 받은 값을 믿는다) */
function restoreLocalRead(ids: Iterable<string>) {
localReadSeq += 1
const reads = new Map<string, boolean>()
for (const id of ids) {
const local = localReads.get(id)
if (!local) continue
if (local.before === null) {
localReads.delete(id)
continue
}
localReads.set(id, { isRead: local.before, seq: localReadSeq, before: local.isRead })
reads.set(id, local.before)
}
writeReads(reads)
}
/** 메일함 ⋯ › 모두 읽음이 끝났다 — 칸에 든 그 메일함의 안 읽은 메일을 읽음으로 */
function noteMailboxRead(mailboxId: string) {
const ids = conversationItems.value
.filter((item) => !item.isRead && item.mailboxIds.includes(mailboxId))
.map((item) => item.id)
if (ids.length > 0) noteLocalRead(ids, true)
}
list.watchLocalReads({ changed: noteLocalRead, restored: restoreLocalRead, mailboxRead: noteMailboxRead })
/**
* 받기를 시작한다 — 지금 차례와 지금 읽음을 보내는 메일을 적어 두고, 나머지는 잊는다
* (그 바꿈은 서버에 닿았다 — 이제 받는 값에 들어 있다)
*/
function markLocalReads(): LocalReadMark {
const sending = new Set<string>()
for (const id of [...localReads.keys()]) {
if (list.pendingFlagsOf(id)?.isRead !== undefined) sending.add(id)
else localReads.delete(id)
}
return { seq: localReadSeq, sending }
}
/**
* 받은 쪽에 이 탭이 아는 읽음을 덮는다 — 지금 읽음을 보내는 중이면 그 값, 받기를 시작한 뒤 바꿨거나 그때 보내던 메일이면 이 탭이 적은 값
* (받은 값은 그 바꿈이 서버에 닿기 전의 값일 수 있다). 그 밖은 받은 값이다 — 다른 탭 · 기기에서 바꾼 읽음도 이때 보인다
*/
function withLocalReads(items: readonly MessageSummary[], mark: LocalReadMark): MessageSummary[] {
return items.map((item) => {
const local = localReads.get(item.id)
const known = local && (local.seq > mark.seq || mark.sending.has(item.id)) ? local.isRead : undefined
const isRead = list.pendingFlagsOf(item.id)?.isRead ?? known ?? item.isRead
return isRead === item.isRead ? item : { ...item, isRead }
})
}
/**
* 쪽을 받아 모은다 — from 이 없으면 처음 쪽부터, 있으면 그 다음 쪽부터. 새로 한 쪽이 차거나 · 더 없거나 · 요청이 FILL_MAX 번이 될 때까지.
* 쪽마다 onPage(지금까지 모은 것, 더 묻는지). 그사이 다른 일이 시작됐으면 null — 받은 것을 쓰지 않는다. 실패는 그대로 던진다
* 쪽마다 onPage(지금까지 모은 것, 더 묻는지). 그사이 다른 일이 시작됐으면 null — 받은 것을 쓰지 않는다. 실패는 그대로 던진다.
* 받은 쪽에는 이 탭이 받는 동안 바꾼 읽음을 덮는다 (withLocalReads)
*/
function gather(
messageId: string,
@@ -161,6 +262,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
let cursor = from ? nextConversationCursor(from.pageInfo) : null
if (from && cursor === null) return Promise.resolve(from)
return tracked(async () => {
const mark = markLocalReads()
let items: MessageSummary[] = from ? [...from.items] : []
let pageInfo: PageInfo | null = from?.pageInfo ?? null
let added = 0
@@ -175,7 +277,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
requests += 1
if (!live()) return null
const merged = appendConversationPage(items, pageInfo, page)
items = merged.items
items = withLocalReads(merged.items, mark)
pageInfo = merged.pageInfo
added += merged.added
cursor = nextConversationCursor(pageInfo)
@@ -396,7 +498,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
// ── 펼친 줄 ─────────────────────────────────────────
function setItemRead(id: string, isRead: boolean) {
conversationItems.value = conversationItems.value.map((item) => (item.id === id ? { ...item, isRead } : item))
writeReads(new Map([[id, isRead]]))
}
/** 이 탭이 아는 읽음 — 열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것, 모르면 받아 둔 대화의 값 */
@@ -490,6 +592,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
refreshing = null
refreshAgain = false
markedRead.clear()
localReads.clear()
}
return {
+41 -2
View File
@@ -14,6 +14,10 @@
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
// 그 전에 떠난 새 메일 확인의 응답은 옛 상태라 버리고 한 번 더 맞춘다 (옮긴 메일이 되살아나거나 표시가 되돌아가지 않게)
//
// 같은 대화 칸에 알리기 (14단계 — 2026-11-07): 이 탭이 읽음을 적을 때마다(setFlagsOf · applySummary) 같은 대화 칸(conversation.ts)이
// 듣는다(watchLocalReads) — 칸의 안 읽은 줄을 굵게 그리고, 칸은 읽음만 바뀐 소식으로 다시 받지 않기 때문이다.
// 여러 통 처리가 바꾸지 못한 메일(restoreLocalReads)과 메일함 ⋯ › 모두 읽음이 끝남(noteMailboxRead)도 알린다
//
// 검색 결과 이어 받기 (14단계 — 계약 2절 "검색 — 14단계"): 서버(색인 방식)는 한 요청에 확인하는 메일 수에 상한이 있어 hasNext 인데 항목이
// 적거나 0개인 쪽을 준다. 검색을 열 때 · 더 볼 때 한 쪽(20통)이 찰 때까지 커서로 이어 묻는다(MAIL_SEARCH_FILL_MAX_REQUESTS 번까지 — 넘으면
// 사용자가 [더 찾기] · [메일 더 보기]로 잇는다). 하나도 찾기 전에는 "찾는 중"(loading), 하나라도 찾으면 보이며 뒤에서 잇는다(isLoadingMore).
@@ -67,6 +71,16 @@ import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
/** 목록 · 열린 메일에 바로 적는 표시 */
type LocalChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
/** 같은 대화 칸이 듣는 이 탭의 읽음 (머리말 — watchLocalReads, conversation.ts) */
export interface LocalReadListener {
/** 그 메일들의 읽음을 이 값으로 적었다 — 먼저 바꿈 · 실패해 되돌림 · 서버가 돌려준 요약 */
changed: (ids: ReadonlySet<string>, isRead: boolean) => void
/** 그 메일들의 읽음을 바꾸기 전 값으로 — 여러 통 처리가 바꾸지 못한 메일 (메일마다의 앞 값은 칸이 안다) */
restored: (ids: ReadonlySet<string>) => void
/** 그 메일함의 메일을 모두 읽음으로 했다 — 메일함 ⋯ › 모두 읽음이 끝남 */
mailboxRead: (mailboxId: string) => void
}
interface ListDeps {
mailApi: MailApi
mailboxes: MailboxesState
@@ -145,6 +159,8 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
* 화면에 그리지 않으므로 반응형이 아니다
*/
const pendingFlags = new Map<string, FlagChanges>()
/** 같은 대화 칸 — 하나만 듣는다 (watchScheduledTotal 과 같은 결) */
let localReadListener: LocalReadListener | null = null
// 출처가 바뀌면(어떻게 바꿨든) 그 수를 세고 알림 줄 정보를 비운다
watch(
@@ -182,7 +198,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/**
* 여러 메일의 표시를 한 번에 — 목록 · 열린 메일 · 대화 줄(그 메일이 든 줄 — 줄의 메일을 모두 바꿨을 때만 줄 전체가 읽음 · 중요 해제다,
* mail-conversation-list.ts 의 withConversationFlags)
* mail-conversation-list.ts 의 withConversationFlags). 읽음이면 같은 대화 칸에도 알린다
*/
function setFlagsOf(ids: ReadonlySet<string>, changes: LocalChanges) {
if (ids.size === 0) return
@@ -193,6 +209,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
const current = opened.selected.value
if (current && ids.has(current.id)) opened.selected.value = { ...current, ...changes }
bumpLocalChange()
if (changes.isRead !== undefined) localReadListener?.changed(ids, changes.isRead)
}
/** 대화 줄의 읽음 · 중요 표시를 앞 값으로 되돌린다 — 보내지 못한 메일이 든 줄 (여러 통 처리 — bulk.ts) */
@@ -208,7 +225,7 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
/**
* 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 — 요약(PATCH 응답)에는 검색 강조가 없으므로 목록 줄의 강조는 그대로 둔다 (14단계).
* 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다
* 대화 줄은 그 메일이 마지막 메일이면 바꿔 끼우고, 표시는 setFlagsOf 와 같은 규칙으로 적는다. 읽음은 같은 대화 칸에도 알린다
*/
function applySummary(summary: MessageSummary) {
messages.value = messages.value.map((message) => {
@@ -225,6 +242,25 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
}
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
bumpLocalChange()
localReadListener?.changed(new Set([summary.id]), summary.isRead)
}
/** 같은 대화 칸이 이 탭의 읽음을 듣는다 (머리말) */
function watchLocalReads(listener: LocalReadListener) {
localReadListener = listener
}
/**
* 여러 통 처리가 바꾸지 못한 메일 — 같은 대화 칸의 읽음을 바꾸기 전으로 (bulk.ts). 대화 목록의 줄은 메일마다의 앞 값을 몰라
* (restoreConversationFlags 는 줄째 되돌린다) 칸이 아는 앞 값으로, 끝났을 때 목록이 바뀌어 있었으면 목록 대신 칸만
*/
function restoreLocalReads(ids: ReadonlySet<string>) {
if (ids.size > 0) localReadListener?.restored(ids)
}
/** 메일함 ⋯ › 모두 읽음이 끝났다 — 같은 대화 칸에 든 그 메일함의 메일도 읽음으로 (mailbox-admin.ts — 목록은 서버 기준으로 다시 맞춘다) */
function noteMailboxRead(mailboxId: string) {
localReadListener?.mailboxRead(mailboxId)
}
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 (대화 줄은 메일이 모두 빠진 줄만 빠지고 그 수를 센다) */
@@ -763,6 +799,9 @@ export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
setFlagsOf,
restoreConversationFlags,
applySummary,
watchLocalReads,
restoreLocalReads,
noteMailboxRead,
removeFromList,
relocate,
beginPending,

Some files were not shown because too many files have changed in this diff Show More