chore: 문구 다듬기 브랜치(14단계 · main 을 합친 판)를 앱 서버 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m47s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m26s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m42s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m20s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m4s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Has been cancelled

이 PR 이 문구 다듬기 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
충돌 넷은 모두 설정 › 로그인된 기기의 글이다 — 앱 쪽은 앱 세션이 함께 보여 "브라우저"를 "기기"로,
다듬기 쪽은 같은 안내 문장을 규칙 9 로("여기에 보이지 않습니다" → "이 목록에 없습니다") 바꿨다.
뜻은 앱 쪽 · 문장은 다듬은 글로 풀었고(account-security.ts · LoginSessionList.vue 주석 · 디자인 README),
시험은 두 쪽이 더한 상수를 모두 남기고 글자 그대로 견주던 줄을 상수로 바꿨다(AccountLoginHistoryPage.spec.ts).
합친 뒤 backend tsc · lint 0 · 단위 3,701 · e2e 333, frontend type-check · lint 0 · 단위 2,774 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
2026-11-08 12:39:07 +09:00
86 changed files with 655 additions and 398 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,
@@ -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([
'이름이나 메일 주소',
@@ -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'
@@ -444,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)
@@ -857,7 +857,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
describe('펼친 줄의 본문 안내 · 그사이 지워진 메일', () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다', async () => {
it('서식을 뺐거나 잘린 본문이면 읽기 화면과 같은 안내 줄을 본문 위에 둔다 — 앞 문장만(원문은 [이 메일만 열기]로 연 읽기 화면에서)', async () => {
serve({ detail: () => ok(detail(REPLY, { isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '글자 본문' })) })
const { wrapper } = await mountReader()
@@ -869,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)
})
@@ -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 브라우저 점검)
// - 경고 "이 창을 닫으면 다시 볼 수 없습니다. …" + 설정값 요약 + [닫기]
// - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」
@@ -4,7 +4,7 @@
// 「로그인된 기기」 + "웹메일 3곳"(앱 세션이 있으면 "웹메일 2곳 · ARA SYSTEM 앱 1곳") · 오른쪽 [다른 곳 모두 로그아웃](44 — 다른 곳이 없으면 그리지 않는다)
// 표(구현은 실제 table): PC · 휴대폰 아이콘(16 — 앱 세션은 늘 휴대폰) · 기기 "Edge · Windows" · "ARA SYSTEM 앱 · Android" / IP / 마지막 활동 / [로그아웃](36 — 밀도 예외).
// 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르는 브라우저는 "알 수 없는 브라우저", 모르는 IP 는 「—」.
// 아래 "메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 기기가 없습니다.")
// 아래 "메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요."(앱 비밀번호는 링크 — 다른 곳이 없으면 앞에 "다른 곳에 로그인된 기기가 없습니다.")
// - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’에서 로그아웃했습니다.」(되돌리기 없음). 누른 줄이 사라지면 초점은 다음 줄(없으면 앞 줄 · 묶음 제목)
// - [다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남고 초점은 묶음 제목으로
// 지금 이 브라우저 줄이 없으면(서버가 이 세션을 알아보지 못함 — 서버는 모두 끊는다) 확인 창이 이 브라우저도 로그아웃된다고 알리고,
@@ -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
@@ -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'
+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 로 이어서 — 수는 이어 센다)
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
@@ -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'
@@ -128,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('조건을 줄여')
@@ -335,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('다른 말로 찾거나 조건을 줄여 보세요.')
})
@@ -402,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()
})
@@ -416,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 () => {
@@ -514,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)
@@ -561,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
@@ -572,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'
@@ -19,6 +20,8 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
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" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
@@ -224,7 +227,7 @@ describe('AccountLoginHistoryPage', () => {
])
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')
})
@@ -259,9 +262,7 @@ describe('AccountLoginHistoryPage', () => {
])
expect(toastMessages()).toEqual(['‘ARA SYSTEM 앱 · Android’에서 로그아웃했습니다.'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 1곳')
expect(wrapper.get('[data-sessions-note]').text()).toBe(
'다른 곳에 로그인된 기기가 없습니다. 메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
)
expect(wrapper.get('[data-sessions-note]').text()).toBe(`${LOGIN_HISTORY_TEXT.noOthers}${SESSIONS_NOTE}`)
})
it('모르는 브라우저는 "알 수 없는 브라우저"', async () => {
@@ -337,9 +338,7 @@ describe('AccountLoginHistoryPage', () => {
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')
})
@@ -504,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()
@@ -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)'),
@@ -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>')
@@ -121,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: '지금 비밀번호',
@@ -199,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: '앱 비밀번호를 복사했습니다.',
@@ -253,7 +253,7 @@ export const LOGIN_HISTORY_TEXT = {
unknownBrowser: '알 수 없는 브라우저',
unknownValue: '알 수 없음',
noOthers: '다른 곳에 로그인된 기기가 없습니다. ',
sessionsNoteBefore: '메일 프로그램은 여기에 보이지 않습니다. 연결을 끊으려면 ',
sessionsNoteBefore: '메일 프로그램은 이 목록에 없습니다. 연결을 끊으려면 ',
sessionsNoteLink: '앱 비밀번호',
sessionsNoteAfter: '를 지워 주세요.',
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
@@ -264,7 +264,7 @@ export const LOGIN_HISTORY_TEXT = {
eventsHintAll: `최근 90일 · 웹메일, ${MOBILE_APP_NAME}, 메일 프로그램`,
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
eventsHintFailuresUnknown: '최근 90일 · 실패만',
filterLabel: '보일 기록',
filterLabel: '볼 기록',
filterAll: '전체',
filterFailures: '실패만',
columnTime: '시각',
@@ -283,7 +283,7 @@ export const LOGIN_HISTORY_TEXT = {
failureWrongCode: '코드가 틀려 실패',
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
more: '더 보기',
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
moreHint: '최근 기록부터 20개씩 볼 수 있습니다. 90일이 지난 기록은 지워집니다.',
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
+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
+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: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
+26 -14
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: '다른 메일함에서 메일을 고른 뒤 [이동]을 누르면 이 메일함으로 옮길 수 있습니다.',
}
/**
@@ -291,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 실사용 시험)
@@ -392,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: '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.',
+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
}
@@ -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('프로젝트가 비어 있습니다')
})
+2 -2
View File
@@ -228,8 +228,8 @@ describe('toast.store', () => {
const failed = toast.progress('비우는 중')
store.dismiss(store.toasts[1]?.id ?? -1)
failed.fail('21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
failed.fail('21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.')
expect(store.toasts.at(-1)).toMatchObject({ kind: 'error', message: '21통 가운데 10통까지 마치고 멈췄습니다. 다시 시도해 주세요.' })
})
})