fix(frontend): 화면 문구를 사용자에게 말하는 말투로 고친다

설계 문서처럼 규칙을 적어 둔 문구가 많다는 사용자 지적(2026-11-05) — "메일 서버에 계정이 있는 직원입니다 —
이름과 주소만 보입니다." · "「@도메인」은 그 도메인에서 오는 메일 모두입니다." 같은 글. 화면 상수 · 템플릿 ·
문장을 만드는 유틸의 236곳을 무엇이 되는지 · 무엇을 하면 되는지로 다시 썼다: 줄표로 잇지 않고, 「」 대신
[단추 이름], 메일 서버 · 엔진 · 판정 같은 만든 쪽 말을 빼고, 존댓말은 그대로. 한도 · 날짜 · 개수는 빼지 않았다.
그 문구를 그대로 보던 spec 을 함께 맞췄다(lint · type-check · 단위 2,354 · TZ=UTC · build 통과).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:44:43 +09:00
parent 7d0d5f34c0
commit ddfb2d14ab
74 changed files with 503 additions and 503 deletions
@@ -154,8 +154,8 @@ describe('ContactDialog', () => {
'집',
'기타',
])
expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다')
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.')
expect(wrapper.text()).toContain('5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.')
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 넣어 주세요.')
expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000')
expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([
'g1',
@@ -224,7 +224,7 @@ describe('ContactDialog', () => {
expect(email?.attributes('type')).toBe('text')
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe(
'5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
'5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.',
)
})
@@ -342,7 +342,7 @@ describe('ContactDialog', () => {
expect(search?.config.silent).toBe(true)
const warning = wrapper.get('[data-contact-duplicate]')
expect(warning.text()).toBe(
'이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기',
'주소록에 있는 강서윤(거래처)의 주소와 같습니다. 그래도 저장할 수 있습니다. 그 연락처 보기',
)
expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id'))
expect(email?.attributes('aria-invalid')).toBe('false')
@@ -370,7 +370,7 @@ describe('ContactDialog', () => {
)
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤')
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤')
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('주소록에 있는 강서윤')
await wrapper.get('#contact-cancel').trigger('click')
await flushPromises()
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
@@ -18,7 +18,7 @@ interface Props {
tone?: 'neutral' | 'danger'
/** 서버 추적 ID — 앞 8자리를 "문의 코드"로 보인다 */
traceId?: string | null
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택하세요") */
/** 영역을 세로로 가득 채워 가운데 둔다 (본문 영역의 "메일을 선택해 주세요") */
fill?: boolean
}
@@ -57,7 +57,7 @@ describe('ComposeScheduleDialog', () => {
expect(day.attributes()).toMatchObject({ min: '2026-10-29', max: '2026-11-05' })
expect((wrapper.get('[data-schedule-time]').element as HTMLSelectElement).value).toBe(String(9 * 60))
expect(wrapper.findAll('[data-schedule-time] option')).toHaveLength(48)
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC 의 시간(\(.+\))?으로 보냅니다 · 7일 안에서 고를 수 있습니다\.$/)
expect(wrapper.get('#compose-schedule-note').text()).toMatch(/^이 PC의 시간(\(.+\))?으로 보내고, 7일 안에서 고를 수 있습니다\.$/)
expect(document.activeElement).toBe(wrapper.get('[data-schedule-confirm]').element)
})
@@ -81,7 +81,7 @@ describe('ComposeScheduleDialog', () => {
expect(wrapper.emitted('confirm')).toEqual([[local(10, 31, 14, 30)]])
})
it('지금부터 5분 뒤 이전이면 "지금부터 5분 뒤 이후의 시각을 고르세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
it('지금부터 5분 뒤 이전이면 "5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요." — 칸이 그 글을 가리키고 넘기지 않는다', async () => {
const { wrapper } = mountAt(local(10, 29, 15))
await wrapper.get('[data-schedule-day]').setValue('2026-10-29')
@@ -89,7 +89,7 @@ describe('ComposeScheduleDialog', () => {
await wrapper.get('[data-schedule-confirm]').trigger('click')
const error = wrapper.get('#compose-schedule-error')
expect(error.text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect(error.text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
expect(error.attributes('role')).toBe('alert')
expect(wrapper.get('[data-schedule-day]').attributes()).toMatchObject({
'aria-invalid': 'true',
@@ -120,7 +120,7 @@ describe('ComposeScheduleDialog', () => {
clock.advance(6 * 60_000)
await wrapper.get('[data-schedule-confirm]').trigger('click')
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
expect(wrapper.emitted('confirm')).toBeUndefined()
})
@@ -138,7 +138,7 @@ describe('MailNotifyOffer (바탕화면 알림 권하기 줄)', () => {
expect(useToastStore().toasts).toMatchObject([
{
kind: 'error',
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
message: '브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다.',
action: { label: '설정 열기', to: { name: 'settings-mail-notify' } },
},
])
@@ -401,7 +401,7 @@ describe('여러 통 처리 도구 줄', () => {
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
})
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
store.toggleCheck('j1')
@@ -415,7 +415,7 @@ describe('여러 통 처리 도구 줄', () => {
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
expect(toast?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
@@ -111,7 +111,7 @@ describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
])
expect(store.messages).toEqual([])
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
['휴지통을 비웠습니다 — 메일 2통.', null],
['메일 2통을 지워 휴지통을 비웠습니다.', null],
])
// 비었으니 단추도 사라진다
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
@@ -216,7 +216,7 @@ describe('MailboxPanel — 메일함 관리', () => {
const input = wrapper.get('#mailbox-name-input')
expect(input.attributes('maxlength')).toBe('64')
expect(document.activeElement).toBe(input.element)
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.')
// 이름을 비웠으면 누를 수 없다
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
@@ -262,7 +262,7 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(useToastStore().toasts).toEqual([])
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.')
})
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
@@ -403,7 +403,7 @@ describe('MailboxPanel — 메일함 관리', () => {
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
})
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "메일 21통을 지워 휴지통을 비웠습니다."', async () => {
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
const { wrapper } = await mountPanel()
@@ -421,7 +421,7 @@ describe('MailboxPanel — 메일함 관리', () => {
await wrapper.get('#mailbox-empty-confirm').trigger('click')
await flushPromises()
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
expect(toastTexts()).toEqual(['메일 21통을 지워 휴지통을 비웠습니다.'])
// 되돌릴 수 없어 [되돌리기]가 없다
expect(useToastStore().toasts[0]?.action).toBeNull()
})
@@ -433,7 +433,7 @@ describe('MailboxPanel — 메일함 관리', () => {
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
['error', '1,234통 가운데 500통까지 처리하고 멈췄습니다. 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
@@ -104,7 +104,7 @@ describe('MessageAttachments — 대용량 첨부', () => {
expect(blocked.attributes('aria-disabled')).toBe('true')
await blocked.trigger('click')
await flushPromises()
expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다')
expect(view.get('[role="status"]').text()).toBe('1.2 GB · 보안 검사가 끝나면 받을 수 있습니다')
expect(downloads).toEqual([])
expect(view.get('[data-large-file="f2"]').classes()).toContain('large-item--danger')
@@ -127,7 +127,7 @@ describe('MessageAttachments — 대용량 첨부', () => {
'612 MB · 아직 받지 않음 · 11월 29일까지',
])
expect(view.get('.link-bar__note').text()).toBe(
'링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
'링크로 보낸 파일 2개는 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
)
await view.get('[data-link-copy]').trigger('click')
@@ -143,7 +143,7 @@ describe('MessageList (분할 보기 목록)', () => {
const box = wrapper.findAll('.message-row [role="checkbox"]')[0]
expect(box?.attributes('aria-disabled')).toBe('true')
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.')
expect(box?.attributes('title')).toBe('보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.')
await box?.trigger('click')
await wrapper.findAll('.message-row__main')[0]?.trigger('click', { metaKey: true })
await flushPromises()
@@ -175,6 +175,6 @@ describe('MessageList (분할 보기 목록)', () => {
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
expect(wrapper.get('.search-scope').text()).toContain('휴지통과 스팸메일함은 빼고 찾습니다')
})
})
@@ -596,7 +596,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
})
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "스팸이 아닌 메일로 표시해 …" 알림', async () => {
const { wrapper } = await mountWith({ box: 'junk' })
const notSpam = wrapper.get('[data-reader-spam]')
expect(notSpam.text()).toBe('스팸 아님')
@@ -606,7 +606,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
await flushPromises()
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
})
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
@@ -281,7 +281,7 @@ onBeforeUnmount(() => {
<StateMessage
v-else
icon="mail"
title="메일을 선택하세요"
title="메일을 선택해 주세요"
:hint="
mailStore.search
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
@@ -271,7 +271,7 @@ describe('MessageTable (목록 보기)', () => {
expect(boxes().map((box) => box.attributes('aria-disabled') ?? null)).toEqual([null, 'true', 'true', null])
expect(boxes()[1]?.attributes('title')).toBe(
'보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
'보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.',
)
expect(boxes()[0]?.attributes('title')).toBeUndefined()
@@ -474,7 +474,7 @@ describe('RecipientField', () => {
wrapper.unmount()
})
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "고르면 4명을 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
const wrapper = mountField([], suggestionsOf([JUNHO], [VENDORS, EMPTY_GROUP]))
await typed(wrapper, 'ㄱㄹ')
@@ -483,7 +483,7 @@ describe('RecipientField', () => {
expect(option.attributes('data-suggestion-group')).toBeDefined()
expect(option.find('svg').exists()).toBe(true)
expect(option.get('.recipient-suggestions__name strong').text()).toBe('거래')
expect(option.get('.recipient-suggestions__email').text()).toBe('4명 · 고르면 한꺼번에 넣습니다')
expect(option.get('.recipient-suggestions__email').text()).toBe('고르면 4명을 한꺼번에 넣습니다')
wrapper.unmount()
})
@@ -53,7 +53,7 @@ describe('TwoFactorGraceNotice', () => {
const bar = wrapper.get('[data-two-factor-grace-notice]')
expect(bar.attributes('role')).toBe('region')
expect(bar.attributes('aria-label')).toBe('2단계 인증 안내')
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
expect(bar.text()).toBe('11월 30일까지 2단계 인증을 켜 주세요. 그 뒤에는 켜야 로그인할 수 있습니다.지금 켜기')
expect(bar.get('strong').text()).toBe('11월 30일까지 2단계 인증을 켜 주세요')
expect(bar.get('[data-two-factor-grace-action]').attributes('href')).toBe('/settings/account/two-factor')
expect(bar.findAll('button')).toHaveLength(0)
@@ -50,7 +50,7 @@ describe('MailEditor (서식 편집기)', () => {
expect(body.attributes('role')).toBe('textbox')
expect(body.attributes('aria-label')).toBe('본문')
expect(body.attributes('aria-multiline')).toBe('true')
expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력하세요')
expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력해 주세요')
expect(view.get('[role="toolbar"]').attributes('aria-label')).toBe('본문 서식')
expect(view.emitted('ready')).toHaveLength(1)
})
@@ -256,7 +256,7 @@ defineExpose({
<EditorToolButton
tool="image"
label="그림 넣기"
title="그림 넣기 — PNG · JPG · GIF"
title="그림 넣기 (PNG, JPG, GIF)"
:disabled="imageDisabled"
:focusable="rovingTool === 'image'"
@press="emit('pick-image')"
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 › 대용량 첨부 › 보내지 않은 파일 (디자인 원본 SettingsLargeFiles — 사용량 상자 바로 아래: 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위)
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
// 줄(52): 아이콘 · 이름 / 상태(올리다 멈춤 — 갈색 · 다 올림 · 검사 상태) · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
// 남는 기한("11월 29일까지 남음", 멈춘 파일은 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글)
// [지우기] → 확인 창(처음 초점 「취소」) → DELETE /file-uploads/{id} → "‘…’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다(스토어).
@@ -4,8 +4,8 @@
// 표(구현은 실제 table): 시각 · 방식 · IP · 기기 · 결과 — 최근 순서 · 20개씩 [더 보기](44)
// - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"
// - 기기: 웹메일은 "Chrome · Windows"(모르면 "알 수 없는 브라우저"), 메일 프로그램은 「—」. 나라 · 지역은 보이지 않는다
// - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」 — 실패는 빨간 굵은 글자만(뱃지 없음)
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."
// - 결과: 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」 — 실패는 빨간 굵은 글자만(뱃지 없음)
// - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."
// - 보기를 바꾸면 그 보기로 처음부터 받는다 — 받는 동안 지난 목록은 그대로 두고 aria-busy (login-history.store)
import { computed } from 'vue'