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'
@@ -118,19 +118,19 @@ describe('useMailFilterRun', () => {
mailStore.listStatus = 'ready'
}
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통에 적용했습니다." + [거래처 보기](그 메일함으로)', async () => {
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통을 정리했습니다." + [거래처 보기](그 메일함으로)', async () => {
const first = deferred()
const second = deferred()
replies(first.promise, second.promise)
expect(useMailFilterRun().start(rule())).toBe(true)
// 첫 응답 전 — 전체는 받은편지함의 메일 수
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']])
const id = useToastStore().toasts[0]?.id
first.finish(step(4500, 30, 3912, 'c1'))
await flushPromises()
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인']])
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인']])
second.finish(step(3912, 7, 0, null))
await flushPromises()
@@ -139,16 +139,16 @@ describe('useMailFilterRun', () => {
expect(toast).toMatchObject({
id,
kind: 'success',
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.',
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
})
expect(cursors()).toEqual([null, 'c1'])
})
it.each([
['읽음 · 중요만 하는 필터', 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({ 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, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
replies(step(8412, matched, 0, null))
@@ -179,7 +179,7 @@ describe('useMailFilterRun', () => {
useMailFilterRun().start(rule({ actions: { ...NO_ACTIONS, markAsRead: true } }))
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중']])
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중']])
first.finish(step(10, 0, 0, null))
await flushPromises()
})
@@ -193,7 +193,7 @@ describe('useMailFilterRun', () => {
// 진행 알림이 그 자리에서 멈춤 문구로 — 까닭은 오류 알림이 함께
expect(shown()).toEqual([
['error', '‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.'],
['error', '‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
const stopped = withAction()
@@ -201,11 +201,11 @@ describe('useMailFilterRun', () => {
useToastStore().clear()
press(stopped)
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, 'c1', 'c1'])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
})
it('처음 요청부터 실패하면 "필터는 저장했지만 …" + [다시 시도] — 처음부터 다시 한다', async () => {
@@ -215,18 +215,18 @@ describe('useMailFilterRun', () => {
await flushPromises()
expect(shown()).toEqual([
['error', '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.'],
['error', '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.SYS_001],
])
const failed = withAction()
expect(failed?.action?.label).toBe('다시 시도')
useToastStore().clear()
press(failed)
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, null])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
})
it('다른 창 · PC 에서 적용하는 중이면(429) "다른 창에서 …" 알림 하나만 — 처음 요청이면 단추가 없다', async () => {
@@ -236,7 +236,7 @@ describe('useMailFilterRun', () => {
await flushPromises()
expect(useToastStore().toasts).toMatchObject([
{ kind: 'error', message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
{ kind: 'error', message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
])
})
@@ -251,7 +251,7 @@ describe('useMailFilterRun', () => {
expect(useToastStore().toasts).toHaveLength(1)
expect(toast).toMatchObject({
kind: 'error',
message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
press(toast)
@@ -326,9 +326,9 @@ describe('useMailFilterRun', () => {
await flushPromises()
expect(runs()).toHaveLength(3)
const busy = '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
const busy = '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
expect(shown()).toEqual([
['progress', '‘둘’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인'],
['progress', '‘둘’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인'],
['error', busy],
['error', busy],
])
@@ -342,10 +342,10 @@ describe('useMailFilterRun', () => {
// 앞 적용이 끝났다 — 바쁨 알림의 [다시 시도]는 멈춘 자리(cursor c1 · 4,200통 · 12통)에서 이어 한다
press(resumed)
expect(shown()).toEqual([['progress', '‘하나’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
expect(shown()).toEqual([['progress', '‘하나’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, 'c1', null, 'c1'])
expect(shown()).toEqual([['success', '‘하나’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
expect(shown()).toEqual([['success', '‘하나’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
})
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
@@ -151,9 +151,9 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(false)
expect(wrapper.find('#restricted-password-code').exists()).toBe(false)
expect(wrapper.get('label[for="restricted-password-next"]').text()).toBe('새 비밀번호')
expect(wrapper.text()).toContain('8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.')
expect(wrapper.text()).toContain('8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.')
expect(wrapper.get('[data-restricted-submit]').text()).toBe('비밀번호 바꾸고 계속')
expect(wrapper.text()).toContain('메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.')
expect(wrapper.text()).toContain('아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.')
expect(document.activeElement?.id).toBe('restricted-password-next')
// 비밀번호는 칸 · 반응형 상태에 남기지 않는다 — 로그인 칸은 사라졌다
expect(wrapper.find('#login-password').exists()).toBe(false)
@@ -279,7 +279,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
expect(wrapper.get('h1').text()).toBe('새 비밀번호를 정해 주세요')
expect(wrapper.find('#restricted-password-temporary').exists()).toBe(true)
expect(wrapper.get('label[for="restricted-password-code"]').text()).toBe('인증 앱 코드')
expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.')
expect(wrapper.text()).toContain('로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.')
expect(urls()).toContain('GET /two-factor')
})
@@ -366,16 +366,16 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
it('새 키를 받아 QR · 키를 그린다 — 지금 비밀번호는 묻지 않고(메모리) 문의 줄 · 아래 안내', async () => {
const { wrapper } = await atSetupStep({ supportContact: () => ok({ text: CONTACT }) })
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
expect(urls()).toContain('POST /two-factor/enrollments')
expect(wrapper.find('[data-qr-code]').exists()).toBe(true)
expect(wrapper.get('[data-setup-key]').text().replace(/\s/g, '')).toBe(enrollment(1).secret)
expect(wrapper.text()).toContain(`앱에는 「ARA SYSTEM (${USER.email})」로 저장됩니다.`)
expect(wrapper.text()).toContain(`앱에는 ‘ARA SYSTEM (${USER.email})’로 저장됩니다.`)
expect(wrapper.get('label[for="restricted-setup-code"]').text()).toBe('앱에 표시된 6자리 코드')
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
expect(wrapper.get('[data-support-line]').text()).toBe(`인증 앱을 쓸 수 없나요?${CONTACT}`)
expect(wrapper.get('[data-support-contact]').text()).toBe(CONTACT)
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
expect(document.activeElement?.id).toBe('restricted-setup-code')
})
@@ -428,7 +428,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
const { wrapper } = await mountLogin({ activate: () => fail(400, 'AUTH_005') })
expect(wrapper.get('label[for="restricted-setup-password"]').text()).toBe('지금 비밀번호')
expect(wrapper.text()).toContain('새로고침해서 다시 넣어야 합니다.')
expect(wrapper.text()).toContain('새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요.')
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
expect(bodyOf('/two-factor/activation')).toEqual([])
@@ -453,7 +453,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
await submitStep(wrapper)
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['새 비밀번호를 정했습니다.'])
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
expect(document.activeElement?.id).toBe('restricted-setup-code')
@@ -491,7 +491,7 @@ describe('LoginPage — 제한된 세션 (13단계)', () => {
})
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜야 합니다')
expect(wrapper.get('h1').text()).toBe('2단계 인증을 켜 주세요')
expect(wrapper.find('#restricted-setup-password').exists()).toBe(false)
await wrapper.get('#restricted-setup-code').setValue('482913')
await submitStep(wrapper)
+1 -1
View File
@@ -206,7 +206,7 @@ describe('LoginPage — 2단계 인증 코드 단계', () => {
expect(input.attributes('maxlength')).toBeUndefined()
expect(document.activeElement?.id).toBe('login-code')
expect(wrapper.text()).toContain('인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.')
expect(wrapper.text()).toContain('Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
expect(wrapper.text()).toContain('Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다.')
})
it('문의 연락처가 있으면(13단계) 「인증 앱을 쓸 수 없나요?」 다음 줄에 연락처', async () => {
+2 -2
View File
@@ -449,7 +449,7 @@ void supportContactStore.ensureLoaded()
로그인
</h1>
<p class="login__subtitle">
회사 메일 계정(@{{ COMPANY_MAIL_DOMAIN }})으로 로그인하세요.
회사 메일 주소(@{{ COMPANY_MAIL_DOMAIN }})로 로그인해 주세요.
</p>
</div>
@@ -524,7 +524,7 @@ void supportContactStore.ensureLoaded()
:contact="supportContactStore.text"
/>
<p class="login__note">
Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)
Outlook이나 휴대폰 기본 메일 앱에서도 IMAP, SMTP 설정으로 같은 메일을 쓸 수 있습니다.
</p>
</template>
</div>
@@ -387,7 +387,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('연락처를 골라 주세요')
await click(row.get('a'))
expect(router.currentRoute.value.fullPath).toBe('/contacts/all/c2')
@@ -624,11 +624,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('회사 메일을 쓰는 직원이라 이름과 메일 주소만 보입니다.')
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('사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다')
await click(detail.get('[data-contact-add-mine]'))
expect(isOpen(app, '#contact-dialog')).toBe(true)
@@ -693,7 +693,7 @@ describe('ContactsPage (주소록 모듈)', () => {
await click(app.get('[data-group-row="g1"] a'))
const input = app.get('[data-contacts-search]')
expect(input.attributes('aria-label')).toBe('주소록에서 찾기')
expect(input.attributes('placeholder')).toBe('이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)')
expect(input.attributes('placeholder')).toBe('이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기')
await input.setValue('박')
clock.advance(CONTACTS_SEARCH_DELAY_MS - 1)
@@ -721,7 +721,7 @@ describe('ContactsPage (주소록 모듈)', () => {
await flushPromises()
const empty = app.get('[data-contacts-empty="search"]')
expect(empty.text()).toContain('‘없는사람’에 맞는 연락처가 없습니다')
expect(empty.text()).toContain('이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.')
expect(empty.text()).toContain('이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.')
// Esc 는 찾을 글을 지운다 — 목록이 다시 그룹 전체로
await input.trigger('keydown', { key: 'Escape' })
@@ -742,7 +742,7 @@ describe('ContactsPage (주소록 모듈)', () => {
await click(app.get('[data-contacts-source="directory:person"]'))
expect(router.currentRoute.value.fullPath).toBe('/contacts/directory/people')
expect((input.element as HTMLInputElement).value).toBe('')
expect(input.attributes('placeholder')).toBe('이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)')
expect(input.attributes('placeholder')).toBe('이름, 주소, 초성(ㅂㅈㅎ)으로 찾기')
})
it('그룹 ⋯ — 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글), 구성원이 없으면 메일 쓰기를 끈다 · 지우기는 확인 없이 + [되돌리기]', async () => {
@@ -764,7 +764,7 @@ describe('ContactsPage (주소록 모듈)', () => {
expect(router.currentRoute.value.fullPath).toBe('/contacts')
expect(app.find('[data-group-row="g1"]').exists()).toBe(false)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다 — 연락처 3명은 그대로 있습니다.')
expect(toast?.message).toBe('‘거래처’ 그룹을 지웠습니다. 그룹에 있던 연락처 3명은 그대로 남아 있습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
@@ -953,8 +953,8 @@ describe('ContactsPage (주소록 모듈)', () => {
const empty = app.get('[data-contacts-empty="first"]')
expect(empty.text()).toContain('아직 연락처가 없습니다')
expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.')
expect(empty.text()).toContain('메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.')
expect(empty.text()).toContain('자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다.')
expect(empty.text()).toContain('메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.')
await click(empty.get('button'))
expect(isOpen(app, '#contact-dialog')).toBe(true)
expect(app.get('#contact-dialog-title').text()).toBe('연락처 추가')
@@ -1001,7 +1001,7 @@ describe('ContactsPage (주소록 모듈)', () => {
await click(app.get('[data-group-row="g2"] button[data-mailbox-more]'))
await click(app.findAll('[role="menuitem"]')[2] ?? app.get('[role="menuitem"]'))
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action ?? null])).toEqual([
['‘프로젝트 알파’ 그룹을 지웠습니다 — 연락처 1명은 그대로 있습니다.', null],
['‘프로젝트 알파’ 그룹을 지웠습니다. 그룹에 있던 연락처 1명은 그대로 남아 있습니다.', null],
])
})
@@ -446,7 +446,7 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
expect(wrapper.get('.compose__send').text()).toBe('보내기')
// 꺼진 단추는 aria-disabled (11단계 — 초점은 받는다) · 까닭은 단추 옆 글
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.')
expect(wrapper.get('#compose-send-why').text()).toBe('보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.')
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_004)
expect(document.activeElement?.textContent?.trim()).toBe('파일 첨부')
expect(useToastStore().toasts).toEqual([])
@@ -239,9 +239,9 @@ describe('ComposePage — 대용량 첨부', () => {
expect(phases(wrapper)).toEqual(['ready', 'waiting-file', 'missing'])
expect(lineOf(wrapper, 'ready')).toBe('1.2 GB · 이 임시 저장본에 11월 25일까지 남습니다')
expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다')
expect(lineOf(wrapper, 'waiting-file')).toContain('올리다 멈춤 · 160 MB / 612 MB 올림 · 같은 파일을 다시 고르면 이어서 올립니다')
expect(wrapper.find('[data-phase="waiting-file"] [data-large-repick]').exists()).toBe(true)
expect(lineOf(wrapper, 'missing')).toBe('설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.')
expect(lineOf(wrapper, 'missing')).toBe('[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.')
const send = () => wrapper.get('.compose__send')
expect(send().attributes('aria-disabled')).toBe('true')
@@ -283,7 +283,7 @@ describe('ComposePage — 대용량 첨부', () => {
expect((sent?.data as Record<string, unknown> | undefined)?.largeFileIds).toEqual(['lf-1'])
expect(calls(requests)).toContain('GET /file-uploads/lf-1')
expect(phases(wrapper)).toEqual(['missing'])
expect(lineOf(wrapper, 'missing')).toBe('서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.')
expect(lineOf(wrapper, 'missing')).toBe('파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.')
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable)
expect(useToastStore().toasts).toEqual([])
@@ -308,7 +308,7 @@ describe('ComposePage — 대용량 첨부', () => {
const lookup = requests.find((request) => request.url === '/share-link-lookups')
expect(lookup?.data).toEqual({ urls: [URL1, URL2] })
expect(wrapper.findAll('[data-quoted-link]').map((notice) => notice.text())).toEqual([
'원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지',
'원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.',
'인용 속 대용량 첨부 링크는 공유가 멈춰 받을 수 없습니다.',
])
// 원래 메일의 대용량 첨부는 다시 싣지 않는다 — 링크 묶음이 없다
@@ -379,7 +379,7 @@ describe('ComposePage — 대용량 첨부', () => {
await flushPromises()
expect(phases(wrapper)).toEqual(['ready'])
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다')
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중이지만 보낼 수 있습니다')
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
})
@@ -216,7 +216,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(toasts().map((toast) => toast.kind)).toEqual(['error'])
})
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다 — …" 에도 [보내기 취소] (같은 마감)', async () => {
it('일부가 받지 못하면 빨간 알림 "보냈지만 1명은 받지 못합니다. …" 에도 [보내기 취소] (같은 마감)', async () => {
const { wrapper } = await mountCompose(server([undoReply([NOBODY])]))
await addRecipient(wrapper, 'doyun.lee@arasystem.it.kr')
await addRecipient(wrapper, NOBODY.email)
@@ -226,7 +226,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
expect(toasts()).toEqual([
expect.objectContaining({
kind: 'error',
message: '보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
action: expect.objectContaining({ label: '보내기 취소' }),
expiresAt: NOW + 10_000,
}),
@@ -369,7 +369,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
const dialog = wrapper.get('#compose-schedule-title').element.closest('dialog')
expect(dialog?.hasAttribute('open')).toBe(true)
// 이 PC 의 검사로는 맞는 시각이다(시계가 어긋남) — 가까운 쪽(5분) 한도로 알린다
expect(wrapper.get('#compose-schedule-error').text()).toBe('지금부터 5분 뒤 이후의 시각을 고르세요.')
expect(wrapper.get('#compose-schedule-error').text()).toBe('5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.')
expect((wrapper.get('[data-schedule-preset="custom"]').element as HTMLInputElement).checked).toBe(true)
const chosen = new Date(preset.at)
expect((wrapper.get('[data-schedule-day]').element as HTMLInputElement).value).toBe(formatDate(chosen))
+5 -5
View File
@@ -319,7 +319,7 @@ describe('ComposePage', () => {
expect(useToastStore().toasts).toEqual([
expect.objectContaining({
kind: 'error',
message: '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
message: '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)',
// 성공 알림과 같은 곳 — 링크가 있으니 10초 (toast.store)
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
}),
@@ -538,7 +538,7 @@ describe('ComposePage', () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
)
})
@@ -547,7 +547,7 @@ describe('ComposePage', () => {
expect(useMailStore().quota).toBeNull()
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
)
})
@@ -1483,7 +1483,7 @@ describe('ComposePage', () => {
expect(dialogOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (받는 쪽이 거절)'],
['error', '보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (받는 쪽이 거절)'],
])
})
@@ -1567,7 +1567,7 @@ describe('ComposePage', () => {
await flushPromises()
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['error', '보냈지만 1명은 받지 못했습니다 — infra@arasystem.it.kr (지금은 받을 수 없음)'],
['error', '보냈지만 1명은 받지 못했습니다. infra@arasystem.it.kr (지금은 받을 수 없음)'],
])
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
})
+1 -1
View File
@@ -1256,7 +1256,7 @@ watch(hasMissingLarge, (missing) => {
v-model="subject"
type="text"
class="compose__subject"
placeholder="제목을 입력하세요"
placeholder="제목을 입력해 주세요"
autocomplete="off"
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
:disabled="preparing"
@@ -126,12 +126,12 @@ describe('예약됨 · 보낸편지함 (9단계)', () => {
}))
}
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각 차례"', async () => {
it('분할 보기 — 예약한 메일을 보낼 시각 차례로: 받는 사람 · 제목 · 보낼 시각(청록) · 머리 "예약됨 · 2통 · 보낼 시각이 이른 순서"', async () => {
const { wrapper } = await mountAt('/mail/scheduled')
expect(requests.find((request) => request.url === '/scheduled-messages')?.params).toEqual({ limit: 20 })
expect(wrapper.get('#scheduled-list-title').text()).toBe('예약됨')
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각 차례')
expect(wrapper.get('.list-header__count').text()).toBe('2통 · 보낼 시각이 이른 순서')
expect(rows(wrapper)).toEqual([
{
who: '받는 사람: 이도윤',
@@ -114,7 +114,7 @@ describe('MailboxPage — 검색 결과', () => {
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
expect(wrapper.text()).toContain(
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
)
await wrapper.get('.search-empty__all').trigger('click')
await flushPromises()
@@ -124,7 +124,7 @@ describe('MailboxPage — 검색 결과', () => {
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통과 스팸메일함은 빼고 찾습니다')
})
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
@@ -167,7 +167,7 @@ describe('MailboxPage — 검색 결과', () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색을 닫고 보낸편지함으로 돌아가기')
await wrapper.get('.search-header__close').trigger('click')
await flushPromises()
@@ -147,10 +147,10 @@ describe('AccountAppPasswordsPage', () => {
const settings = wrapper.get('[data-mail-client-settings]').text()
expect(settings).toContain('mx1.arasystem.it.kr · 포트 993 · SSL/TLS')
expect(settings).toContain('mx1.arasystem.it.kr · 포트 465 · SSL/TLS')
expect(settings).toContain('ara.kim@arasystem.it.kr — 메일 주소 전체')
expect(settings).toContain('여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다')
expect(settings).toContain('ara.kim@arasystem.it.kr · 메일 주소 전체')
expect(settings).toContain('웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호')
expect(wrapper.get('[data-app-password-notes]').text()).toContain(
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요.',
)
})
@@ -214,7 +214,7 @@ describe('AccountAppPasswordsPage', () => {
expect(dialogOpen('app-password-create-title')).toBe(true)
expect(document.activeElement?.id).toBe('app-password-name')
expect(wrapper.get('#app-password-name').attributes('maxlength')).toBe('40')
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.')
await create(wrapper, ' ')
expect(wrapper.get('#app-password-name-help').text()).toBe('이름을 넣어 주세요.')
@@ -237,7 +237,7 @@ describe('AccountAppPasswordsPage', () => {
expect(dialogOpen('app-password-create-title')).toBe(false)
expect(dialogOpen('app-password-result-title')).toBe(true)
expect(document.querySelector('#app-password-result-message')?.textContent?.trim()).toBe(
'‘집 노트북 메일’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
'‘집 노트북 메일’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.',
)
expect(wrapper.findAll('.result__group').map((group) => group.text())).toEqual([
'app_',
@@ -370,7 +370,7 @@ describe('AccountAppPasswordsPage', () => {
replies[0]?.(fail(400, 'VAL_001'))
await flushPromises()
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.')
expect(wrapper.get('#app-password-name-help').text()).toBe('어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.')
expect(toastMessages()).toEqual([])
})
@@ -193,7 +193,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('웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
expect(note.get('a').attributes('href')).toBe('/settings/account/app-passwords')
})
@@ -204,7 +204,7 @@ describe('AccountLoginHistoryPage', () => {
expect(wrapper.get('[data-session="old"] [data-session-device]').text()).toBe('알 수 없는 브라우저')
})
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’의 로그인을 끊었습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
it('줄의 [로그아웃]은 묻지 않고 바로 끊는다 — 「‘Edge · Windows’에서 로그아웃했습니다.」 · 줄이 빠지고 초점은 다음 줄', async () => {
const wrapper = await mountPage()
const button = wrapper.findAll('[data-session-end]')[0]
;(button?.element as HTMLButtonElement).focus()
@@ -214,7 +214,7 @@ describe('AccountLoginHistoryPage', () => {
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
'/auth/sessions/edge',
])
expect(toastMessages()).toEqual(['‘Edge · Windows’의 로그인을 끊었습니다.'])
expect(toastMessages()).toEqual(['‘Edge · Windows’에서 로그아웃했습니다.'])
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows', 'Safari · iOS'])
expect(wrapper.get('[data-sessions-count]').text()).toBe('웹메일 2곳')
expect(document.activeElement?.getAttribute('aria-label')).toBe('‘Safari · iOS’ 로그아웃')
@@ -225,11 +225,11 @@ describe('AccountLoginHistoryPage', () => {
await wrapper.findAll('[data-session-end]')[1]?.trigger('click')
await flushPromises()
expect(toastMessages()).toEqual(['‘Safari · iOS’의 로그인을 끊었습니다.'])
expect(toastMessages()).toEqual(['‘Safari · iOS’에서 로그아웃했습니다.'])
expect(sessionRows(wrapper)).toHaveLength(2)
})
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 곳의 로그인 2곳을 끊었습니다.」 — 지금 이 브라우저만 남는다', async () => {
it('[다른 곳 모두 로그아웃] → 확인 창(처음 초점 「취소」 · 끊을 곳의 이름) → 「다른 2곳에서 로그아웃했습니다.」 — 지금 이 브라우저만 남는다', async () => {
const wrapper = await mountPage()
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
@@ -238,9 +238,9 @@ describe('AccountLoginHistoryPage', () => {
expect(dialog?.hasAttribute('open')).toBe(true)
expect(dialog?.getAttribute('role')).toBe('alertdialog')
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
expect(dialog?.textContent).toContain('메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.')
expect(dialog?.textContent).toContain('메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.')
expect(document.activeElement?.hasAttribute('data-end-others-cancel')).toBe(true)
// 데이터를 지우는 일이 아니다 — 청록 주 단추
expect(wrapper.get('[data-end-others-confirm]').classes()).not.toContain('modal-dialog__action--danger')
@@ -249,12 +249,12 @@ describe('AccountLoginHistoryPage', () => {
await flushPromises()
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual(['/auth/sessions'])
expect(toastMessages()).toEqual(['다른 곳의 로그인 2곳을 끊었습니다.'])
expect(toastMessages()).toEqual(['다른 2곳에서 로그아웃했습니다.'])
expect(dialog?.hasAttribute('open')).toBe(false)
expect(sessionRows(wrapper).map((row) => row[0])).toEqual(['Chrome · Windows'])
expect(wrapper.find('[data-sessions-end-others]').exists()).toBe(false)
expect(wrapper.get('[data-sessions-note]').text()).toBe(
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.',
'다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
)
expect(document.activeElement?.id).toBe('login-sessions-title')
})
@@ -273,10 +273,10 @@ describe('AccountLoginHistoryPage', () => {
await wrapper.get('[data-sessions-end-others]').trigger('click')
await flushPromises()
expect(document.querySelector('#end-other-sessions-title')?.textContent?.trim()).toBe(
'이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
'이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
)
expect(document.querySelector('#end-other-sessions-message')?.textContent?.trim()).toBe(
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 3곳(Chrome · Windows, Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
)
await wrapper.get('[data-end-others-confirm]').trigger('click')
@@ -288,7 +288,7 @@ describe('AccountLoginHistoryPage', () => {
'GET /auth/sessions',
'POST /auth/refresh',
])
expect(toastMessages()).toContain('이 브라우저를 포함한 3곳의 로그인을 끊었습니다.')
expect(toastMessages()).toContain('이 브라우저를 포함해 3곳에서 로그아웃했습니다.')
// 빈 목록을 그려 두지 않는다 — 다시 받은 요청이 세션 만료로 이어졌다
expect(wrapper.find('[data-session]').exists()).toBe(false)
expect(useAuthStore().sessionExpired).toBe(true)
@@ -329,11 +329,11 @@ describe('AccountLoginHistoryPage', () => {
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(eventRows(wrapper)).toEqual([
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '2단계 인증으로 성공'],
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '코드가 틀려 실패'],
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '앱 비밀번호가 필요해 실패'],
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '비밀번호가 틀려 실패'],
])
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
@@ -363,9 +363,9 @@ describe('AccountLoginHistoryPage', () => {
expect(all.attributes('aria-pressed')).toBe('false')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 4번')
expect(eventRows(wrapper).map((row) => row[4])).toEqual([
'실패 — 코드 틀림',
'실패 — 앱 비밀번호 필요',
'실패 — 비밀번호 틀림',
'코드가 틀려 실패',
'앱 비밀번호가 필요해 실패',
'비밀번호가 틀려 실패',
])
// 같은 보기를 다시 누르면 받지 않는다
@@ -374,7 +374,7 @@ describe('AccountLoginHistoryPage', () => {
expect(eventRequests()).toHaveLength(2)
})
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다."', async () => {
it('빈 상태(실패만) — "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 볼 수 있습니다."', async () => {
const wrapper = await mountPage({
events: (request) => (request.params.isSuccess === false ? ok(pageOf([], null, 0)) : ok(pageOf(EVENTS))),
})
@@ -383,7 +383,7 @@ describe('AccountLoginHistoryPage', () => {
expect(wrapper.find('[data-login-event]').exists()).toBe(false)
expect(wrapper.get('[data-events-empty]').text()).toContain('최근 90일 동안 실패한 로그인이 없습니다')
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 봅니다.')
expect(wrapper.get('[data-events-empty]').text()).toContain('[전체]를 누르면 성공한 로그인도 볼 수 있습니다.')
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 실패 0번')
})
@@ -398,7 +398,7 @@ describe('AccountLoginHistoryPage', () => {
},
})
expect(wrapper.get('[data-events-more]').text()).toBe('더 보기')
expect(wrapper.text()).toContain('최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.')
expect(wrapper.text()).toContain('최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.')
await wrapper.get('[data-events-filter="failures"]').trigger('click')
await flushPromises()
@@ -53,12 +53,12 @@ describe('AccountMailClientsPage', () => {
const wrapper = await mountPage()
expect(wrapper.get('h1').text()).toBe('메일 프로그램 연결')
expect(wrapper.text()).toContain('아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.')
expect(wrapper.text()).toContain('아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.')
expect(settingsRows(wrapper)).toEqual([
'mx1.arasystem.it.kr · 포트 993 · SSL/TLS',
'mx1.arasystem.it.kr · 포트 465 · SSL/TLS',
`${USER.email} — 메일 주소 전체`,
'앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
`${USER.email} · 메일 주소 전체`,
'웹메일 비밀번호가 아닌 앱 비밀번호',
])
expect(requests.map((request) => request.url)).toEqual(['/two-factor'])
})
@@ -72,10 +72,10 @@ describe('AccountMailClientsPage', () => {
expect(wrapper.get('[data-mail-clients-create]').attributes('href')).toBe('/settings/account/app-passwords')
})
it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호(권장) …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => {
it('켜지 않은 직원 — 비밀번호 줄은 「앱 비밀번호를 권합니다. …」, 청록 상자 「앱 비밀번호로 연결하기를 권합니다」', async () => {
const wrapper = await mountPage(() => ok(OFF))
expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다')
expect(settingsRows(wrapper).at(-1)).toBe('앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다.')
const notice = wrapper.get('[data-mail-clients-notice]')
expect(notice.attributes('data-mail-clients-notice')).toBe('recommend')
expect(notice.text()).toContain('앱 비밀번호로 연결하기를 권합니다')
@@ -92,7 +92,7 @@ describe('AccountMailClientsPage', () => {
const wrapper = await mountPage()
expect(wrapper.get('[data-mail-clients-cloud-warning]').text()).toBe(
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
)
const buttons = wrapper.findAll('[data-mail-client] h3 button')
expect(buttons.map((button) => button.text())).toEqual([
@@ -118,14 +118,14 @@ describe('AccountMailClientsPage', () => {
expect(panel.attributes('role')).toBe('region')
expect(panel.attributes('aria-labelledby')).toBe('mail-client-outlook-button')
expect(panel.findAll('li')).toHaveLength(5)
expect(panel.text()).toContain('Outlook 에서 [파일] → [계정 추가]를 누릅니다.')
expect(panel.text()).toContain('Outlook에서 [파일] → [계정 추가]를 누릅니다.')
// 호스트 이름은 굵게
expect(panel.get('strong').text()).toBe('mx1.arasystem.it.kr')
await wrapper.get('#mail-client-android-button').trigger('click')
expect(outlook.attributes('aria-expanded')).toBe('false')
expect(wrapper.find('#mail-client-outlook-panel').exists()).toBe(false)
expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다.')
expect(wrapper.get('#mail-client-android-panel').text()).toContain('보안 유형은 [SSL/TLS]를 고른 뒤 [다음]을 누릅니다.')
await wrapper.get('#mail-client-android-button').trigger('click')
expect(wrapper.findAll('[role="region"]')).toHaveLength(0)
@@ -136,6 +136,6 @@ describe('AccountMailClientsPage', () => {
const notes = wrapper.get('[data-mail-clients-notes]').findAll('p').map((note) => note.text())
expect(notes).toHaveLength(4)
expect(notes[1]).toContain('POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.')
expect(notes[1]).toContain('POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요.')
})
})
@@ -79,18 +79,18 @@ describe('AccountPasswordPage', () => {
expect(wrapper.get('#password-current').attributes('autocomplete')).toBe('current-password')
expect(wrapper.get('#password-next').attributes('autocomplete')).toBe('new-password')
expect(wrapper.get('#password-next-hint').text()).toBe(
'8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
'8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
)
// 모든 직원 — 다른 곳 로그인이 끊기고 앱 비밀번호도 모두 지워진다 (12단계)
expect(wrapper.get('[data-password-note]').text()).toContain(
'바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
'바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
)
expect(wrapper.get('[data-password-note]').text()).toContain(
'앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
'앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
)
// 2단계 인증이 꺼진 직원 — 머리 설명은 그대로, 코드 칸이 없다
expect(wrapper.get('.settings-page__description').text()).toBe(
'웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
'웹메일에 로그인할 때와 휴대폰 메일 앱을 연결할 때 함께 쓰는 비밀번호입니다.',
)
expect(wrapper.find('#password-code').exists()).toBe(false)
@@ -148,7 +148,7 @@ describe('AccountPasswordPage', () => {
// 서버가 엔진을 두 번 부르고 다른 세션을 끊는다 — 요청 한도는 보통(API_TIMEOUT_MS)보다 긴 30초
expect(PASSWORD_CHANGE_TIMEOUT_MS).toBe(30_000)
expect(passwordRequests()[0]?.config.timeout).toBe(PASSWORD_CHANGE_TIMEOUT_MS)
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.')
expect(wrapper.get('#password-status').text()).toBe('비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.')
for (const id of ['password-current', 'password-next', 'password-confirm']) {
expect((wrapper.get(`#${id}`).element as HTMLInputElement).value).toBe('')
}
@@ -234,7 +234,7 @@ describe('AccountPasswordPage', () => {
autocomplete: 'one-time-code',
'aria-describedby': 'password-code-hint',
})
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.')
expect(wrapper.get('#password-code-hint').text()).toBe('코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.')
})
it('코드는 숫자만 받는다 — 붙여 넣은 "123 456" · "123-456" 은 다듬고, 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007)', async () => {
@@ -120,10 +120,10 @@ describe('AccountTwoFactorPage', () => {
expect(wrapper.find('[data-two-factor-notes]').exists()).toBe(false)
})
it('켜짐 — 「켜짐 — 2026년 10월 31일부터」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
it('켜짐 — 「2026년 10월 31일부터 켜짐」 · [2단계 인증 끄기] · 안내 상자 / 켠 때를 모르면 「켜짐」', async () => {
const wrapper = await mountPage({ status: () => ok(ON) })
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
expect(wrapper.get('[data-two-factor-label]').classes()).toContain('two-factor__state-label--on')
expect(wrapper.get('[data-two-factor-disable]').text()).toBe('2단계 인증 끄기')
expect(wrapper.find('[data-two-factor-warning]').exists()).toBe(false)
@@ -176,7 +176,7 @@ describe('AccountTwoFactorPage', () => {
['K7QF', '2M4X', 'HB6R', 'TZ3N'],
['PW5C', 'YL7D', 'JQ2V', 'MGAS'],
])
expect(wrapper.text()).toContain('앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다.')
expect(wrapper.text()).toContain('앱에는 ‘ARA SYSTEM (ara.kim@arasystem.it.kr)’로 저장됩니다.')
expect(document.activeElement?.id).toBe('two-factor-enable-code')
expect(wrapper.get('#two-factor-enable-code').attributes()).toMatchObject({
inputmode: 'numeric',
@@ -243,11 +243,11 @@ describe('AccountTwoFactorPage', () => {
// 서버가 엔진을 여러 번 부른다 — 한도는 보통(10초)보다 긴 30초
expect(calls('/two-factor/activation')[0]?.config.timeout).toBe(TWO_FACTOR_CHANGE_TIMEOUT_MS)
expect(enableDialog()?.hasAttribute('open')).toBe(false)
expect(wrapper.get('[data-two-factor-label]').text()).toBe('켜짐 — 2026년 10월 31일부터')
expect(wrapper.get('[data-two-factor-label]').text()).toBe('2026년 10월 31일부터 켜짐')
const done = wrapper.get('[data-two-factor-done]')
expect(done.attributes('role')).toBe('status')
expect(done.text()).toContain('2단계 인증을 켰습니다')
expect(done.text()).toContain('메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.')
expect(done.text()).toContain('Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다.')
expect(done.get('a').text()).toBe('앱 비밀번호 만들기')
expect(done.get('a').attributes('href')).toBe('/settings/account/app-passwords')
expect(document.activeElement).toBe(done.element)
@@ -668,7 +668,7 @@ describe('AccountTwoFactorPage — 필수인 직원 (13단계)', () => {
expect(wrapper.get('[data-two-factor-locked]').text()).toBe('회사 정책에 따라 끌 수 없습니다')
expect(wrapper.find('[data-two-factor-grace]').exists()).toBe(false)
const notes = wrapper.get('[data-two-factor-notes]').text()
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.')
expect(notes).toContain('휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.')
expect(notes).toContain('휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.')
})
})
@@ -119,11 +119,11 @@ describe('MailBlocksPage', () => {
expect(empty?.[1]).not.toMatch(/display:\s*none/)
})
it('비었으면 "차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다."', async () => {
it('비었으면 "차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다."', async () => {
const wrapper = await mountPage({ blocks: [] })
expect(wrapper.get('[data-block-empty]').text()).toBe(
'차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
'차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다.',
)
expect(wrapper.get('[data-block-count]').text()).toBe('0 / 500')
})
@@ -150,12 +150,12 @@ describe('MailBlocksPage', () => {
it.each([
['형식', 'kim@', '메일 주소나 @도메인 형식으로 써 주세요.'],
['이미 차단함', 'NEWS@cloudbrief.example', '이미 차단한 주소입니다.'],
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
['사내 도메인', '@arasystem.it.kr', '내 주소 · 사내 도메인은 차단할 수 없습니다.'],
['내 주소', 'ara.kim@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'],
['사내 도메인', '@arasystem.it.kr', '내 주소와 사내 도메인은 차단할 수 없습니다.'],
[
'여러 사람이 쓰는 메일 서비스',
'@gmail.com',
'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
'gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요.',
],
])('보내기 전 검사 — %s: 칸 아래 오류(빨강) · 잘못된 입력, 보내지 않는다', async (_label, value, text) => {
const wrapper = await mountPage()
@@ -247,7 +247,7 @@ describe('MailBlocksPage', () => {
})
describe('엔진에서 우리 규칙이 꺼져 있으면(isActive: false) 머리 아래 노란 줄 — 단추 없음', () => {
const INACTIVE = '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.'
const INACTIVE = '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.'
it('차단한 주소가 있을 때만 보인다(role=status) — 켜져 있거나 비었으면 없다', async () => {
const off = await mountPage({ isActive: false })
@@ -237,24 +237,24 @@ describe('MailFilterPage', () => {
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
'옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요.',
])
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
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-count]').text()).toBe('필터 5개를 만들었고, 50개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.')
expect(wrapper.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
'·위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
'·이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
'·스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
'·전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
'·부재중 자동 응답은 필터와 함께 동작합니다.',
'·위에 있는 필터부터 차례로 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
'·이미 받은 메일도 정리하려면 필터를 저장할 때 [받은편지함에 이미 있는 메일에도 적용]을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
'·스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
'·전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
'·필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
])
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('「받은편지함에 이미 있는 메일에도 적용」')
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('[받은편지함에 이미 있는 메일에도 적용]')
expect(wrapper.get('.settings-page__description').text()).toBe(
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
@@ -403,7 +403,7 @@ describe('MailFilterPage', () => {
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
'‘${’는 필터에 쓸 수 없습니다. 이 글자를 빼고 넣어 주세요.',
)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
expect(document.activeElement).toBe(value.element)
@@ -477,7 +477,7 @@ describe('MailFilterPage', () => {
const select = wrapper.get('#mail-filter-move-target')
const options = select.findAll('option')
expect(options.map((option) => option.text())).toEqual([
'(지워진 메일함) — 다른 메일함을 고르세요',
'지워진 메일함이니 다른 메일함을 골라 주세요',
'받은편지함',
'거래처',
'전체 공지',
@@ -721,7 +721,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('다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.')
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
@@ -820,7 +820,7 @@ describe('MailFilterPage', () => {
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
const runs = () =>
requests.filter((request) => request.url === '/mail-filter-runs').map((request) => request.data as MailFilterRunRequest)
const HELP = '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.'
const HELP = '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.'
it('할 일 아래 — 창을 열 때마다 꺼져 있고 설명은 칸의 aria-describedby, 전달도 하면 설명에 더한다 · 상자 아래 스팸 한 줄', async () => {
const { wrapper } = await mountPage()
@@ -831,7 +831,7 @@ describe('MailFilterPage', () => {
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
expect(applyHelp(wrapper)).toBe(HELP)
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.')
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 분류된 메일은 필터를 거치지 않습니다.')
// 6단계의 안내 띠는 없다
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
@@ -852,7 +852,7 @@ describe('MailFilterPage', () => {
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-filter-apply] label').classes()).toContain('is-disabled')
expect(applyHelp(wrapper)).toBe('전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.')
expect(applyHelp(wrapper)).toBe('전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다.')
await wrapper.get('#mail-filter-read').setValue(true)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
@@ -863,7 +863,7 @@ describe('MailFilterPage', () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r5')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.')
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.')
await click(wrapper.get('#mail-filter-cancel'))
await openRule(wrapper, 'r2')
@@ -927,7 +927,7 @@ describe('MailFilterPage', () => {
{ ...rule, cursor: 'c1' },
])
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인'],
['progress', '‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,500 / 8,412통 확인'],
])
second.finish(step(3912, 7, 0, null))
@@ -936,7 +936,7 @@ describe('MailFilterPage', () => {
expect(useToastStore().toasts).toHaveLength(1)
expect(done).toMatchObject({
kind: 'success',
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
message: '‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.',
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
})
expect(toasts()).not.toContain('필터를 저장했습니다.')
@@ -952,7 +952,7 @@ describe('MailFilterPage', () => {
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
await click(wrapper.get('#mail-filter-cancel'))
// 다른 화면으로 가도 이어 한다
@@ -960,7 +960,7 @@ describe('MailFilterPage', () => {
wrapper.unmount()
first.finish(step(8412, 12, 0, null))
await flushPromises()
expect(toasts()).toEqual(['‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'])
expect(toasts()).toEqual(['‘결재 알림’ 필터로 받은편지함의 메일 12통을 정리했습니다.'])
expect(useToastStore().toasts[0]?.action).toBeNull()
const { wrapper: again } = await mountPage()
@@ -981,7 +981,7 @@ describe('MailFilterPage', () => {
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
await flushPromises()
expect(runs().map((request) => request.cursor)).toEqual([null])
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.'])
useToastStore().clear()
const leaving = deferred()
@@ -997,7 +997,7 @@ describe('MailFilterPage', () => {
leaving.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
await flushPromises()
expect(runs().map((request) => request.cursor)).toEqual([null])
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
expect(toasts()).toEqual(['‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.'])
})
it('저장하는 중에 세션이 바뀌면 적용 요청을 보내지 않는다 — 알림도 없다', async () => {
@@ -158,7 +158,7 @@ describe('MailLargeFilesPage', () => {
expect(view.get('[data-large-usage]').text()).toBe('6.2 / 10 GB')
expect(view.get('meter').attributes('aria-valuetext')).toBe('62%')
expect(view.get('.large-usage__note').text()).toBe(
'보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
'보낸 링크 4.3 GB, 임시 저장했거나 올리다 멈춘 파일 1.9 GB입니다. 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
)
})
@@ -199,7 +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) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본을 열면 첨부 칸에 지운 파일로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
@@ -56,13 +56,13 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(wrapper.text()).toContain('새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다.')
})
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … 「허용」을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
it('묻기 전 — 칸은 비었고 "켜면 브라우저가 … [허용]을 눌러 주세요.", 보일 것 · 시험 알림은 흐리게', async () => {
const wrapper = await mountPage()
expect(enableInput(wrapper).checked).toBe(false)
expect(enableInput(wrapper).disabled).toBe(false)
expect(wrapper.get('#notify-enabled-note').text()).toBe(
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
'켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
)
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
expect(wrapper.get('#notify-test').attributes('disabled')).toBeDefined()
@@ -101,7 +101,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
expect(enableInput(wrapper).disabled).toBe(true)
const note = wrapper.get('#notify-enabled-note')
expect(note.text()).toBe(
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
)
expect(note.classes()).toContain('notify-form__note--danger')
expect(useMailNotifyStore().settings.enabled).toBe(false)
@@ -200,7 +200,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('끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.')
await subject.setValue(false)
expect(useMailNotifyStore().settings.showSubject).toBe(false)
@@ -213,7 +213,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const status = () => wrapper.get('#notify-realtime-status')
expect(wrapper.get('h2').text()).toBe('실시간 연결')
expect(status().text()).toBe('연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().text()).toBe('연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.')
expect(status().find('.realtime-link__dot').classes()).toContain('is-live')
expect(wrapper.find('#notify-realtime-notice').exists()).toBe(false)
@@ -225,7 +225,7 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
// 끊긴 동안 — 상태 줄 바로 아래, "저절로 다시 잇습니다…" 안내 앞
expect(wrapper.get('#notify-realtime-notice').text()).toBe('연결될 때까지 바탕화면 알림은 오지 않습니다.')
expect(wrapper.find('[role="status"] #notify-realtime-notice').exists()).toBe(true)
expect(wrapper.text()).toContain('저절로 다시 잇습니다.')
expect(wrapper.text()).toContain('저절로 다시 연결합니다.')
realtime.setStatus('connecting')
await flushPromises()
@@ -238,9 +238,9 @@ describe('MailNotifyPage (설정 › 메일 › 알림)', () => {
const notes = wrapper.get('[data-notify-notes]')
expect(notes.findAll('p').map((note) => note.findAll('span').at(-1)?.text())).toEqual([
'알림 설정은 이 브라우저에만 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
'알림 설정은 이 브라우저에만 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
'웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
'웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
'웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
])
expect(notes.get('strong').text()).toBe('이 브라우저에만')
})
@@ -78,15 +78,15 @@ describe('MailSendReadPage', () => {
['30초', 'false'],
])
expect(wrapper.get('#send-read-undo-hint').text()).toBe(
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다.',
)
expect(wrapper.findAll('.send-read__option-label').map((label) => label.text())).toEqual([
'늘 묻기',
'주소록 · 사내 직원의 메일은 늘 보기',
'주소록과 사내 직원의 메일은 늘 보기',
'늘 보기',
])
expect(checkedImages(wrapper)).toEqual(['trusted'])
expect(wrapper.get('[data-images-note]').text()).toContain('사내 메일 서버가 대신 받아 보여 줍니다')
expect(wrapper.get('[data-images-note]').text()).toContain('외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다.')
expect(wrapper.get('[data-settings-status]').text()).toBe('')
})
@@ -189,7 +189,7 @@ describe('MailSignaturePage', () => {
isForNew: true,
isForReply: false,
})
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.')
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.')
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
await wrapper.get('#signature-for-new').setValue(false)
@@ -466,10 +466,10 @@ describe('MailSignaturePage', () => {
expect(replyMode.attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.')
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 인용한 원문 위에 서명을 넣습니다.')
await wrapper.get('#signature-for-reply').setValue(false)
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.')
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장과 전달에는 서명을 넣지 않습니다.')
})
})
@@ -102,7 +102,7 @@ describe('MailVacationPage', () => {
// 엔진이 바이트로 막는 한도 안 — 한글로 채워도 저장된다 (계약 2절 "부재중 자동 응답"의 "길이 한도의 근거")
expect(wrapper.get('#vacation-subject').attributes('maxlength')).toBe('150')
expect(wrapper.get('#vacation-body').attributes('maxlength')).toBe('600')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있습니다 — 자동으로 답하지 않습니다.')
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe('꺼져 있어 자동으로 답하지 않습니다.')
})
it('저장한 값으로 채운다 — 날짜는 브라우저 시간대의 날짜로, 미리 보기는 보낸 사람 · 제목 · 내용', async () => {
@@ -112,7 +112,7 @@ describe('MailVacationPage', () => {
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시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준).',
'시작일 0시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
)
expect(value(wrapper, '#vacation-subject')).toBe(SAVED.subject)
const preview = wrapper.get('[data-vacation-preview]')
@@ -120,7 +120,7 @@ describe('MailVacationPage', () => {
expect(preview.get('[data-vacation-preview-subject]').text()).toBe(SAVED.subject)
expect(preview.get('[data-vacation-preview-body]').element.textContent).toBe(SAVED.textBody)
expect(wrapper.get('[data-vacation-preview-note]').text()).toBe(
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
'받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
)
})
@@ -215,7 +215,7 @@ describe('MailVacationPage', () => {
expect(puts()).toEqual([{ ...SAVED, isEnabled: false, textBody: longBody }])
expect(wrapper.find('#vacation-body-error').exists()).toBe(false)
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
expect(useMailVacationStore().vacation?.isEnabled).toBe(false)
expect(value(wrapper, '#vacation-body')).toBe(longBody)
})
@@ -227,7 +227,7 @@ describe('MailVacationPage', () => {
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
expect(wrapper.get('#vacation-save-status').text()).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
// 설정한 적 없는 계정을 그대로 꺼 둔 채 저장해도 된다
const { wrapper: fresh } = await mountPage()
@@ -279,7 +279,7 @@ describe('MailVacationPage', () => {
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
const INACTIVE_TEXT =
'켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.'
'다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.'
it('켜 두었는데 isActive: false 면 머리 아래 노란 줄(단추 없음) — 저장하면 isActive 없이 보내고 응답의 값으로 내린다', async () => {
const { wrapper } = await mountPage({
+8 -8
View File
@@ -108,11 +108,11 @@ describe('SharePage', () => {
// 이름과 주소 사이 띄어쓰기는 괄호 칸 안에 둔다 — 좁은 화면은 괄호와 함께 감추고 주소를 다음 줄로 (디자인 원본 SharePage "김아라 <ara.kim@…>")
expect(view.get('.share__intro').text()).toContain('보낸 사람 김아라 <ara.kim@arasystem.it.kr> · ARA SYSTEM')
expect(view.get('.share__intro').text()).toContain(
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간) · 30일 남음',
'한국 시간으로 2026년 11월 29일(일)까지 내려받을 수 있습니다. 30일 남았습니다.',
)
expect(view.findAll('.share-file__meta').map((meta) => meta.text())).toEqual([
'1.2 GB · 보안 검사 · 이상 없음',
'612 MB · 보안 검사 · 이상 없음',
'1.2 GB · 보안 검사 결과 이상 없음',
'612 MB · 보안 검사 결과 이상 없음',
])
const download = view.get<HTMLAnchorElement>('[data-share-file="f1"] a.share-file__download')
expect(download.attributes('href')).toBe(`/api/public-links/${TOKEN}/files/f1`)
@@ -122,11 +122,11 @@ describe('SharePage', () => {
)
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
expect(view.get('.share__english').text()).toBe(
'Files from 김아라 (ara.kim@arasystem.it.kr) — download by Nov 29, 2026 (KST). This page never asks for a login or password.',
'Files from 김아라 (ara.kim@arasystem.it.kr). Download by Nov 29, 2026 (KST). This page will never ask you to log in or enter a password.',
)
// 메일 제목 · 본문 · 받는 사람 · 받은 수는 보이지 않는다
expect(view.text()).not.toContain('회 받음')
expect(document.title).toBe('김아라 님이 보낸 파일 — ARA SYSTEM')
expect(document.title).toBe('김아라 님이 보낸 파일 · ARA SYSTEM')
})
it('링크 정보는 쿠키 없이 · 캐시 없이 · 주소를 싣지 않고 묻는다 — 검색 엔진 · referrer 막기 머리도 둔다', async () => {
@@ -192,7 +192,7 @@ describe('SharePage', () => {
const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'scanning' }] }) }, { link: publicLink() })
expect(view.get('[data-share-view="scanning"] h1').text()).toBe('보안 검사 중입니다')
expect(view.get('.share__english').text()).toBe('Security scan in progress — please check again in a few minutes.')
expect(view.get('.share__english').text()).toBe('A security scan is in progress. Please check again in a few minutes.')
await view.get('[data-share-recheck]').trigger('click')
await flushPromises()
@@ -213,7 +213,7 @@ describe('SharePage', () => {
const infected = view.get('[data-share-file="f1"]')
expect(infected.classes()).toContain('share-file--danger')
expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험 — 막았습니다')
expect(infected.get('.share-file__meta').text()).toBe('1.2 GB · 위험한 내용이 있어 막았습니다')
expect(infected.find('.share-file__download').exists()).toBe(false)
const unscannable = view.get('[data-share-file="f2"]')
expect(unscannable.classes()).toContain('share-file--warning')
@@ -287,7 +287,7 @@ describe('SharePage', () => {
expect(view.get('h1').text()).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 1개 (1.2 GB)')
expect(view.find('.share__email').exists()).toBe(false)
expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 — ARA SYSTEM')
expect(document.title).toBe('ara.kim@arasystem.it.kr 님이 보낸 파일 · ARA SYSTEM')
})
it('불러오는 중 — 자리 그림은 숨기고 "불러오는 중"을 화면 낭독기에 읽어 준다 (리뷰 L9)', async () => {
+1 -1
View File
@@ -267,7 +267,7 @@ watch(
<span class="share__org"> · {{ APP_NAME }}</span>
</p>
<p class="share__line">
<strong>{{ untilText }}</strong>{{ untilTail }}
{{ SHARE_PAGE_TEXT.untilBefore }}<strong>{{ untilText }}</strong>{{ untilTail }}
</p>
</div>
<ul class="share__files">
@@ -57,7 +57,7 @@ export const LOGIN_TWO_FACTOR_TEXT = {
codeLabel: '인증 앱에 표시된 6자리 코드',
submit: '확인',
submitting: '확인하는 중…',
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
note: 'Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.',
/** 코드 단계에 10분 넘게 머문 뒤 [확인] — 첫 단계의 위쪽 상자 */
expired: '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.',
} as const
@@ -70,30 +70,30 @@ export const TWO_FACTOR_TEXT = {
off: '꺼짐',
offHint: '지금은 로그인할 때 비밀번호만 확인합니다.',
on: '켜짐',
onSince: (day: string) => `켜짐 — ${day}부터`,
onSince: (day: string) => `${day}부터 켜짐`,
onHint: '로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다.',
enable: '2단계 인증 켜기',
disable: '2단계 인증 끄기',
warningTitle: '켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다',
/** 경고 글 — 가운데 「앱 비밀번호」는 앱 비밀번호 화면 링크 */
warningBefore:
'Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 ',
'Outlook, 썬더버드, 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 ',
warningLink: '앱 비밀번호',
warningAfter: '를 만들어 두면 편합니다.',
stepsTitle: '켜는 차례',
stepsTitle: '켜는 방법',
steps: [
'휴대폰에 인증 앱을 설치합니다 — Google Authenticator, Microsoft Authenticator 처럼 6자리 코드를 만드는(TOTP) 앱이면 됩니다.',
'휴대폰에 인증 앱을 설치합니다. Google Authenticator나 Microsoft Authenticator처럼 6자리 코드를 만드는 앱이면 됩니다.',
'[2단계 인증 켜기]를 누르고 화면의 QR 코드를 인증 앱으로 찍습니다.',
'앱에 표시된 6자리 코드와 지금 비밀번호를 넣으면 켜집니다. 그다음부터 로그인할 때마다 코드를 묻습니다.',
],
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다.',
noteChangePhone: '휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요. 끌 때 지금 휴대폰의 코드가 필요합니다.',
noteLostPhone: '휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.',
noteMailClientBefore: '메일 프로그램은 ',
noteMailClientLink: '앱 비밀번호',
noteMailClientAfter: '로 연결합니다.',
/** 켠 직후 머리 아래 안내 상자 — 이 화면을 떠나면 사라진다 */
enabledTitle: '2단계 인증을 켰습니다',
enabledText: '메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다.',
enabledText: 'Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램은 다시 연결할 때 앱 비밀번호가 필요합니다.',
enabledAction: '앱 비밀번호 만들기',
disabled: '2단계 인증을 껐습니다.',
} as const
@@ -102,17 +102,17 @@ export const TWO_FACTOR_TEXT = {
export const TWO_FACTOR_ENABLE_TEXT = {
title: '2단계 인증 켜기',
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
scanHint: '인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다.',
scanHint: '인증 앱에서 [+] → [QR 코드 스캔]을 고른 뒤 이 그림을 찍어 주세요.',
qrLabel: '인증 앱에 등록할 QR 코드',
keyLabel: '찍을 수 없으면 이 키를 입력하세요',
keyLabel: '찍을 수 없으면 이 키를 입력해 주세요',
copy: '복사',
copyLabel: '키 복사',
copied: '키를 복사했습니다.',
copyFailed: '키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요.',
/** 앱에 저장될 이름 — 서버의 otpauthUrl 과 같은 이름(계약 5절 "정한 것" 1) */
savedAs: (email: string) => `앱에는 「ARA SYSTEM (${email})」로 저장됩니다.`,
savedAs: (email: string) => `앱에는 ‘ARA SYSTEM (${email})’로 저장됩니다.`,
/** 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다 (README "켜기 창") */
discardNote: '켜지 않고 닫으면 이 키는 버립니다 — 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
discardNote: '켜지 않고 닫으면 이 키는 더 쓸 수 없습니다. 앱에 먼저 저장했다면 그 항목을 지워 주세요.',
loading: 'QR 코드를 만드는 중…',
codeLabel: '앱에 표시된 6자리 코드',
passwordLabel: '지금 비밀번호',
@@ -139,12 +139,12 @@ export const TWO_FACTOR_DISABLE_TEXT = {
export const APP_PASSWORD_TEXT = {
title: '앱 비밀번호',
description:
'Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다.',
'Outlook, 썬더버드, 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면 기기를 잃어버렸을 때 그 비밀번호만 지울 수 있습니다.',
count: (count: string, max: string) => `${count} / ${max}개`,
create: '앱 비밀번호 만들기',
/** 5개를 모두 만들었을 때 머리 아래 한 줄 — [만들기]를 눌러도 이 글을 읽어 준다 */
full: (max: string) => `${max}개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.`,
listLabel: '앱 비밀번호 — 최근에 만든 차례',
listLabel: '앱 비밀번호 목록, 최근에 만든 순서',
columnName: '이름',
columnCreated: '만든 날',
columnLastUsed: '마지막 사용',
@@ -154,20 +154,20 @@ export const APP_PASSWORD_TEXT = {
deleteLabel: (name: string) => `‘${name}’ 앱 비밀번호 지우기`,
emptyTitle: '만든 앱 비밀번호가 없습니다',
emptyHint:
'Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다.',
'Outlook이나 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결할 수 있습니다.',
settingsTitle: '메일 프로그램 설정값',
incoming: '받는 메일 (IMAP)',
outgoing: '보내는 메일 (SMTP)',
username: '사용자 이름',
usernameNote: ' — 메일 주소 전체',
usernameNote: ' · 메일 주소 전체',
password: '비밀번호',
passwordNote: '여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
passwordNote: '웹메일 비밀번호가 아닌, 여기서 만든 앱 비밀번호',
port: (port: number) => `포트 ${port}`,
notes: [
'앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다.',
'앱 비밀번호는 메일 프로그램에서 메일을 읽고 보낼 때만 씁니다. 웹메일 로그인에는 쓸 수 없습니다.',
'2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.',
'휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 메일 프로그램마다 새로 만들어 주세요.',
'휴대폰이나 PC를 잃어버렸다면 그 기기의 앱 비밀번호를 바로 지워 주세요.',
],
deleted: (name: string) => `‘${name}’ 앱 비밀번호를 지웠습니다.`,
} as const
@@ -177,7 +177,7 @@ export const APP_PASSWORD_CREATE_TEXT = {
title: '앱 비밀번호 만들기',
nameLabel: '이름',
namePlaceholder: '예: 회사 PC 아웃룩',
nameHelp: '어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.',
nameHelp: '어느 프로그램이나 기기에서 쓰는지 알아볼 수 있게 지어 주세요. 40자까지 쓸 수 있습니다.',
empty: '이름을 넣어 주세요.',
duplicate: '같은 이름의 앱 비밀번호가 있습니다.',
submit: '만들기',
@@ -187,7 +187,7 @@ export const APP_PASSWORD_CREATE_TEXT = {
/** 만든 결과 창 (모달 620 · 처음 초점 [복사]) — 값은 이 창에서 한 번만 */
export const APP_PASSWORD_RESULT_TEXT = {
title: '앱 비밀번호를 만들었습니다',
message: (name: string) => `‘${name}’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
message: (name: string) => `‘${name}’ 앱 비밀번호입니다. 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.`,
copy: '복사',
copyLabel: '앱 비밀번호 복사',
lengthNote: (length: string) => `${length}자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.`,
@@ -235,13 +235,13 @@ export const LOGIN_HISTORY_TEXT = {
unknownBrowser: '알 수 없는 브라우저',
unknownValue: '알 수 없음',
noOthers: '다른 곳에 로그인된 브라우저가 없습니다. ',
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램은 ',
sessionsNoteBefore: '웹메일 로그인만 보입니다. 메일 프로그램의 연결을 끊으려면 ',
sessionsNoteLink: '앱 비밀번호',
sessionsNoteAfter: '를 지워 끊습니다.',
sessionEnded: (device: string) => `‘${device}’의 로그인을 끊었습니다.`,
othersEnded: (count: string) => `다른 곳의 로그인 ${count}곳을 끊었습니다.`,
sessionsNoteAfter: '를 지워 주세요.',
sessionEnded: (device: string) => `‘${device}’에서 로그아웃했습니다.`,
othersEnded: (count: string) => `다른 ${count}곳에서 로그아웃했습니다.`,
/** 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음) — 이 브라우저까지 끊었다 */
allEnded: (count: string) => `이 브라우저를 포함한 ${count}곳의 로그인을 끊었습니다.`,
allEnded: (count: string) => `이 브라우저를 포함해 ${count}곳에서 로그아웃했습니다.`,
eventsTitle: '최근 로그인',
eventsHintAll: '최근 90일 · 웹메일과 메일 프로그램',
eventsHintFailures: (count: string) => `최근 90일 · 실패 ${count}번`,
@@ -257,34 +257,34 @@ export const LOGIN_HISTORY_TEXT = {
methodSmtp: '메일 프로그램 · SMTP',
appPassword: (name: string) => `앱 비밀번호 ‘${name}’`,
success: '성공',
successTwoFactor: '성공 · 2단계 인증',
successTwoFactor: '2단계 인증으로 성공',
failure: '실패',
failureWrongPassword: '실패 — 비밀번호 틀림',
failureWrongCode: '실패 — 코드 틀림',
failureAppPasswordRequired: '실패 — 앱 비밀번호 필요',
failureWrongPassword: '비밀번호가 틀려 실패',
failureWrongCode: '코드가 틀려 실패',
failureAppPasswordRequired: '앱 비밀번호가 필요해 실패',
more: '더 보기',
moreHint: '최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다.',
moreHint: '최근 순서로 20개씩 보입니다. 90일이 지난 기록은 지워집니다.',
emptyFailuresTitle: '최근 90일 동안 실패한 로그인이 없습니다',
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 봅니다.',
emptyFailuresHint: '[전체]를 누르면 성공한 로그인도 볼 수 있습니다.',
emptyAllTitle: '최근 90일 동안 로그인 기록이 없습니다',
emptyAllHint: '기록은 이 기능을 배포한 날부터 쌓입니다.',
emptyAllHint: '이 기능이 생긴 날부터 기록이 쌓입니다.',
} as const
/** [다른 곳 모두 로그아웃] 확인 (alertdialog 400 · 처음 초점 「취소」) */
export const END_OTHER_SESSIONS_TEXT = {
title: '다른 곳의 로그인을 모두 끊을까요?',
title: '다른 곳에서 모두 로그아웃할까요?',
/** "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 …" — 이름은 세 곳까지, 넘으면 "외 n곳" */
message: (count: string, names: string) =>
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.`,
`이 브라우저를 뺀 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.`,
/**
* 서버가 이 브라우저를 알아보지 못했을 때(지금 이 브라우저 줄이 없음 — 서버는 이 요청의 세션을 몰라 모두 끊는다, 계약 5절) —
* 이 브라우저도 로그아웃된다고 알린다
*/
titleAll: '이 브라우저를 포함해 모든 곳의 로그인을 끊을까요?',
titleAll: '이 브라우저를 포함해 모든 곳에서 로그아웃할까요?',
messageAll: (count: string, names: string) =>
`이 브라우저를 알아보지 못해 이 브라우저를 포함한 ${count}곳(${names})의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
`이 브라우저를 알아보지 못해서 이 브라우저를 포함한 ${count}곳(${names})의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.`,
moreNames: (count: string) => `외 ${count}곳`,
note: '메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.',
note: '메일 프로그램은 로그아웃되지 않습니다. 연결을 끊으려면 앱 비밀번호를 지워 주세요.',
confirm: '모두 로그아웃',
cancel: '취소',
} as const
+6 -6
View File
@@ -14,7 +14,7 @@ export const PASSWORD_CHANGE_TIMEOUT_MS = 30 * 1000
export const PASSWORD_TEXT = {
title: '비밀번호',
description: '웹메일과 휴대폰 메일 앱(IMAP)이 함께 쓰는 메일 계정의 비밀번호입니다.',
description: '웹메일에 로그인할 때와 휴대폰 메일 앱을 연결할 때 함께 쓰는 비밀번호입니다.',
/** 2단계 인증을 켠 직원의 머리 설명 (12단계 — 디자인 원본 SettingsTwoFactor "비밀번호 — 2단계 인증을 켠 직원") */
descriptionTwoFactor: '웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.',
current: '지금 비밀번호',
@@ -24,14 +24,14 @@ export const PASSWORD_TEXT = {
showCurrent: '지금 비밀번호 보기',
showNext: '새 비밀번호 보기',
/** 새 비밀번호 아래 안내 — 규칙에 맞지 않으면 이 글이 빨간 오류로 바뀐다 */
hint: '8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
hint: '8자 이상이고 지금 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
mismatch: '새 비밀번호가 서로 다릅니다.',
/** 2단계 인증을 켠 직원만 — 양식 맨 아래 (코드가 30초마다 바뀌어 먼저 넣으면 다른 칸을 쓰는 동안 지날 수 있다) */
code: '인증 앱 코드',
codeHint: '코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.',
codeHint: '코드는 30초마다 바뀌니 다른 칸을 다 채운 뒤에 넣어 주세요.',
submit: '비밀번호 바꾸기',
done: '비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다.',
noteSessions: '바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다.',
done: '비밀번호를 바꿨습니다. 다른 기기와 브라우저에서는 로그아웃했습니다.',
noteSessions: '바꾸면 이 브라우저를 빼고 다른 기기와 브라우저의 웹메일에서 로그아웃됩니다.',
/** 모든 직원 (12단계) — 비밀번호를 바꾸면 서버가 그 직원의 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") */
noteAppPasswords: '앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
noteAppPasswords: '앱 비밀번호도 모두 지워지니, 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요.',
} as const
+20 -20
View File
@@ -1,5 +1,5 @@
// 주소록 상수 — 계약 3절 "주소록 — 8단계" · 디자인 README "8단계 — 주소록" (디자인 원본 Contacts · ContactsEdit · ContactsMail)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 보드의 글 그대로다(보드에 없던 것은 README "구현하며 정한 보완"으로 옮긴다).
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 README "화면 문구 — 사용자에게 말하듯"(2026-11-05)을 따른다 — 보드의 옛 글과 다를 수 있다.
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
@@ -114,9 +114,9 @@ export const CONTACT_GROUP_MENU_TEXT = {
/** 상단 바의 찾기 칸 — 지금 보는 목록 안에서 찾는다 */
export const CONTACTS_SEARCH_TEXT = {
label: '주소록에서 찾기',
placeholder: '이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)',
placeholder: '이름, 주소, 회사, 전화번호, 초성(ㅂㅈㅎ)으로 찾기',
/** 사내 목록은 이름 · 주소로만 찾는다 (계약 3절 "찾기") */
directoryPlaceholder: '이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)',
directoryPlaceholder: '이름, 주소, 초성(ㅂㅈㅎ)으로 찾기',
clear: '찾을 글 지우기',
} as const
@@ -126,7 +126,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: '구성원 넣기',
@@ -142,11 +142,11 @@ export const CONTACTS_LIST_TEXT = {
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
export const CONTACTS_EMPTY_TEXT = {
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchHint: '이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.',
directorySearchHint: '이름 · 주소의 일부나 이름의 초성으로 찾습니다.',
searchHint: '이름, 주소, 회사, 전화번호의 일부나 이름의 초성으로 찾을 수 있습니다.',
directorySearchHint: '이름이나 주소의 일부, 이름의 초성으로 찾을 수 있습니다.',
firstTitle: '아직 연락처가 없습니다',
firstHint: '자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.',
firstNote: '메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.',
firstHint: '자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾을 수 있습니다.',
firstNote: '메일을 읽다가 [⋯] → [보낸 사람을 주소록에 추가]로도 넣을 수 있습니다.',
groupTitle: (name: string) => `‘${name}’ 그룹에 아직 아무도 없습니다`,
groupHint: '구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다.',
directoryTitle: {
@@ -183,9 +183,9 @@ export const CONTACT_DETAIL_TEXT = {
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
directoryHint:
'사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요.',
noneTitle: '연락처를 고르세요',
noneHint: '목록에서 고르면 주소 · 전화번호가 여기에 보입니다.',
'사내 목록은 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호나 메모를 적어 두려면 내 주소록에 추가해 주세요.',
noneTitle: '연락처를 골라 주세요',
noneHint: '목록에서 고르면 주소와 전화번호가 여기에 보입니다.',
leaveToList: '목록으로',
loading: '연락처 불러오는 중',
} as const
@@ -208,13 +208,13 @@ export const CONTACT_DIALOG_TEXT = {
emailAddress: (ordinal: string) => `${ordinal} 이메일`,
emailRemove: (ordinal: string) => `${ordinal} 이메일 빼기`,
addEmail: '이메일 더하기',
emailHint: '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
emailHint: '5개까지 넣을 수 있고, 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다.',
phone: '전화번호',
phoneKind: (ordinal: string) => `${ordinal} 전화번호의 종류`,
phoneNumber: (ordinal: string) => `${ordinal} 전화번호`,
phoneRemove: (ordinal: string) => `${ordinal} 전화번호 빼기`,
addPhone: '전화번호 더하기',
phoneHint: '5개까지',
phoneHint: '5개까지 넣을 수 있습니다.',
company: '회사',
department: '부서',
title: '직책',
@@ -228,15 +228,15 @@ export const CONTACT_DIALOG_TEXT = {
emailsTooMany: (max: string) => `이메일은 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
phonesTooMany: (max: string) => `전화번호는 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
groups: '그룹',
required: '이름이나 이메일 가운데 하나는 있어야 합니다.',
required: '이름이나 이메일 가운데 하나는 넣어 주세요.',
save: '저장',
cancel: '취소',
remove: '이 연락처 지우기',
emailInvalid: '이메일 형식이 올바르지 않습니다.',
emailRepeated: '같은 주소를 두 번 넣었습니다.',
phoneInvalid: '전화번호 형식이 올바르지 않습니다. 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.',
phoneInvalid: '전화번호 형식이 올바르지 않습니다. 숫자와 띄어쓰기, + - ( ) . # * 기호만 쓸 수 있습니다.',
/** 같은 주소가 이미 주소록에 있다 — 막지 않는다 (who: "강서윤(거래처)") */
duplicate: (who: string) => `이미 주소록에 있는 주소입니다 — ${who}. 그래도 저장할 수 있습니다.`,
duplicate: (who: string) => `주소록에 있는 ${who}의 주소와 같습니다. 그래도 저장할 수 있습니다.`,
duplicateView: '그 연락처 보기',
} as const
@@ -273,8 +273,8 @@ export const CONTACT_MEMBERS_TEXT = {
mine: '내 연락처',
staff: '사내 직원',
search: '구성원으로 넣을 연락처 찾기',
searchPlaceholder: '이름 · 주소 — 초성도 됩니다',
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. 「사내 직원」에서 고르면 그 직원을 내 주소록에도 추가합니다(이름 · 주소).',
searchPlaceholder: '이름, 주소, 초성으로 찾기',
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. [사내 직원]에서 고르면 그 직원의 이름과 주소를 내 주소록에도 추가합니다.',
picked: (count: string) => `${count}명 고름`,
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
add: '넣기',
@@ -294,7 +294,7 @@ export const CONTACTS_TOAST_TEXT = {
groupCreated: (name: string) => `‘${name}’ 그룹을 만들었습니다.`,
groupRenamed: '그룹 이름을 바꿨습니다.',
groupRemoved: (name: string, count: string | null) =>
count === null ? `‘${name}’ 그룹을 지웠습니다.` : `‘${name}’ 그룹을 지웠습니다 — 연락처 ${count}명은 그대로 있습니다.`,
count === null ? `‘${name}’ 그룹을 지웠습니다.` : `‘${name}’ 그룹을 지웠습니다. 그룹에 있던 연락처 ${count}명은 그대로 남아 있습니다.`,
groupRestored: '그룹을 되돌렸습니다.',
/** 응답의 addedCount — 고른 수와 다를 수 있다(이미 구성원 · 그룹에 넣을 수 없는 카드는 서버가 건너뛴다) */
membersAdded: (count: string, group: string) => `${count}명을 ‘${group}’ 그룹에 넣었습니다.`,
@@ -317,7 +317,7 @@ export const CONTACT_READER_TEXT = {
/** 받는 사람 추천의 주소록 · 그룹 (디자인 원본 ContactsMail "받는 사람 추천") */
export const RECIPIENT_CONTACT_TEXT = {
/** 그룹 줄의 설명 */
groupDetail: (count: string) => `${count}명 · 고르면 한꺼번에 넣습니다`,
groupDetail: (count: string) => `고르면 ${count}명을 한꺼번에 넣습니다`,
groupLoading: (group: string) => `‘${group}’ 그룹의 구성원을 불러오는 중…`,
groupAdded: (group: string, count: string, field: string) => `‘${group}’ 그룹의 ${count}명을 ${field}에 넣었습니다.`,
groupNone: (group: string, field: string) => `‘${group}’ 그룹에서 ${field}에 더 넣을 주소가 없습니다.`,
+54 -52
View File
@@ -81,13 +81,13 @@ export const SHARE_SCAN_POLL_LIMIT = 20
// ── 화면 글 — 쓰기 화면 (디자인 원본 ComposeLargeFiles) ─────────────
export const LARGE_FILE_TEXT = {
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)" */
hintTail: (maxFile: string, maxCount: string) => `더 큰 파일은 링크로 보냅니다 (파일당 ${maxFile} · ${maxCount}개까지)`,
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다" */
hintTail: (maxFile: string, maxCount: string) => `더 큰 파일은 파일당 ${maxFile}, ${maxCount}개까지 링크로 보냅니다`,
normalGroup: (count: string) => `메일에 담는 파일 ${count}개`,
normalTotal: (used: string, limit: string) => `합계 ${used} / ${limit}`,
linkGroup: (count: string) => `링크로 보내는 파일 ${count}개`,
linkGroupHint: (days: string, until: string) =>
`받는 사람은 보낸 날부터 ${days}일 동안 내려받습니다 — 오늘 보내면 ${until}까지`,
`받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다. 오늘 보내면 ${until}까지입니다.`,
/** 보관 사용량이 80%를 넘을 때 링크 묶음 머리 오른쪽 */
quotaWarning: (usage: string) => `보관 ${usage}`,
/** 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status) */
@@ -111,22 +111,22 @@ export const LARGE_FILE_TEXT = {
queued: '차례를 기다리는 중',
reconnecting: (progress: string) => `연결이 끊겨 다시 잇는 중… · ${progress} 올림`,
paused: (progress: string) => `멈춤 · ${progress} 올림`,
stalled: (uploaded: string) => `올리지 못하고 멈췄습니다 — 올린 ${uploaded} 는 남아 있습니다.`,
stalled: (uploaded: string) => `올리지 못하고 멈췄습니다. 지금까지 올린 ${uploaded}는 남아 있습니다.`,
stalledEmpty: '올리지 못하고 멈췄습니다.',
waitingFile: (progress: string) =>
`올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · ${progress} 올림`,
repickMismatch: '고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요.',
scanning: (size: string) => `${size} · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다`,
unscannable: (size: string) => `${size} · 검사하지 못함 — 암호가 걸린 압축 파일 등`,
`올리다 멈춤 · ${progress} 올림 · 같은 파일을 다시 고르면 이어서 올립니다`,
repickMismatch: '고른 파일의 이름이나 크기가 멈춘 파일과 다릅니다. 같은 파일을 골라 주세요.',
scanning: (size: string) => `${size} · 보안 검사 중이지만 보낼 수 있습니다`,
unscannable: (size: string) => `${size} · 암호가 걸린 압축 파일처럼 검사하지 못한 파일`,
keptInDraft: (size: string, until: string) => `${size} · 이 임시 저장본에 ${until}까지 남습니다`,
infected: '위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요.',
missingExpired: '기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요.',
missingDeleted: '설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.',
missingUnknown: '서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.',
missingExpired: '기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요.',
missingDeleted: '[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.',
missingUnknown: '파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.',
tooLarge: (max: string) => `파일 하나는 ${max} 까지 보낼 수 있습니다.`,
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다.`,
overQuota: (limit: string) =>
`대용량 보관 한도(${limit})를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
`대용량 첨부는 ${limit}까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
quotaLink: '대용량 첨부 보기',
unnamed: '(이름 없는 파일)',
/** 화면 낭독기 — 10% 마다 */
@@ -136,21 +136,21 @@ export const LARGE_FILE_TEXT = {
/** [멈춤] 단추 이름 — "설계도_v3.dwg 올리기 멈춤" */
pauseLabel: (name: string) => `${name} 올리기 멈춤`,
/** 서버가 올리던 파일을 잊었다(404 — 마지막 조각 뒤 24시간 · 설정에서 지움) — 멈춘 줄 글. [다시 시도]는 처음부터 새로 올린다 */
restartNeeded: '서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다.',
restartNeeded: '올리던 파일을 찾을 수 없습니다. [다시 시도]를 누르면 처음부터 올립니다.',
} as const
/** [보내기]를 막는 까닭 — 단추 옆 글 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다") */
export const SEND_BLOCK_TEXT = {
uploading: (count: string) => `파일 ${count}개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.`,
incomplete: '다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요.',
unsendable: '보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.',
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다 — 빼 주세요.`,
uploading: (count: string) => `파일 ${count}개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다.`,
incomplete: '다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요.',
unsendable: '보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.',
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다. 몇 개를 빼 주세요.`,
} as const
/** 인용 속 원래 링크의 상태 — 첨부 칸 아래 글자로만 (디자인 README "11단계 › 읽기 화면 › 전달 쓰기 화면") */
export const QUOTED_LINK_TEXT = {
active: (count: string, until: string) => `원래 메일의 대용량 첨부 ${count}개는 인용 속 링크로 함께 갑니다 — ${until}까지`,
activeUnknownCount: (until: string) => `인용 속 대용량 첨부 링크는 함께 갑니다 — ${until}까지`,
active: (count: string, until: string) => `원래 메일의 대용량 첨부 ${count}개는 인용 속 링크로 함께 갑니다. ${until}까지 받을 수 있습니다.`,
activeUnknownCount: (until: string) => `인용 속 대용량 첨부 링크도 함께 갑니다. ${until}까지 받을 수 있습니다.`,
revoked: (label: string) => `인용 속 ${label} 링크는 공유가 멈춰 받을 수 없습니다.`,
expired: (label: string) => `인용 속 ${label} 링크는 기간이 지나 받을 수 없습니다.`,
/** 파일 이름을 모를 때 */
@@ -172,18 +172,18 @@ export const MESSAGE_LARGE_FILE_TEXT = {
until: (date: string) => `${date}까지`,
downloads: (count: string) => `${count}회 받음`,
notDownloaded: '아직 받지 않음',
scanning: '보안 검사 중 — 끝나면 받을 수 있습니다',
infected: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등',
expired: (date: string) => `기간 지남 — ${date}까지였습니다`,
revoked: '공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
scanning: '보안 검사가 끝나면 받을 수 있습니다',
infected: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
unscannable: '암호가 걸린 압축 파일처럼 검사하지 못한 파일',
expired: (date: string) => `받을 수 있는 기간이 ${date}에 끝났습니다`,
revoked: '보낸 사람이 링크를 끊어 받을 수 없습니다',
/** 보낸 사람 자신 — 끊은 날을 알면 그 날짜(디자인 원본 MailReadLargeFiles 셋째 줄), 모르면 날짜 없이 */
revokedOwn: (date: string | null) => (date ? `공유 멈춤 — ${date}에 끊었습니다` : '공유 멈춤 — 링크를 끊었습니다'),
limitReached: '횟수 초과 — 더 내려받을 수 없습니다',
missing: '찾을 수 없음 — 더 내려받을 수 없습니다',
revokedOwn: (date: string | null) => (date ? `${date}에 링크를 끊었습니다` : '링크를 끊었습니다'),
limitReached: '받을 수 있는 횟수를 넘어 더 내려받을 수 없습니다',
missing: '파일을 찾을 수 없어 더 내려받을 수 없습니다',
download: (name: string) => `${name} 다운로드`,
/** 보낸 사람 자신 — 첨부 칸 아래 한 줄 */
linkNote: (count: string) => `링크로 보낸 파일 ${count}개 — 받는 사람 모두 같은 링크로 받습니다.`,
linkNote: (count: string) => `링크로 보낸 파일 ${count}개는 받는 사람 모두 같은 링크로 받습니다.`,
linkNoteCut: (count: number) =>
count === 1 ? '끊으면 받을 수 없습니다.' : count === 2 ? '끊으면 둘 다 받을 수 없습니다.' : '끊으면 모두 받을 수 없습니다.',
copyLink: '링크 복사',
@@ -192,7 +192,7 @@ export const MESSAGE_LARGE_FILE_TEXT = {
revoke: '링크 끊기',
/** 본문의 덩이를 접는 줄 (본문 틀 안 — 스크립트 없이 details · summary) */
foldLabel: '본문의 대용량 첨부 안내',
foldHint: '(접어 둠) — 파일은 아래 첨부 칸에 있습니다',
foldHint: '파일은 아래 첨부 칸에 있어 접어 두었습니다',
foldOpen: '펼치기',
foldClose: '접기',
} as const
@@ -206,7 +206,7 @@ export const LARGE_FILE_REVOKE_TEXT = {
/** 설정 — 메일 제목으로 */
messageWithSubject: (subject: string, count: string) =>
`‘${subject}’의 파일 ${count}개를 받는 사람 모두 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.`,
nameSeparator: ' · ',
nameSeparator: ', ',
confirm: '링크 끊기',
cancel: '취소',
done: '링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.',
@@ -224,16 +224,16 @@ export const LARGE_FILE_SETTINGS_TEXT = {
description: (days: string) =>
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
usageLabel: '보관 사용량',
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent} · 보내지 않은 파일 ${unsent}(임시 저장 · 올리다 멈춤).`,
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent}, 임시 저장했거나 올리다 멈춘 파일 ${unsent}입니다.`,
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
unsentTitle: (count: string) => `보내지 않은 파일 ${count}개`,
unsentHint: (size: string) => `${size} · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다`,
unsentHint: (size: string) => `${size} · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일이라 지우면 바로 공간이 생깁니다`,
unsentList: '보내지 않은 파일',
unsentUploading: '올리다 멈춤',
unsentReady: '다 올림',
unsentScanning: '보안 검사 중',
unsentUnscannable: '검사하지 못함',
unsentInfected: '위험 — 보낼 수 없음',
unsentInfected: '위험해서 보낼 수 없음',
partUploaded: (bytes: string) => `${bytes} 올림`,
remainUntil: (when: string) => `${when}까지 남음`,
afterLastChunk: '마지막으로 올린 뒤 24시간',
@@ -241,12 +241,12 @@ 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}’ 파일을 지웠습니다.`,
linksTitle: (count: string) => `보낸 링크 ${count}개`,
linksHint: '메일마다 한 덩이 · 최근에 보낸 차례',
linksHint: '메일마다 하나씩, 최근에 보낸 순서',
linksList: '링크로 보낸 메일',
sentLine: (day: string, count: string, total: string) => `${day} 보냄 · 파일 ${count}개 · ${total}`,
until: (date: string) => `${date}까지`,
@@ -269,7 +269,7 @@ export const LARGE_FILE_SETTINGS_TEXT = {
notDownloaded: '아직 받지 않음',
lastDownloaded: (when: string) => `마지막 ${when}`,
more: '더 보기',
moreHint: '최근에 보낸 차례 · 20개씩. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
emptyHint: (max: string, days: string) =>
`메일 쓰기에서 ${max} 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
@@ -279,31 +279,33 @@ export const LARGE_FILE_SETTINGS_TEXT = {
// ── 화면 글 — 공개 내려받기 화면 (디자인 원본 SharePage) ─────────────
export const SHARE_PAGE_TEXT = {
barTitle: '파일 내려받기',
/** 탭 제목 — "김아라 님이 보낸 파일 — ARA SYSTEM" */
tabTitle: (sender: string, app: string) => `${sender} 님이 보낸 파일 — ${app}`,
tabTitleDefault: (app: string) => `파일 내려받기 — ${app}`,
/** 탭 제목 — "김아라 님이 보낸 파일 · ARA SYSTEM" (앱의 다른 탭 제목과 같은 가운뎃점) */
tabTitle: (sender: string, app: string) => `${sender} 님이 보낸 파일 · ${app}`,
tabTitleDefault: (app: string) => `파일 내려받기 · ${app}`,
title: (sender: string, count: string, total: string) => `${sender} 님이 보낸 파일 ${count}개 (${total})`,
senderLabel: '보낸 사람',
/** 기한 줄 — "한국 시간으로 **2026년 11월 29일(일)까지** 내려받을 수 있습니다. 30일 남았습니다." (날짜 조각만 굵게) */
untilBefore: '한국 시간으로 ',
until: (date: string) => `${date}까지`,
untilTail: (days: string) => ` 내려받을 수 있습니다 (한국 시간) · ${days}일 남음`,
untilTailLastDay: ' 내려받을 수 있습니다 (한국 시간) · 오늘까지',
clean: '보안 검사 · 이상 없음',
untilTail: (days: string) => ` 내려받을 수 있습니다. ${days}일 남았습니다.`,
untilTailLastDay: ' 내려받을 수 있습니다. 오늘이 마지막 날입니다.',
clean: '보안 검사 결과 이상 없음',
scanning: '보안 검사 중',
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요.',
infected: '위험 — 막았습니다',
unscannable: '암호가 걸린 압축 파일처럼 안을 볼 수 없어 검사하지 못한 파일입니다. 보낸 사람을 아는 경우에만 열어 주세요.',
infected: '위험한 내용이 있어 막았습니다',
limitReached: '내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요.',
missing: '찾을 수 없는 파일입니다 — 더 내려받을 수 없습니다.',
missing: '파일을 찾을 수 없어 더 내려받을 수 없습니다.',
download: '내려받기',
downloadLabel: (name: string) => `${name} 내려받기`,
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도하세요 — 받은 곳부터 이어서 받습니다.',
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다.',
noLogin: '이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
reportBefore: '받을 이유가 없는 파일이면 ',
reportAfter: ' 로 알려 주세요.',
englishActive: (sender: string, email: string, until: string) =>
`Files from ${sender} (${email}) — download by ${until} (KST). This page never asks for a login or password.`,
`Files from ${sender} (${email}). Download by ${until} (KST). This page will never ask you to log in or enter a password.`,
scanningTitle: '보안 검사 중입니다',
scanningMessage: '파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요.',
scanningEnglish: 'Security scan in progress — please check again in a few minutes.',
scanningEnglish: 'A security scan is in progress. Please check again in a few minutes.',
recheck: '다시 확인',
infectedTitle: '내려받을 수 없는 파일입니다',
infectedMessage: '보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다.',
@@ -314,16 +316,16 @@ export const SHARE_PAGE_TEXT = {
expiredEnglish: (date: string) => `This link expired on ${date} (KST).`,
revokedTitle: '보낸 사람이 공유를 멈췄습니다',
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
revokedEnglish: 'The sender stopped sharing these files.',
revokedEnglish: 'The sender has stopped sharing these files.',
notFoundTitle: '찾을 수 없는 링크입니다',
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
notFoundEnglish: 'Link not found — check that the whole address was copied.',
notFoundEnglish: 'Link not found. Please check that the full address was copied.',
tooManyTitle: '요청이 너무 많습니다',
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
tooManyEnglish: 'Too many requests — please try again shortly.',
tooManyEnglish: 'Too many requests. Please try again in a moment.',
errorTitle: '화면을 불러오지 못했습니다',
errorMessage: '잠시 뒤 다시 시도해 주세요.',
errorEnglish: 'Something went wrong — please try again shortly.',
errorEnglish: 'Something went wrong. Please try again in a moment.',
retry: '다시 시도',
/** 누르기 직전에 다시 물었더니 받을 수 없게 됐다 (role=status) */
unavailableNow: '이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.',
+7 -7
View File
@@ -56,7 +56,7 @@ export const MAIL_BLOCK_TEXT = {
// ── 차단 확인 창 ──
dialogTitle: '보낸 사람 차단',
dialogMessage: '앞으로 오는 메일을 스팸메일함으로 보냅니다. 부재중 자동 응답도 보내지 않습니다.',
dialogMessage: '앞으로 오는 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다.',
scopeGroup: '차단할 범위',
scopeAddress: '이 주소만',
scopeDomain: '이 도메인 전체',
@@ -65,7 +65,7 @@ export const MAIL_BLOCK_TEXT = {
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
settingsHint: '차단한 주소는 설정 › 메일 › 차단한 주소에서 풀 수 있습니다.',
settingsHint: '차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.',
confirm: '차단',
cancel: '취소',
@@ -90,23 +90,23 @@ export const MAIL_BLOCK_TEXT = {
/** "3 / 500" */
count: (count: string, max: string) => `${count} / ${max}`,
description:
'이 주소에서 오는 메일은 스팸메일함으로 갑니다. 부재중 자동 응답도 보내지 않습니다. 「@도메인」은 그 도메인에서 오는 메일 모두입니다.',
'차단한 주소에서 온 메일은 스팸메일함으로 옮겨지고, 부재중 자동 응답도 보내지 않습니다. @spam.example 처럼 @로 시작해 넣으면 그 도메인에서 오는 메일을 모두 막습니다.',
addLabel: '차단할 주소',
addPlaceholder: 'kim@example.com 또는 @example.com',
add: '차단',
/** 칸 아래 오류 */
invalid: '메일 주소나 @도메인 형식으로 써 주세요.',
duplicate: '이미 차단한 주소입니다.',
own: '내 주소 · 사내 도메인은 차단할 수 없습니다.',
publicDomain: 'gmail.com 처럼 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없습니다 — 주소로 막아 주세요.',
own: '내 주소와 사내 도메인은 차단할 수 없습니다.',
publicDomain: 'gmail.com처럼 여러 사람이 쓰는 메일 서비스는 도메인 전체를 막을 수 없습니다. 주소를 하나씩 넣어 막아 주세요.',
full: `${MAIL_BLOCK_MAX}개까지 차단할 수 있습니다.`,
/** 목록 줄 아래 작은 글 — "10월 29일 차단" · "도메인 전체 · 10월 21일 차단" */
blockedOn: (day: string) => `${day} 차단`,
domainBlockedOn: (day: string) => `도메인 전체 · ${day} 차단`,
empty: '차단한 주소가 없습니다. 메일의 ⋯ › 보낸 사람 차단으로도 막을 수 있습니다.',
empty: '차단한 주소가 없습니다. 메일을 읽다가 [⋯] → [보낸 사람 차단]으로도 막을 수 있습니다.',
/**
* 머리 아래 노란 줄 — 차단한 주소가 있는데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false). 단추는 없다 — 차단하거나 풀면 다시 켜진다
* (보드에 없다 — 7단계 필터 · 부재중 자동 응답의 노란 줄과 같은 결)
*/
inactive: '지금은 차단이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 주소를 차단하거나 풀면 다시 켜집니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 차단이 되지 않습니다. 주소를 차단하거나 풀면 다시 켜집니다.',
} as const
+33 -35
View File
@@ -1,6 +1,6 @@
// 설정 › 계정 › 메일 프로그램 연결 (13단계 — 계약 6절 "메일 프로그램 연결", 디자인 README "13단계 › 설정 › 계정 › 메일 프로그램 연결")
// API 는 없다 — 이 상수로만 그린다. 설정값(호스트 · 포트)은 앱 비밀번호 화면과 같은 MAIL_CLIENT_SETTINGS 하나.
// 차례 글은 README 그대로다 — 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게. 판에 따라 메뉴 이름이 조금 다를 수 있다.
// 차례 글은 README 그대로다 — 누르거나 고르는 것은 [ ], 호스트 이름은 굵게(README "화면 문구 — 사용자에게 말하듯"). 판에 따라 메뉴 이름이 조금 다를 수 있다.
import { MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security'
import type { IconName } from '@/shared/constants/icons'
@@ -22,28 +22,28 @@ export interface MailClientGuide {
export const MAIL_CLIENTS_TEXT = {
title: '메일 프로그램 연결',
description: '아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.',
description: '아웃룩, 썬더버드, 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.',
settingsTitle: '메일 프로그램 설정값',
/** 설정값 상자의 비밀번호 줄 — 2단계 인증을 켠 직원 (상태를 모르면 이쪽 — 더 안전한 쪽) */
passwordOn: '앱 비밀번호 — 웹메일 비밀번호가 아닙니다',
passwordOn: '웹메일 비밀번호가 아닌 앱 비밀번호',
/** 설정값 상자의 비밀번호 줄 — 켜지 않은 직원 */
passwordOff: '앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다',
passwordOff: '앱 비밀번호를 권합니다. 2단계 인증을 켜지 않았다면 웹메일 비밀번호로도 연결됩니다.',
requiredTitle: '비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요',
requiredText: '2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다.',
requiredText: '2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣어 주세요.',
recommendTitle: '앱 비밀번호로 연결하기를 권합니다',
recommendText:
'지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다.',
createAppPassword: '앱 비밀번호 만들기',
stepsTitle: '프로그램별 연결 차례',
stepsHint: '쓰는 프로그램을 누르면 차례가 펼쳐집니다.',
stepsTitle: '프로그램별 연결 방법',
stepsHint: '쓰는 프로그램을 누르면 연결 방법이 펼쳐집니다.',
/** 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 이 줄을 지운다 */
cloudWarning:
'새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
'새 Outlook과 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다. 회사 정책에 따라 쓰지 않는 것이 좋습니다.',
listLabel: '메일 프로그램',
notes: [
'연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요.',
'POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요.',
'연결이 안 되면 사용자 이름에 메일 주소 전체를 넣었는지, 비밀번호 칸에 앱 비밀번호 40자를 띄어쓰기 없이 넣었는지 확인해 주세요.',
'POP3로는 연결할 수 없으니 계정 유형은 IMAP을 골라 주세요.',
'웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워지니, 프로그램마다 새로 만들어 넣어 주세요.',
'그래도 안 되면 관리자에게 문의해 주세요.',
],
/** 앱 비밀번호 화면의 설정값 상자 아래 한 줄 — 「메일 프로그램 연결」이 이 화면 링크 */
@@ -52,23 +52,21 @@ export const MAIL_CLIENTS_TEXT = {
shortcutAfter: '에서 볼 수 있습니다.',
} as const
const PORTS = `${imapPort} · ${smtpPort}`
/** 프로그램별 연결 차례 — 아웃룩 · 새 Outlook · 썬더버드 · iPhone · 안드로이드 (맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다) */
export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
{
id: 'outlook',
name: '아웃룩 (Windows)',
description: 'Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook',
description: 'Microsoft 365나 Outlook 2016 이후의 데스크톱 Outlook',
icon: 'monitor',
steps: [
['Outlook 에서 [파일] → [계정 추가]를 누릅니다.'],
['메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다.'],
['계정 유형에서 「IMAP」을 고릅니다.'],
['Outlook에서 [파일] → [계정 추가]를 누릅니다.'],
['메일 주소를 넣고 [고급 옵션] → [내 계정을 수동으로 설정]을 고른 뒤 [연결]을 누릅니다.'],
['계정 유형에서 [IMAP]을 고릅니다.'],
[
'받는 메일 · 보내는 메일 모두 서버 ',
'받는 메일과 보내는 메일 모두 서버는 ',
{ strong: host },
`, 포트는 ${PORTS}, 암호화 방법은 「${security}」를 넣고 [다음]을 누릅니다.`,
`로 넣습니다. 포트는 받는 메일 ${imapPort}, 보내는 메일 ${smtpPort}이고, 암호화 방법은 [${security}]를 고른 뒤 [다음]을 누릅니다.`,
],
['비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다.'],
],
@@ -76,24 +74,24 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
{
id: 'new-outlook',
name: '새 Outlook',
description: 'Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다',
description: '메일이 Microsoft 클라우드를 거치는 Windows의 새 Outlook',
icon: 'monitor',
steps: [
['[설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다.'],
['메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다.'],
['[고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다.'],
['[계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다.'],
['메일 주소를 넣고 [계속]을 누른 뒤 공급자에서 [IMAP]을 고릅니다.'],
['[고급 설정 표시]를 누르고 받는 서버와 보내는 서버에 위 설정값을, 비밀번호 칸 두 곳에 모두 앱 비밀번호를 넣습니다.'],
['[계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다. 위의 주의 글을 읽고 정해 주세요.'],
],
},
{
id: 'thunderbird',
name: '썬더버드',
description: 'Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다',
description: 'Windows와 Mac에서 주소와 앱 비밀번호만 넣으면 됩니다',
icon: 'monitor',
steps: [
['썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다.'],
['이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다.'],
['메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다.'],
['이름과 메일 주소를 넣고, 비밀번호 칸에 앱 비밀번호를 넣은 뒤 [계속]을 누릅니다.'],
['메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다. [IMAP]이 보이면 [완료]를 누릅니다.'],
['찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다.'],
],
},
@@ -103,29 +101,29 @@ export const MAIL_CLIENT_GUIDES: readonly MailClientGuide[] = [
description: '기본 메일 앱',
icon: 'smartphone',
steps: [
['[설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일])'],
['이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다.'],
['[설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. iOS 18에서는 [설정] → [앱] → [메일]에서 시작합니다.'],
['이름, 메일 주소, 설명을 넣고, 암호 칸에 앱 비밀번호를 넣은 뒤 [다음]을 누릅니다.'],
[
'「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 ',
'[IMAP]을 고르고, 받는 메일 서버와 보내는 메일 서버의 호스트 이름에 ',
{ strong: host },
', 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다.',
', 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다. 보내는 메일 서버 칸이 선택 사항으로 보여도 꼭 채워 주세요.',
],
[`[다음] → [저장]을 누릅니다. 포트는 스스로 ${PORTS} 로 맞춥니다.`],
[`[다음] → [저장]을 누릅니다. 포트는 받는 메일 ${imapPort}, 보내는 메일 ${smtpPort}로 저절로 맞춰집니다.`],
],
},
{
id: 'android',
name: '안드로이드',
description: 'Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값',
description: 'Gmail 앱 기준이며, 삼성 이메일 앱 같은 다른 메일 앱도 같은 값을 넣습니다',
icon: 'smartphone',
steps: [
['Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다.'],
['메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다.'],
['메일 주소를 넣고 [수동 설정] → [개인(IMAP)]을 고릅니다.'],
['비밀번호에 앱 비밀번호를 넣습니다.'],
[
'받는 서버 · 보내는 서버에 ',
'받는 서버와 보내는 서버는 ',
{ strong: host },
`, 포트는 ${PORTS}, 보안 유형은 「${security}」를 넣고 [다음]을 누릅니다.`,
`로 넣습니다. 포트는 받는 서버 ${imapPort}, 보내는 서버 ${smtpPort}이고, 보안 유형은 [${security}]를 고른 뒤 [다음]을 누릅니다.`,
],
],
},
+1 -1
View File
@@ -28,7 +28,7 @@ export const MAIL_BODY_LINE_HEIGHT = 1.75
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
export const EDITOR_PLACEHOLDER = '내용을 입력하세요'
export const EDITOR_PLACEHOLDER = '내용을 입력해 주세요'
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
+32 -32
View File
@@ -84,39 +84,39 @@ export const MAIL_FILTER_SUMMARY_TEXT = {
export const MAIL_FILTER_TEXT = {
title: '필터',
description:
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일을 정리하고, 저장할 때 고르면 받은편지함에 이미 있는 메일도 함께 정리합니다.',
create: '새 필터',
/**
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
* 되돌리기 · 다른 곳에서 지워진 규칙의 저장이 한도를 넘을 때(오류 알림 · 고치기 창 안)
*/
fullNote: `필터는 ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.`,
listLabel: '필터 — 위에서부터 차례로 적용',
count: (count: string) => `필터 ${count}개 · ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 행을 누르면 고칩니다.`,
listLabel: '필터 목록, 위에 있는 필터부터 차례로 정리합니다',
count: (count: string) => `필터 ${count}개를 만들었고, ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 줄을 누르면 고칠 수 있습니다.`,
// 단추 이름 · 알림의 quoted 는 따옴표로 묶은 이름(quotedRuleTitle — 길면 자르고, 이름에 작은따옴표가 있으면 큰따옴표로 묶는다)
/** 켜기 스위치(role="switch") — 켜짐 · 꺼짐은 aria-checked 가 알린다 (이름은 바꾸지 않는다) */
switchLabel: (quoted: string) => `${quoted} 필터 사용`,
editLabel: (quoted: string) => `${quoted} 필터 고치기`,
moveUp: (quoted: string) => `${quoted} 위로`,
moveDown: (quoted: string) => `${quoted} 아래로`,
missingMailbox: '옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
missingMailbox: '옮길 메일함이 지워져 메일이 받은편지함으로 들어옵니다. 눌러서 고쳐 주세요.',
emptyTitle: '아직 필터가 없습니다',
emptyText:
'보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.',
'보낸 사람, 받는 사람, 제목, 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음이나 중요로 표시하거나, 동료에게 전달합니다.',
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
reload: '다시 불러오기',
inactive: '지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 지금은 필터와 부재중 자동 응답이 되지 않습니다.',
reactivate: '다시 켜기',
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게 (7단계 — 디자인 원본 MailFilterApply 의 글)
noteOrderStrong: '위에서부터 차례로',
noteOrderAfter: ' 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
noteApplyBefore: '이미 받은 메일에도 적용하려면 필터를 저장할 때 ',
noteApplyStrong: '「받은편지함에 이미 있는 메일에도 적용」',
noteApplyAfter: '을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
noteSpam: '스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
noteOrderStrong: '위에 있는 필터부터 차례로',
noteOrderAfter: ' 메일을 정리합니다. 메일함으로 옮기는 필터에 맞은 메일은 그 아래 필터를 거치지 않습니다. 순서는 ▲ ▼ 로 바꿀 수 있습니다.',
noteApplyBefore: '이미 받은 메일도 정리하려면 필터를 저장할 때 ',
noteApplyStrong: '[받은편지함에 이미 있는 메일에도 적용]',
noteApplyAfter: '을 골라 주세요. 받은편지함에 있는 메일만 그 필터 하나로 정리합니다.',
noteSpam: '스팸으로 분류된 메일은 필터를 거치지 않고 그대로 스팸메일함으로 갑니다.',
noteForward:
'전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
noteVacation: '부재중 자동 응답은 필터와 함께 동작합니다.',
'전달은 새로 오는 메일만 사내 주소로 합니다. 메일 한 통은 한 곳으로만 전달되고, 전달하는 필터가 여럿이면 위에 있는 필터가 먼저입니다. 전달해도 내 메일함에 그대로 남습니다.',
noteVacation: '필터가 메일을 옮겨도 부재중 자동 응답은 그대로 보냅니다.',
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
saved: '필터를 저장했습니다.',
removed: (quoted: string) => `${quoted} 필터를 지웠습니다.`,
@@ -133,18 +133,18 @@ export const MAIL_FILTER_DIALOG_TEXT = {
nameOptional: '(선택)',
namePlaceholder: '비워 두면 조건이 이름이 됩니다',
matchLegend: '어떤 메일에',
conditionField: (no: number) => `조건 ${no} — 어디에서`,
conditionOperator: (no: number) => `조건 ${no} — 포함 · 포함하지 않음`,
conditionValue: (no: number) => `조건 ${no} — 찾을 글`,
conditionField: (no: number) => `조건 ${no}의 찾을 곳`,
conditionOperator: (no: number) => `조건 ${no}의 포함 여부`,
conditionValue: (no: number) => `조건 ${no}의 찾을 글`,
conditionRemove: (no: number) => `조건 ${no} 지우기`,
addCondition: '조건 더하기',
conditionHint: `${MAIL_FILTER_MAX_CONDITIONS}개까지 · 글자는 대소문자를 가리지 않습니다`,
conditionHint: `조건은 ${MAIL_FILTER_MAX_CONDITIONS}개까지 넣을 수 있고, 영문 대소문자는 가리지 않습니다`,
actionsLegend: '할 일',
move: '메일함으로 옮기기',
moveTarget: '옮길 메일함',
moveHint: '옮기면 이 아래 필터는 적용하지 않습니다.',
moveHint: '옮긴 메일은 이 아래 필터를 거치지 않습니다.',
/** 옮길 메일함이 지워졌다 — 이 항목이 골라진 채로 보인다 (다시 고를 수는 없다) */
missingMailboxOption: '(지워진 메일함) — 다른 메일함을 고르세요',
missingMailboxOption: '지워진 메일함이니 다른 메일함을 골라 주세요',
markAsRead: '읽음으로 표시',
flag: '중요 표시',
forward: '동료에게 전달',
@@ -157,7 +157,7 @@ export const MAIL_FILTER_DIALOG_TEXT = {
cancel: '취소',
// 칸 아래 빨간 글 — [저장]을 누를 때 검사한다(알림은 띄우지 않는다). 그 밖의 형식 오류(이름 · 찾을 글의 길이 · 제어 문자)는 에러 코드 사전의 VAL_001
conditionEmpty: '비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
conditionVariable: '‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
conditionVariable: '‘${’는 필터에 쓸 수 없습니다. 이 글자를 빼고 넣어 주세요.',
// 전달 주소 — 캔버스에는 바깥 주소만 있다. 빈 칸 · 형식 · 내 주소는 2026-10-28 구현하며 정함 (형식 문구는 서버의 거절 문구와 같다)
forwardEmpty: '전달할 주소를 입력해 주세요.',
forwardInvalid: '전달할 주소 형식이 올바르지 않습니다.',
@@ -173,17 +173,17 @@ export const MAIL_FILTER_DIALOG_TEXT = {
*/
export const MAIL_FILTER_APPLY_TEXT = {
label: '받은편지함에 이미 있는 메일에도 적용',
help: '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.',
help: '저장하면 받은편지함에서 조건에 맞는 메일을 찾아 바로 정리합니다.',
/** 전달도 하는 필터 — 설명 뒤에 더한다 */
helpForward: ' 전달은 새로 오는 메일에만 합니다.',
forwardOnly: '전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.',
forwardOnly: '전달은 새로 오는 메일에만 해서, 전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
/** 옮길 메일함이 지금 메일함 목록에 없다 — 읽음 · 중요 없이 옮기기만 (7단계 리뷰로 더한 경우) */
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.',
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.',
/** 받은편지함으로 옮기기만 — 이미 받은편지함에 있는 메일이다 (7단계 리뷰로 더한 경우) */
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
/** 상자 아래 한 줄 */
spam: '스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.',
spam: '스팸으로 분류된 메일은 필터를 거치지 않습니다.',
} as const
/**
@@ -191,26 +191,26 @@ export const MAIL_FILTER_APPLY_TEXT = {
* quoted 는 따옴표로 묶은 이름(quotedRuleTitle), 수는 formatCount 로 만든 글자. 확인 = 지금까지 확인한 수, 전체 = 확인 + 남은 수(어림)
*/
export const MAIL_FILTER_RUN_TEXT = {
progress: (quoted: string) => `${quoted} 필터를 받은편지함에 적용하는 중`,
progress: (quoted: string) => `${quoted} 필터로 받은편지함을 정리하는 중`,
/** 진행 문구 뒤 — 전체를 아직 모르면(받은편지함 수를 모르고 첫 응답 전) 뺀다 */
progressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
done: (quoted: string, matched: string) => `${quoted} 필터를 받은편지함의 메일 ${matched}통에 적용했습니다.`,
done: (quoted: string, matched: string) => `${quoted} 필터로 받은편지함의 메일 ${matched}통을 정리했습니다.`,
/** 맞는 메일이 없었다 */
none: (quoted: string) => `받은편지함에 ${quoted} 필터에 맞는 메일이 없습니다.`,
/** 옮기는 필터의 끝 알림 단추 — 그 메일함으로 */
viewMailbox: (mailbox: string) => `${mailbox} 보기`,
/** 도중에 멈췄다 — [다시 시도]는 남은 메일부터 이어서 */
stopped: (quoted: string, total: string, scanned: string, matched: string) =>
`${quoted} 필터 — ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다(${matched}통에 적용). 다시 시도해 주세요.`,
`${quoted} 필터로 ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다. 그동안 ${matched}통을 정리했습니다. 다시 시도해 주세요.`,
/** 처음 요청부터 실패했다 — 필터는 저장되어 있다(새로 오는 메일에는 적용된다) */
notApplied: '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.',
notApplied: '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.',
/** 429 REQ_001 — 같은 직원의 적용이 다른 창 · PC 에서 진행 중 */
otherWindow: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
otherWindow: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
/**
* 이 탭에서 다른 필터를 적용하는 중에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함).
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게 한다
*/
busy: '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
retry: '다시 시도',
} as const
+11 -11
View File
@@ -53,26 +53,26 @@ export const MAIL_NOTIFY_TEXT = {
menu: '알림',
title: '알림',
description:
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 두세요.',
'새 메일은 1분을 기다리지 않고 바로 목록에 나타납니다. 다른 창을 보는 동안에도 알고 싶으면 바탕화면 알림을 켜 주세요.',
enable: '새 메일이 오면 바탕화면 알림 보내기',
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다 — 「허용」을 눌러 주세요.',
noteAsk: '켜면 브라우저가 알림을 허락할지 한 번 묻습니다. [허용]을 눌러 주세요.',
noteGranted: '이 브라우저에서 알림이 허용되어 있습니다.',
noteBlocked:
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠) › 알림을 「허용」으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
'브라우저가 이 사이트의 알림을 막고 있습니다. 주소창 왼쪽의 사이트 정보(자물쇠)를 눌러 [알림]을 [허용]으로 바꾼 뒤 이 화면을 새로 고쳐 주세요.',
noteUnsupported: '이 브라우저는 바탕화면 알림을 지원하지 않습니다. 새 메일은 그래도 바로 목록에 나타납니다.',
contentLegend: '알림에 보일 것',
showSubject: '메일 제목도 보이기',
showSubjectHint: '끄면 보낸 사람 이름만 보입니다 — 화면을 다른 사람과 함께 보는 자리라면 꺼 두세요.',
showSubjectHint: '끄면 보낸 사람 이름만 보입니다. 다른 사람과 화면을 함께 보는 자리라면 꺼 두는 것이 좋습니다.',
test: '시험 알림 보내기',
testHint: '바탕화면 오른쪽 아래(맥은 오른쪽 위)에 알림이 뜨면 됩니다.',
testHint: '바탕화면 오른쪽 아래에 알림이 뜨면 됩니다. 맥에서는 오른쪽 위에 뜹니다.',
realtimeTitle: '실시간 연결',
realtimeLive: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
realtimeLive: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
realtimeLiveHint:
'연결할 수 없을 때는 "연결할 수 없어 1분마다 확인합니다."로 바뀌고, 저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
'연결이 끊기면 1분마다 새 메일을 확인하면서 저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
realtimeDown: '연결할 수 없어 1분마다 확인합니다.',
/** 끊긴 동안 — 상태 줄 바로 아래 (2026-10-28 화면 점검 반영) */
realtimeDownNotice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
realtimeDownHint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
realtimeDownHint: '저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
/** 처음 잇는 동안 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
realtimeConnecting: '연결하는 중입니다…',
/** Web Locks · BroadcastChannel 이 없는 브라우저 — 1분 확인만 한다 (캔버스에는 없다 — 2026-10-28 구현하며 정함) */
@@ -80,9 +80,9 @@ export const MAIL_NOTIFY_TEXT = {
/** 아래 안내 상자 — 첫 줄의 "이 브라우저에만"은 굵게 */
noteBrowserBefore: '알림 설정은 ',
noteBrowserStrong: '이 브라우저에만',
noteBrowserAfter: ' 적용됩니다. 다른 PC · 브라우저에서는 그곳에서 따로 켜 주세요.',
noteBrowserAfter: ' 저장됩니다. 다른 PC나 브라우저에서는 그곳에서 따로 켜 주세요.',
noteOpenTab: '웹메일 탭이 하나라도 열려 있어야 알림이 옵니다. 탭을 모두 닫으면 오지 않습니다.',
noteVisible: '웹메일 화면을 보고 있을 때는 바탕화면 알림을 보내지 않습니다 — 목록에 바로 보이므로.',
noteVisible: '웹메일 화면을 보고 있을 때는 새 메일이 목록에 바로 보이므로 바탕화면 알림을 보내지 않습니다.',
// 시험 알림 — 지금 탭에서 바로 보낸다
testTitle: 'ARA 웹메일',
@@ -106,6 +106,6 @@ export const MAIL_NOTIFY_TEXT = {
/** 허락을 받아 켰을 때 (성공 알림) */
enabledToast: '바탕화면 알림을 켰습니다.',
/** 브라우저가 막았을 때 (오류 알림 + 설정 열기) */
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 설정 › 알림에 있습니다.',
blockedToast: '브라우저가 알림을 막고 있습니다. 푸는 방법은 [설정] → [알림]에서 볼 수 있습니다.',
openSettings: '설정 열기',
} as const
@@ -46,10 +46,10 @@ export const SEND_LATER_TEXT = {
customDate: '날짜',
customTime: '시각',
/** zone 은 브라우저가 알려 주는 시간대 이름(Intl — "한국 표준시" 등) */
note: (zone: string) => `이 PC 의 시간(${zone})으로 보냅니다 · 7일 안에서 고를 수 있습니다.`,
note: (zone: string) => `이 PC의 시간(${zone})으로 보내고, 7일 안에서 고를 수 있습니다.`,
/** 시간대 이름을 모를 때 */
noteWithoutZone: '이 PC 의 시간으로 보냅니다 · 7일 안에서 고를 수 있습니다.',
tooSoon: '지금부터 5분 뒤 이후의 시각을 고르세요.',
noteWithoutZone: '이 PC의 시간으로 보내고, 7일 안에서 고를 수 있습니다.',
tooSoon: '5분 뒤보다 이른 시각은 고를 수 없습니다. 더 늦은 시각을 골라 주세요.',
tooLate: '7일 안의 시각만 고를 수 있습니다.',
confirm: '예약',
cancel: '취소',
@@ -68,10 +68,10 @@ export const SEND_LATER_TEXT = {
/** 메일함 패널의 「예약됨」 · 목록 머리 */
mailbox: '예약됨',
/** 목록 머리의 개수 — "2통 · 보낼 시각 차례" */
listCount: (count: string) => `${count}통 · 보낼 시각 차례`,
listCount: (count: string) => `${count}통 · 보낼 시각이 이른 순서`,
/** 목록이 비었을 때 (보드에 없다 — 보낸 · 취소한 뒤 그 자리에서 비면) */
emptyTitle: '예약한 메일이 없습니다',
emptyHint: '쓰기 화면의 [보내기 ▾] › 예약 발송…으로 보낼 시각을 정할 수 있습니다.',
emptyHint: '메일을 쓸 때 [보내기 ▾] → [예약 발송…]으로 보낼 시각을 정할 수 있습니다.',
/** 표 · 목록의 보낼 시각 칸 이름 */
sendAtColumn: '보낼 시각',
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00 에 보낼 메일입니다." */
@@ -97,7 +97,7 @@ export const SEND_LATER_TEXT = {
/** 보내기 취소를 기다리는 동안 */
rowSending: '보내는 중',
/** 아직 보내지 않은 메일의 고르기 칸(꺼짐)의 까닭 — 옮기거나 지워도 엔진은 그 시각에 보낸다 (보드에 없다) */
heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다 — 열어서 취소하거나 삭제해 주세요.',
heldNotSelectable: '보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.',
} as const
/** 보낼 시각 뒤 이만큼 기다렸다 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다 — 엔진이 풀어도 새 메일 확인 · 실시간 알림은 바뀌지 않는다 */
@@ -11,24 +11,24 @@ export const EXTERNAL_IMAGES_OPTIONS: readonly ExternalImagesSetting[] = ['ask',
export const MAIL_SETTINGS_TEXT = {
title: '보내기 · 읽기',
/** 보드에 없다 — 저장 단추가 없으니 고르는 대로 저장된다는 것을 머리에서 알린다 */
description: '고르는 대로 저장합니다. 이 설정은 어느 PC 에서나 같습니다.',
description: '고르는 대로 바로 저장됩니다. 이 설정은 어느 PC에서나 같습니다.',
saved: '저장했습니다',
undoTitle: '보내기 취소',
undoOff: '끔',
undoSeconds: (seconds: number) => `${seconds}초`,
undoHint:
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그동안 메일은 메일 서버에서 기다리므로 창을 닫아도 보내집니다.',
'[보내기]를 누른 뒤 이 시간 동안 [보내기 취소]로 되돌릴 수 있습니다. 그사이 창을 닫아도 시간이 지나면 메일은 그대로 보내집니다.',
imagesTitle: '외부 이미지',
images: {
ask: { label: '늘 묻기', hint: '메일마다 [이미지 보기]를 눌러 봅니다' },
trusted: {
label: '주소록 · 사내 직원의 메일은 늘 보기',
label: '주소록과 사내 직원의 메일은 늘 보기',
hint: '보낸 사람이 내 주소록이나 사내 목록에 있으면 바로 보입니다',
},
always: { label: '늘 보기', hint: '스팸메일함의 메일은 이 설정과 상관없이 묻습니다' },
} satisfies Record<ExternalImagesSetting, { label: string; hint: string }>,
imagesNote:
'외부 이미지는 사내 메일 서버가 대신 받아 보여 줍니다 — 보낸 사람에게 이 PC 의 주소는 드러나지 않습니다. 다만 이미지를 받으면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
'외부 이미지는 회사를 거쳐 불러오므로 보낸 사람은 이 PC의 주소를 알 수 없습니다. 다만 이미지를 불러오면 보낸 사람은 메일을 열어 본 것을 알 수 있습니다.',
} as const
@@ -59,15 +59,15 @@ export const SIGNATURE_TEXT = {
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
fieldLabel: '서명 내용',
hint:
'굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
'굵게, 기울임, 밑줄, 글자 색, 링크, 그림을 쓸 수 있습니다. 그림은 PNG, JPG, GIF 파일을 한 장 300KB 이하로 3장까지 넣을 수 있고 ' +
'붙여 넣거나 끌어다 놓아도 됩니다. 그림은 메일에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
usageLegend: '서명을 넣을 메일',
forNew: '새 메일',
forReply: '답장 · 전달',
forReplyNote: '— 인용한 원문 위에 넣습니다',
forReply: '답장과 전달',
forReplyNote: '인용한 원문 위에 넣습니다',
save: '저장',
saving: '저장하는 중…',
saved: '저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.',
saved: '저장했습니다. 다음에 메일을 쓸 때부터 들어갑니다.',
uploading: '그림을 올리는 중…',
imageSizeLabel: '그림 크기',
imageSizes: { small: '작게', medium: '보통', original: '원본' },
@@ -83,8 +83,8 @@ export const SIGNATURE_TEXT = {
export const SIGNATURE_PREVIEW_NOTES = {
newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
newOff: '새 메일에는 서명을 넣지 않습니다.',
replyOn: '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.',
replyOff: '답장 · 전달에는 서명을 넣지 않습니다.',
replyOn: '답장과 전달에는 인용한 원문 위에 서명을 넣습니다.',
replyOff: '답장과 전달에는 서명을 넣지 않습니다.',
} as const
/**
@@ -17,26 +17,26 @@ 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시부터 끝나는 날 자정까지 답합니다. 시각은 이 브라우저의 시간대를 따릅니다.',
subject: '제목',
body: '내용',
bodyHint: '글자만 보냅니다 (서식 · 그림 없음). 서명은 붙지 않습니다.',
bodyHint: '서식과 그림 없이 글자만 보내고, 서명은 붙지 않습니다.',
save: '저장',
saving: '저장하는 중…',
previewTitle: '보낸 사람이 받는 답장',
previewFrom: '보낸 사람',
previewSubject: '제목',
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙).',
previewNoteOff: '꺼져 있습니다 — 자동으로 답하지 않습니다.',
previewNoteOn: '받은 메일의 보낸 사람에게 위 글로 답합니다. 같은 사람에게는 매번 보내지 않습니다.',
previewNoteOff: '꺼져 있어 자동으로 답하지 않습니다.',
// 저장한 뒤의 상태 글 — 날짜는 "10월 30일(금)" (올해가 아니면 앞에 연도)
savedOff: '저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.',
savedOff: '저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.',
savedUntilOff: '저장했습니다. 끌 때까지 자동으로 답합니다.',
savedRange: (from: string, to: string) => `저장했습니다. ${from}부터 ${to}까지 자동으로 답합니다.`,
savedFrom: (from: string) => `저장했습니다. ${from}부터 자동으로 답합니다.`,
@@ -52,7 +52,7 @@ export const VACATION_TEXT = {
* 머리 아래 노란 줄 (7단계 — 디자인 원본 MailFilterApplyStates) — 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있을 때(isActive: false).
* 단추는 없다 — 저장 단추는 늘 누를 수 있다. 저장하면 필터도 함께 다시 켜진다
*/
inactive: '켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.',
inactive: '다른 곳에서 메일 설정이 바뀌어 켜 둔 자동 응답이 지금은 나가지 않습니다. 아래 [저장]을 누르면 다시 켜집니다.',
} as const
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
+8 -8
View File
@@ -156,18 +156,18 @@ export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
/**
* 받는 사람 일부가 받지 못한 채 보냈을 때 — "보냈습니다" 대신 오류 알림 모양(흰 카드) + [보낸편지함 보기]
* (계약 2절 "받는 사람별 결과" · 디자인 README "발송 · 부재중 결함 수정" — 캔버스에는 없다).
* 문장은 shared/utils/mail-send-result.ts 가 짓는다 — "보냈지만 3명은 받지 못했습니다 — a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
* 문장은 shared/utils/mail-send-result.ts 가 짓는다 — "보냈지만 3명은 받지 못했습니다. a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명"
*/
export const MAIL_PARTIAL_SEND_TEXT = {
/** count 는 formatCount 로 만든 글자, recipients 는 아래 recipient 를 separator 로 이은 것 (+ others) */
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다 — ${recipients}`,
message: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못했습니다. ${recipients}`,
/**
* 엔진이 붙잡아 둔 발송(보내기 취소를 기다리는 중 · 9단계)이면 아직 나가지 않았다 — "받지 못합니다" + [보내기 취소]
* (디자인 원본 MailSendLater: "보냈지만 1명은 받지 못합니다 — 주소 (까닭)")
*/
pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다 — ${recipients}`,
pendingMessage: (count: string, recipients: string) => `보냈지만 ${count}명은 받지 못합니다. ${recipients}`,
/** 예약 발송이면 — "예약했지만 N명은 받지 못합니다 — …" + [예약됨 보기] (보드에 없다) */
scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다 — ${recipients}`,
scheduledMessage: (count: string, recipients: string) => `예약했지만 ${count}명은 받지 못합니다. ${recipients}`,
/** 주소와 까닭 사이를 띄운다 — 긴 주소 뒤에서 줄을 바꿔 까닭 낱말이 쪼개지지 않게 */
recipient: (email: string, reason: string) => `${email} (${reason})`,
separator: ', ',
@@ -266,17 +266,17 @@ export const MAIL_SEARCH_TEXT = {
clearTitle: '검색어 지우기 (Esc)',
close: '검색 닫기',
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
closeTitle: (place: string) => `검색 닫기 — ${place}`,
closeTitle: (place: string) => `검색을 닫고 ${place} 돌아가기`,
title: '검색 결과',
quoted: (query: string) => `‘${query}’`,
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
searching: (query: string) => `‘${query}’ · 찾는 중`,
scopeLabel: '찾을 곳',
scopeAll: '전체 메일함',
scopeAllHint: '휴지통 · 스팸메일함은 빼고 찾습니다',
scopeAllHint: '휴지통과 스팸메일함은 빼고 찾습니다',
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
emptyHint:
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
'낱말 단위로 찾습니다. ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.',
searchAll: '전체 메일함에서 찾기',
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
loading: '찾는 중',
@@ -327,7 +327,7 @@ export const MAIL_SPAM_TEXT = {
/** 스팸메일함 — 받은편지함으로 옮기고 표시를 "스팸 아님"으로 바꾼다 */
notSpam: '스팸 아님',
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로") */
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다 (스팸 아님).`,
notSpamMoved: (count: string, place: string) => `메일 ${count}통을 스팸이 아닌 메일로 표시해 ${place} 옮겼습니다.`,
} as const
/** 읽기 화면 도구 줄의 ⋯ (더 보기 40×44) 메뉴 */
@@ -38,7 +38,7 @@ export const MAILBOX_DIALOG_TEXT = {
createChildTitle: (parent: string) => `‘${parent}’ 안에 새 메일함`,
renameTitle: '메일함 이름 바꾸기',
nameLabel: '이름',
nameHelp: '64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.',
nameHelp: '64자까지 쓸 수 있고, 같은 자리에 같은 이름은 쓸 수 없습니다.',
create: '만들기',
rename: '바꾸기',
cancel: '취소',
@@ -72,7 +72,7 @@ export const MAILBOX_OPERATION_TEXT = {
/** 처리할 것이 없었다 — 다른 탭 · 메일 프로그램이 먼저 했다 */
readNone: (mailbox: string) => `${mailbox}에 안 읽은 메일이 없습니다.`,
emptyProgress: (object: string) => `${object} 비우는 중`,
emptyDone: (object: string, count: string) => `${object} 비웠습니다 — 메일 ${count}통.`,
emptyDone: (object: string, count: string) => `메일 ${count}통을 지워 ${object} 비웠습니다.`,
/** subject 에는 메일함 이름에 '이/가'까지 붙여 넘긴다 ("휴지통이") */
emptyNone: (subject: string) => `${subject} 이미 비어 있습니다.`,
deleteProgress: (mailbox: string) => `‘${mailbox}’ 메일함을 지우는 중`,
@@ -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
@@ -27,34 +27,34 @@ export const LOGIN_PASSWORD_CHANGE_TEXT = {
next: '새 비밀번호',
showNext: '새 비밀번호 보기',
/** 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 */
nextHint: '8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.',
nextHint: '8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.',
confirm: '새 비밀번호 확인',
mismatch: '새 비밀번호가 서로 다릅니다.',
/** 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다) */
code: '인증 앱 코드',
codeHint: '로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.',
codeHint: '로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.',
submit: '비밀번호 바꾸고 계속',
submitting: '바꾸는 중…',
note: '메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.',
note: '아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.',
/** 끝난 뒤 알림 */
done: '새 비밀번호를 정했습니다.',
} as const
/** 로그인 — 2단계 인증 켜기(필수) (디자인 원본 LoginTwoFactorSetup). QR · 키 · [복사]의 글은 12단계 TWO_FACTOR_ENABLE_TEXT */
export const LOGIN_TWO_FACTOR_SETUP_TEXT = {
title: '2단계 인증을 켜야 합니다',
title: '2단계 인증을 켜 주세요',
subtitle: '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.',
scanTitle: '인증 앱으로 QR 코드를 찍어 주세요',
scanHint:
'인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.',
'인증 앱에서 [+] → [QR 코드 스캔]을 고릅니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.',
codeLabel: '앱에 표시된 6자리 코드',
/** 화면 메모리에 지금 비밀번호가 없을 때만 ③ */
passwordLabel: '지금 비밀번호',
showPassword: '지금 비밀번호 보기',
passwordHint: '새로고침해서 다시 넣어야 합니다.',
passwordHint: '새로고침한 뒤에는 지금 비밀번호를 한 번 더 넣어 주세요.',
submit: '켜고 계속',
submitting: '켜는 중…',
note: 'Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.',
note: 'Outlook이나 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.',
/** 켠 뒤 알림 — [앱 비밀번호 만들기](단추가 있는 알림 10초) */
done: '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.',
doneAction: '앱 비밀번호 만들기',
@@ -78,12 +78,12 @@ export const TWO_FACTOR_GRACE_TEXT = {
region: '2단계 인증 안내',
/** "11월 30일까지 2단계 인증을 켜 주세요" (굵게) — 날짜는 시작일 전날, 올해가 아니면 해를 붙인다 */
deadline: (day: string) => `${day}까지 2단계 인증을 켜 주세요`,
barAfter: ' — 그 뒤에는 켜야 로그인할 수 있습니다.',
barAfter: '. 그 뒤에는 켜야 로그인할 수 있습니다.',
action: '지금 켜기',
/** 설정 › 2단계 인증 머리 아래 갈색 상자의 둘째 줄 */
settingsText: '회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다.',
/** 필수인 직원이 켠 뒤 — [2단계 인증 끄기] 대신 */
cannotDisable: '회사 정책에 따라 끌 수 없습니다',
/** 필수인 직원이 켠 뒤 — 아래 안내의 첫 줄 (12단계 「끈 뒤 새 휴대폰으로 다시 켜 주세요」 대신) */
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.',
noteChangePhone: '휴대폰을 바꿀 때는 관리자에게 2단계 인증을 풀어 달라고 요청해 주세요. 다음에 로그인할 때 새 휴대폰으로 다시 켜면 됩니다.',
} as const
@@ -112,7 +112,7 @@ describe('2단계 인증 상태 글', () => {
it('꺼짐 · 켜짐 — 웹메일에서 켠 때가 있으면 "2026년 10월 31일부터"', () => {
expect(twoFactorStateLabel({ isEnabled: false, enabledAt: null })).toBe('꺼짐')
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: null })).toBe('켜짐')
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: '2026-10-31T03:00:00Z' })).toBe('켜짐 — 2026년 10월 31일부터')
expect(twoFactorStateLabel({ isEnabled: true, enabledAt: '2026-10-31T03:00:00Z' })).toBe('2026년 10월 31일부터 켜짐')
})
})
@@ -167,17 +167,17 @@ describe('로그인 기록', () => {
it('결과 글 다섯 가지 — 실패는 isFailure', () => {
expect(loginResultLabel(event({}))).toEqual({ label: '성공', isFailure: false })
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '성공 · 2단계 인증', isFailure: false })
expect(loginResultLabel(event({ isTwoFactor: true }))).toEqual({ label: '2단계 인증으로 성공', isFailure: false })
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongPassword' }))).toEqual({
label: '실패 — 비밀번호 틀림',
label: '비밀번호가 틀려 실패',
isFailure: true,
})
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'wrongCode' }))).toEqual({
label: '실패 — 코드 틀림',
label: '코드가 틀려 실패',
isFailure: true,
})
expect(loginResultLabel(event({ isSuccess: false, failureReason: 'appPasswordRequired' }))).toEqual({
label: '실패 — 앱 비밀번호 필요',
label: '앱 비밀번호가 필요해 실패',
isFailure: true,
})
// 모르는 까닭 — 「실패」만
@@ -195,7 +195,7 @@ describe('다른 곳 모두 로그아웃', () => {
const others = otherSessions(sessions)
expect(others.map((item) => item.id)).toEqual(['a', 'b'])
expect(endOtherSessionsMessage(others)).toBe(
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
'이 브라우저를 뺀 2곳(Edge · Windows, Safari · iOS)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
const many = [
@@ -205,7 +205,7 @@ describe('다른 곳 모두 로그아웃', () => {
session({ id: 'e', device: null }),
]
expect(endOtherSessionsMessage(many)).toBe(
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.',
'이 브라우저를 뺀 5곳(Edge · Windows, Safari · iOS, Firefox · Linux 외 2곳)의 웹메일에서 로그아웃합니다. 그곳에서 다음에 무엇이든 누르면 바로 로그인 화면이 나옵니다.',
)
})
@@ -213,7 +213,7 @@ describe('다른 곳 모두 로그아웃', () => {
const unknown = [session({ id: 'a', device: { browser: 'Edge', os: 'Windows' } }), session({ id: 'b', device: null })]
expect(endOtherSessionsMessage(unknown, true)).toBe(
'이 브라우저를 알아보지 못해 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일 로그인을 끊습니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
'이 브라우저를 알아보지 못해서 이 브라우저를 포함한 2곳(Edge · Windows, 알 수 없는 브라우저)의 웹메일에서 로그아웃합니다. 이 브라우저도 로그아웃되어 로그인 화면으로 돌아갑니다.',
)
})
})
@@ -71,7 +71,7 @@ export function isMobileDevice(device: LoginDevice | null): boolean {
return MOBILE_OS.has(device?.os ?? '')
}
/** 2단계 인증 상태 글 — 「꺼짐」 · 「켜짐 — 2026년 10월 31일부터」(켠 때를 모르면 「켜짐」) */
/** 2단계 인증 상태 글 — 「꺼짐」 · 「2026년 10월 31일부터 켜짐」(켠 때를 모르면 「켜짐」) */
export function twoFactorStateLabel(status: TwoFactorStatus): string {
if (!status.isEnabled) return TWO_FACTOR_TEXT.off
const day = status.enabledAt ? formatLongDate(status.enabledAt) : ''
@@ -127,7 +127,7 @@ export function loginDeviceLabel(event: LoginEvent): string | null {
}
/**
* 로그인 기록의 결과 글 — 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」.
* 로그인 기록의 결과 글 — 「성공」 · 「2단계 인증으로 성공」 · 「비밀번호가 틀려 실패」 · 「코드가 틀려 실패」 · 「앱 비밀번호가 필요해 실패」.
* 실패는 화면이 빨간 굵은 글자로만 보인다 (뱃지 없음)
*/
export function loginResultLabel(event: LoginEvent): { label: string; isFailure: boolean } {
@@ -32,7 +32,7 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
it('검사 중 — 끝나는 날 대신 까닭, 받기는 막는다(aria-disabled) · 검사하지 못함은 받을 수 있다', () => {
expect(messageLargeFileView({ ...FILE, status: 'scanning' })).toEqual({
meta: '1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다',
meta: '1.2 GB · 보안 검사가 끝나면 받을 수 있습니다',
tone: 'normal',
download: 'blocked',
})
@@ -41,27 +41,27 @@ describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeF
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
meta: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
meta: '위험한 내용이 있어 보안 검사에서 막았고, 받을 수 없습니다',
tone: 'danger',
download: 'none',
})
expect(messageLargeFileView({ ...FILE, status: 'expired' })).toEqual({
meta: '1.2 GB · 기간 지남 — 11월 29일까지였습니다',
meta: '1.2 GB · 받을 수 있는 기간이 11월 29일에 끝났습니다',
tone: 'dim',
download: 'none',
})
expect(messageLargeFileView({ ...FILE, status: 'revoked' }).meta).toBe('1.2 GB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다')
expect(messageLargeFileView({ ...FILE, status: 'revoked' }).meta).toBe('1.2 GB · 보낸 사람이 링크를 끊어 받을 수 없습니다')
expect(messageLargeFileView({ ...FILE, status: 'revoked', downloads: { count: 3, lastDownloadedAt: null } }).meta).toBe(
'1.2 GB · 3회 받음 · 공유 멈춤 — 링크를 끊었습니다',
'1.2 GB · 3회 받음 · 링크를 끊었습니다',
)
// 끊은 때(revokedAt)는 보낸 사람 자신에게만 날짜로 보인다(한국 날짜 — 디자인 원본 MailReadLargeFiles 셋째 줄). 받는 사람은 날짜 없이
const revokedAt = '2026-10-30T15:30:00Z'
expect(messageLargeFileView({ ...FILE, status: 'revoked', revokedAt }).meta).toBe(
'1.2 GB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
'1.2 GB · 보낸 사람이 링크를 끊어 받을 수 없습니다',
)
expect(
messageLargeFileView({ ...FILE, status: 'revoked', revokedAt, downloads: { count: 3, lastDownloadedAt: null } }).meta,
).toBe('1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다')
).toBe('1.2 GB · 3회 받음 · 10월 31일에 링크를 끊었습니다')
expect(messageLargeFileView({ ...FILE, status: 'limit-reached' }).tone).toBe('dim')
expect(messageLargeFileView({ ...FILE, status: 'missing' }).download).toBe('none')
})
+4 -4
View File
@@ -92,7 +92,7 @@ describe('크기 · 진행 글', () => {
it('첨부 안내 — 일반 한도 뒤에 링크 안내를 붙인다 (디자인 원본 ComposeLargeFiles)', () => {
expect(composeAttachmentHint({ ...NORMAL, maxCount: 20 }, LIMITS)).toBe(
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다',
)
})
@@ -166,7 +166,7 @@ describe('[보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭"
it('올리는 중 — 일반 첨부와 대용량 파일을 합한 수', () => {
expect(sendBlockOf({ ...clear, normalUploading: 1, largeActive: 1 })).toEqual({
code: 'large-file-uploading',
text: '파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.',
text: '파일 2개를 올리는 중입니다. 다 올린 뒤에 보낼 수 있습니다.',
})
expect(sendBlockOf({ ...clear, normalUploading: 1 })?.code).toBe('attachment-uploading')
})
@@ -175,11 +175,11 @@ describe('[보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭"
const all = { ...clear, largeActive: 1, largeIncomplete: true, largeInfected: true, largeCount: 11 }
expect(sendBlockOf(all)).toEqual({
code: 'large-file-infected',
text: '보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.',
text: '보낼 수 없는 파일이 있습니다. 빨간 줄의 파일을 빼 주세요.',
})
expect(sendBlockOf({ ...all, largeInfected: false })).toEqual({
code: 'large-file-incomplete',
text: '다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요.',
text: '다 올리지 못한 파일이 있습니다. 이어 올리거나 빼 주세요.',
})
expect(sendBlockOf({ ...all, largeInfected: false, largeIncomplete: false })?.code).toBe('large-file-count')
expect(sendBlockOf({ ...clear, largeMissing: true })?.code).toBe('large-file-missing')
+1 -1
View File
@@ -104,7 +104,7 @@ export function formatSizeLimit(bytes: number): string {
}
/**
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다".
* 일반 첨부의 형식 설명(문서, 이미지, 압축 파일)은 링크 안내로 바꾼다 (디자인 원본 ComposeLargeFiles)
*/
export function composeAttachmentHint(
+3 -3
View File
@@ -66,10 +66,10 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
})
it('스팸 아님 — "메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님)." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
it('스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." · 일부만 처리했으면 그대로 둔 수를 덧붙인다', () => {
expect(notSpamMovedText(1, '받은편지함')).toBe('메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다.')
expect(notSpamMovedText(2, '받은편지함', 1)).toBe(
'메일 2통을 받은편지함으로 옮겼습니다 (스팸 아님). 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
'메일 2통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
})
})
@@ -119,25 +119,25 @@ describe('알림 문구 (디자인 원본 MailFilterApplyStates)', () => {
const copy = mailFilterRunCopy(rule())
it('하는 중 — "· 확인 / 전체통 확인", 전체를 모르면 수를 뺀다', () => {
expect(copy.progress({ scanned: 4200, total: 8412 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인')
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터를 받은편지함에 적용하는 중')
expect(copy.progress({ scanned: 4200, total: 8412 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중 · 4,200 / 8,412통 확인')
expect(copy.progress({ scanned: 0, total: 0 })).toBe('‘거래처 메일’ 필터로 받은편지함을 정리하는 중')
})
it('끝 — N통에 적용, 0통이면 맞는 메일이 없다', () => {
expect(copy.done(37)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.')
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터를 받은편지함의 메일 1,234통에 적용했습니다.')
expect(copy.done(37)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 37통을 정리했습니다.')
expect(copy.done(1234)).toBe('‘거래처 메일’ 필터로 받은편지함의 메일 1,234통을 정리했습니다.')
expect(copy.done(0)).toBe('받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.')
})
it('멈춤 — 전체 가운데 확인한 데까지 · 적용한 수', () => {
expect(copy.stopped({ scanned: 4200, matched: 12, total: 8412 })).toBe(
'‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.',
'‘거래처 메일’ 필터로 8,412통 가운데 4,200통까지 확인하고 멈췄습니다. 그동안 12통을 정리했습니다. 다시 시도해 주세요.',
)
})
it('이름 없는 필터는 조건 글이 이름 자리에 — 작은따옴표가 겹치지 않게 큰따옴표로 묶는다', () => {
expect(mailFilterRunCopy(rule({ name: '' })).done(3)).toBe(
'“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함” 필터를 받은편지함의 메일 3통에 적용했습니다.',
'“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함” 필터로 받은편지함의 메일 3통을 정리했습니다.',
)
})
})
+1 -1
View File
@@ -250,7 +250,7 @@ describe('대용량 첨부 덩이 접기 (11단계 — 디자인 원본 MailRead
expect(details).not.toBeNull()
expect(details?.hasAttribute('open')).toBe(false)
expect(details?.querySelector('summary')?.textContent).toContain('본문의 대용량 첨부 안내')
expect(details?.querySelector('summary')?.textContent).toContain('(접어 둠) — 파일은 아래 첨부 칸에 있습니다')
expect(details?.querySelector('summary')?.textContent).toContain('파일은 아래 첨부 칸에 있어 접어 두었습니다')
expect(details?.querySelector('table a')?.getAttribute('href')).toBe('https://files.arasystem.it.kr/s/abc')
expect(body.innerHTML).not.toContain('data-ara-large-files')
// 덩이는 제자리 — 내 글 아래, 인용 위
@@ -13,14 +13,14 @@ import {
describe('realtimeLinkText (설정 › 알림의 실시간 연결 줄)', () => {
it('연결됨은 초록 점과 "연결되어 있습니다", 끊김 · 지원 안 함은 "연결할 수 없어 1분마다 확인합니다."', () => {
expect(realtimeLinkText('live')).toMatchObject({
text: '연결되어 있습니다 — 새 메일이 오면 바로 목록에 나타납니다.',
text: '연결되어 있어 새 메일이 오면 바로 목록에 나타납니다.',
notice: null,
live: true,
})
expect(realtimeLinkText('down')).toEqual({
text: '연결할 수 없어 1분마다 확인합니다.',
notice: '연결될 때까지 바탕화면 알림은 오지 않습니다.',
hint: '저절로 다시 잇습니다. 회사 방화벽 · 프록시가 오래 열린 연결을 끊으면 이렇게 됩니다.',
hint: '저절로 다시 연결합니다. 회사 방화벽이나 프록시가 오래 열린 연결을 끊으면 이렇게 될 수 있습니다.',
live: false,
})
expect(realtimeLinkText('unsupported')).toEqual({
@@ -12,8 +12,8 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
expect(partialSendMessage([])).toBeNull()
})
it('1명 — "보냈지만 1명은 받지 못했습니다 — 주소 (까닭)"', () => {
expect(partialSendMessage([NOBODY])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)')
it('1명 — "보냈지만 1명은 받지 못했습니다. 주소 (까닭)"', () => {
expect(partialSendMessage([NOBODY])).toBe('보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)')
})
it.each<[RejectedRecipientReason, string]>([
@@ -22,29 +22,29 @@ describe('partialSendMessage — 받는 사람 일부가 받지 못한 발송
['temporary', '지금은 받을 수 없음'],
])('까닭 %s 는 "%s"', (reason, text) => {
expect(partialSendMessage([{ email: 'a@arasystem.it.kr', reason }])).toBe(
`보냈지만 1명은 받지 못했습니다 — a@arasystem.it.kr (${text})`,
`보냈지만 1명은 받지 못했습니다. a@arasystem.it.kr (${text})`,
)
})
it('2명 — 두 사람 모두 "주소 (까닭)"를 ", " 로 잇는다', () => {
expect(partialSendMessage([NOBODY, REFUSED])).toBe(
'보냈지만 2명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절)',
'보냈지만 2명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절)',
)
})
it('3명 이상 — 앞의 두 명만 적고 나머지는 " 외 N명"', () => {
expect(partialSendMessage([NOBODY, REFUSED, BUSY])).toBe(
'보냈지만 3명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절) 외 1명',
'보냈지만 3명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소), ceo@partner.example (받는 쪽이 거절) 외 1명',
)
const many = Array.from({ length: 5 }, (_, index) => ({ ...BUSY, email: `user${index + 1}@arasystem.it.kr` }))
expect(partialSendMessage(many)).toBe(
'보냈지만 5명은 받지 못했습니다 — user1@arasystem.it.kr (지금은 받을 수 없음), user2@arasystem.it.kr (지금은 받을 수 없음) 외 3명',
'보냈지만 5명은 받지 못했습니다. user1@arasystem.it.kr (지금은 받을 수 없음), user2@arasystem.it.kr (지금은 받을 수 없음) 외 3명',
)
})
it.each(['bounced', 'constructor'])('계약에 없는 까닭(%s)이면 까닭 없이 주소만 적는다', (reason) => {
const recipient: RejectedRecipient = { email: NOBODY.email, reason: reason as string as RejectedRecipientReason }
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr')
expect(partialSendMessage([recipient])).toBe('보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr')
})
})
@@ -53,19 +53,19 @@ describe('partialSendMessage — 엔진이 붙잡아 둔 발송 (9단계 "보내
it('보내기 취소를 기다리는 중이면 아직 나가지 않았다 — "받지 못합니다" (디자인 원본 MailSendLater)', () => {
expect(partialSendMessage([NOBODY], { pendingKind: 'undo' })).toBe(
'보냈지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
'보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
)
})
it('예약이면 "예약했지만 … 받지 못합니다"', () => {
expect(partialSendMessage([NOBODY], { pendingKind: 'scheduled' })).toBe(
'예약했지만 1명은 받지 못합니다 — nobody@arasystem.it.kr (없는 주소)',
'예약했지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)',
)
})
it('바로 보냈으면(null) 지금처럼 "받지 못했습니다"', () => {
expect(partialSendMessage([NOBODY], { pendingKind: null })).toBe(
'보냈지만 1명은 받지 못했습니다 — nobody@arasystem.it.kr (없는 주소)',
'보냈지만 1명은 받지 못했습니다. nobody@arasystem.it.kr (없는 주소)',
)
})
})
@@ -148,7 +148,7 @@ describe('알리는 글', () => {
expect(vacationSavedText(ranged(null, null), now)).toBe('저장했습니다. 끌 때까지 자동으로 답합니다.')
expect(vacationSavedText(ranged(null, '2026-11-03'), now)).toBe('저장했습니다. 11월 3일(화)까지 자동으로 답합니다.')
expect(vacationSavedText(ranged('2026-10-30', null), now)).toBe('저장했습니다. 10월 30일(금)부터 자동으로 답합니다.')
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐습니다 — 써 둔 글은 남겨 둡니다.')
expect(vacationSavedText(OFF, now)).toBe('저장했습니다. 자동 응답을 껐고, 써 둔 글은 그대로 남아 있습니다.')
})
it('올해가 아니면 연도를 붙인다', () => {
@@ -10,15 +10,15 @@ 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('1,234통 가운데 600통까지 처리하고 멈췄습니다. 다시 시도해 주세요.')
// 다른 탭이 먼저 했다
expect(copy.done(0)).toBe('받은편지함에 안 읽은 메일이 없습니다.')
})
it('비우기 — 메일함 이름의 받침에 맞춰 을/를', () => {
expect(mailboxOperationCopy('empty', '휴지통').progress(20, 21)).toBe('휴지통을 비우는 중 · 20 / 21통')
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('휴지통을 비웠습니다 — 메일 21통.')
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('스팸메일함을 비웠습니다 — 메일 5통.')
expect(mailboxOperationCopy('empty', '휴지통').done(21)).toBe('메일 21통을 지워 휴지통을 비웠습니다.')
expect(mailboxOperationCopy('empty', '스팸메일함').done(5)).toBe('메일 5통을 지워 스팸메일함을 비웠습니다.')
// 다른 탭이 먼저 비웠다 — 받침에 맞춰 이/가
expect(mailboxOperationCopy('empty', '휴지통').done(0)).toBe('휴지통이 이미 비어 있습니다.')
expect(mailboxOperationCopy('empty', 'Spam').done(0)).toBe('Spam이(가) 이미 비어 있습니다.')
@@ -41,13 +41,13 @@ describe('인용 속 링크의 상태 → 칸 아래 글 (디자인 README "11
it('받을 수 있는 링크 — 원래 메일의 파일 수와 끝나는 날(한국)', () => {
expect(quotedLinkNotices([{ url: URL, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' }], names)).toEqual([
{ key: URL, tone: 'info', text: '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다 — 11월 29일까지' },
{ key: URL, tone: 'info', text: '원래 메일의 대용량 첨부 2개는 인용 속 링크로 함께 갑니다. 11월 29일까지 받을 수 있습니다.' },
])
})
it('파일 수를 모르면 수 없이', () => {
expect(quotedLinkNotices([{ url: OTHER, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' }], names)[0]?.text).toBe(
'인용 속 대용량 첨부 링크는 함께 갑니다 — 11월 29일까지',
'인용 속 대용량 첨부 링크도 함께 갑니다. 11월 29일까지 받을 수 있습니다.',
)
})
+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통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
})
})