generated from ttipo/project-base
docs: 14단계 화면(검색 · 대화 묶기)을 확정해 디자인 문서에 옮긴다 #17
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}에 더 넣을 주소가 없습니다.`,
|
||||
|
||||
@@ -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: '이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]를 고른 뒤 [다음]을 누릅니다.`,
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 = '내용을 입력해 주세요'
|
||||
|
||||
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/** 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) — 서명 설정과 같은 모양 */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -104,7 +104,7 @@ export function formatSizeLimit(bytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
|
||||
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 파일당 2GB, 10개까지 링크로 보냅니다".
|
||||
* 일반 첨부의 형식 설명(문서, 이미지, 압축 파일)은 링크 안내로 바꾼다 (디자인 원본 ComposeLargeFiles)
|
||||
*/
|
||||
export function composeAttachmentHint(
|
||||
|
||||
@@ -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통을 정리했습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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일까지 받을 수 있습니다.',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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통까지 처리하고 멈췄습니다. 다시 시도해 주세요.' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user