fix(frontend): 묶은 휴지통의 영구 삭제 글과 띄어 쓴 조사를 고친다

- 대화로 묶은 휴지통에서 줄을 골라 영구 삭제하면 확인 창 · 알림을
  "휴지통에 있는 메일 N통을 …"으로 쓴다 — 줄의 통 수는 대화 전체라서
  (옮기기 알림 "{메일함}에 있는 메일 N통을 …"과 같은 결)
- 값을 끼워 넣는 글의 띄어 쓴 조사를 붙인다(9단계 "@도메인에서",
  11단계 "25MB를" · "2GB까지" · "20MB가"), 공개 내려받기 화면의 신고
  주소는 끝 글자로 조사를 고른다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:13:33 +09:00
parent 08aafdef5c
commit 749997cb18
16 changed files with 139 additions and 37 deletions
@@ -1,17 +1,22 @@
<script setup lang="ts">
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
// 대화로 묶은 휴지통에서 줄을 골랐으면(from) 제목은 "휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서 (14단계, 2026-11-07)
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통 — from 없이 지금 글) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
import { computed } from 'vue'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
import { formatCount } from '@/shared/utils/format'
import { purgeTitleText } from '@/shared/utils/mail-bulk'
interface Props {
open: boolean
count: number
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
/** 대화로 묶은 목록에서 고른 줄이면 그 메일함 이름 ("휴지통") — 없으면 "메일 N통을 …" */
from?: string | null
}
interface Emits {
@@ -19,15 +24,19 @@ interface Emits {
(e: 'cancel'): void
}
defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
from: null,
})
const emit = defineEmits<Emits>()
const title = computed(() => purgeTitleText(props.count, { from: props.from }))
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
:title="title"
:message="MAIL_BULK_TEXT.purgeMessage"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@@ -8,7 +8,8 @@
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다.
// - 대화로 묶은 메일함(14단계)은 "대화 N개 선택됨"이고 하는 일은 고른 줄의 메일 모두(붙잡힌 메일은 빼고) — 영구 삭제 확인의 수는 그 메일 수다
// ("휴지통에 있는 메일 N통을 영구 삭제할까요?" — 줄의 통 수는 대화 전체라서, useBulkMailActions 의 conversationFrom).
// 분할 보기는 보이는 글만 "N개 선택됨"으로 줄이고 화면 낭독은 "대화 N개 선택됨"을 읽는다 — 목록 칸(384)에서 단추 일곱(280)과 한 줄에 들게
// (2026-11-06 실사용 시험: "대화 1개 선택됨" 88px 이면 도구가 두 줄 · 97px 로 넘쳤다. "N개 선택됨"은 세 자리까지 79px — 1440 의 자리 83px)
import { computed, nextTick, ref, watch } from 'vue'
@@ -251,6 +252,7 @@ watch(disabledTools, (disabled) => {
<MailPurgeDialog
:open="purgeOpen"
:count="mailStore.checkedActionIds.length"
:from="bulk.conversationFrom.value"
id-prefix="bulk-purge"
@confirm="onPurgeConfirm"
@cancel="purgeOpen = false"
@@ -587,7 +587,7 @@ describe('MessageReader — 9단계', () => {
expect(wrapper.get('#block-dialog-title').text()).toBe('보낸 사람 차단')
expect((dialog.get('[data-block-scope="address"]').element as HTMLInputElement).checked).toBe(true)
expect(dialog.text()).toContain('news@cloudbrief.example')
expect(dialog.text()).toContain('@cloudbrief.example 에서 오는 메일 모두')
expect(dialog.text()).toContain('@cloudbrief.example에서 오는 메일 모두')
expect((dialog.get('[data-block-move]').element as HTMLInputElement).checked).toBe(true)
expect(document.activeElement).toBe(wrapper.get('[data-block-confirm]').element)
+14 -3
View File
@@ -14,7 +14,8 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험)
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
export function useBulkMailActions() {
const mailStore = useMailStore()
@@ -35,6 +36,13 @@ export function useBulkMailActions() {
const inJunk = computed<boolean>(() => mailStore.currentMailbox?.role === 'junk')
/** 스팸 단추가 옮길 곳 — 스팸 신고는 스팸메일함, 스팸 아님은 받은편지함. 그 메일함을 모르면 단추를 그리지 않는다 */
const spamTarget = computed<Mailbox | null>(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk'))
/**
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
*/
const conversationFrom = computed<string | null>(() =>
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
)
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
const moveTargets = computed<Mailbox[]>(() =>
mailStore.orderedMailboxes
@@ -66,7 +74,7 @@ export function useBulkMailActions() {
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
const from = mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null
const from = conversationFrom.value
busy.value = true
try {
const result = await mailStore.moveChecked(mailbox.id, options)
@@ -103,11 +111,13 @@ export function useBulkMailActions() {
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
async function purge(): Promise<void> {
if (busy.value || mailStore.checkedCount === 0) return
// 옮기기와 같은 까닭 — 대화로 묶은 휴지통이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (시작할 때의 메일함)
const from = conversationFrom.value
busy.value = true
try {
const result = await mailStore.purgeChecked()
await leaveRemovedMessage(result)
feedback.announcePurge(result)
feedback.announcePurge(result, { from })
} finally {
busy.value = false
}
@@ -119,6 +129,7 @@ export function useBulkMailActions() {
inTrash,
inJunk,
spamTarget,
conversationFrom,
moveTargets,
markRead,
toggleFlagged,
@@ -336,7 +336,7 @@ describe('useLargeFileUploads', () => {
expect(uploads.items.value.map((item) => [item.name, item.phase, item.errorText])).toEqual([
// 한도 글은 MB · GB 로 적는다 — 이 테스트의 12바이트 한도는 "0MB" 로 보인다
['too-big.zip', 'rejected', '파일 하나는 0MB 까지 보낼 수 있습니다.'],
['too-big.zip', 'rejected', '파일 하나는 0MB까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
@@ -6,7 +6,7 @@ import {
purgedMessageText,
restoredMessageText,
type BulkOrigin,
type MovedTextOptions,
type BulkTextOptions,
} from '@/shared/utils/mail-bulk'
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
@@ -17,7 +17,7 @@ import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOption
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다."
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
* - 대화로 묶은 목록에서 줄에 한 일(14단계)은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 통 수는 대화 전체라서 (options.from)
* - "메일 N통을 영구 삭제했습니다."
* - "메일 N통을 영구 삭제했습니다." — 대화로 묶은 휴지통에서 줄에 한 일이면 "휴지통에 있는 메일 N통을 영구 삭제했습니다." (같은 까닭 · options.from)
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
@@ -41,7 +41,7 @@ export function useMailMoveFeedback() {
}
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: MovedTextOptions = {}) {
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: BulkTextOptions = {}) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done === 0 && skipped === 0) return
@@ -57,10 +57,11 @@ export function useMailMoveFeedback() {
)
}
function announcePurge(result: MailBulkResult) {
/** textOptions.from — 대화로 묶은 휴지통에서 줄에 한 일이면 그 메일함 이름 ("휴지통에 있는 메일 2통을 영구 삭제했습니다.") */
function announcePurge(result: MailBulkResult, textOptions: BulkTextOptions = {}) {
const done = result.doneIds.length
const skipped = result.skippedIds.length
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped, textOptions))
}
return { announceMove, announcePurge }
+1 -1
View File
@@ -636,7 +636,7 @@ describe('ComposePage', () => {
])
expect(wrapper.get('[data-large-upload]').text()).toContain('도면.pdf')
expect(wrapper.get('[data-large-switch]').text()).toBe(
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB 를 넘어 링크로 보냅니다.',
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB를 넘어 링크로 보냅니다.',
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
@@ -116,6 +116,8 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
let loadSettings: (() => MockResult) | null
/** GET /messages 의 응답을 바꾼다 (붙잡아 두기 등) */
let listMessages: (() => MockResult | Promise<MockResult>) | null
/** 읽기 화면이 여는 메일(GET /messages/{id})의 메일함 — 처음은 받은편지함 */
let openedMailboxIds: string[]
const mounted: VueWrapper[] = []
beforeEach(() => {
@@ -130,6 +132,7 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
patchSettings = null
loadSettings = null
listMessages = null
openedMailboxIds = ['inbox']
requests = installMockApi((request) => {
const { url, method } = request
if (url === '/mail-state') return ok({ state })
@@ -176,7 +179,7 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
}
if (url.endsWith('/conversation')) return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 1 }, basis: 'index' })
const opened = /^\/messages\/([^/]+)$/.exec(url)?.[1]
if (opened && method === 'GET') return ok(detail(summary(opened)))
if (opened && method === 'GET') return ok(detail(summary(opened, { mailboxIds: openedMailboxIds })))
return ok(null)
})
})
@@ -396,23 +399,51 @@ describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 2통을 받은편지함으로 되돌렸습니다.')
})
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창 · 알림은 "휴지통에 있는 메일 N통을 …"(줄의 통 수는 대화 전체라서)이고 그 줄의 메일을 POST /message-deletions', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
rows = [
conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 }),
conversation('t2', ['m1']),
]
const { wrapper } = await mountMail('/mail/trash')
// 줄은 대화 전체의 "5통" — 지우는 것은 휴지통의 2통이다 (2026-11-07)
expect(wrapper.findAll('.thread-row')[0]?.get('[data-thread-count]').text()).toBe('5통')
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
const dialog = wrapper.get('#bulk-purge-title')
expect(dialog.text()).toBe('메일 2통을 영구 삭제할까요?')
expect(dialog.text()).toBe('휴지통에 있는 메일 2통을 영구 삭제할까요?')
expect(wrapper.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8', 'm7'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 2통을 영구 삭제했습니다.')
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('휴지통에 있는 메일 2통을 영구 삭제했습니다.')
})
it('묶은 휴지통이어도 읽기 화면의 영구 삭제(한 통)는 지금 글 그대로 — "메일 1통을 영구 삭제할까요?" · "메일 1통을 영구 삭제했습니다."', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }), messageCount: 5 })]
openedMailboxIds = ['trash']
const { wrapper } = await mountMail('/mail/trash/m8')
const remove = wrapper.get('[data-reader-delete]')
expect(remove.text()).toBe('영구 삭제')
await remove.trigger('click')
await settle()
expect(wrapper.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
// 고른 줄이 없어 도구 줄(과 그 확인 창)은 없다 — 확인 단추는 읽기 화면의 것 하나
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 1통을 영구 삭제했습니다.')
})
it('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', async () => {
@@ -276,7 +276,7 @@ describe('MailLargeFilesPage', () => {
expect(view.get('[data-large-usage]').text()).toBe('0 / 10 GB')
expect(view.get('[data-large-empty]').text()).toContain('링크로 보낸 파일이 없습니다')
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB 가 넘는 파일을 고르면 링크로 보냅니다.')
expect(view.get('[data-large-empty]').text()).toContain('메일 쓰기에서 20MB가 넘는 파일을 고르면 링크로 보냅니다.')
expect(view.find('#large-unsent-title').exists()).toBe(false)
})
+1 -1
View File
@@ -120,7 +120,7 @@ describe('SharePage', () => {
expect(view.get('.share__safety').text()).toBe(
'이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
)
expect(view.get('.share__report').text()).toBe('받을 이유가 없는 파일이면 security@arasystem.it.kr 로 알려 주세요.')
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 will never ask you to log in or enter a password.',
)
+6 -1
View File
@@ -22,6 +22,7 @@ import { APP_NAME } from '@/shared/constants/brand'
import { SHARE_PAGE_TEXT, SHARE_SCAN_POLL_DELAYS_MS, SHARE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize } from '@/shared/utils/format'
import { directionParticle } from '@/shared/utils/korean'
import {
formatKstEnglishDate,
formatKstFullDate,
@@ -71,6 +72,10 @@ const view = computed(() => {
return 'active'
})
/** 신고 주소 뒤의 글 — 조사는 주소에 붙이고 받침에 맞춘다 (영문으로 끝나면 "(으)로") */
const reportAfter = computed(() =>
link.value ? SHARE_PAGE_TEXT.reportAfter(directionParticle(link.value.reportAddress)) : '',
)
const untilText = computed(() => (link.value ? SHARE_PAGE_TEXT.until(formatKstFullDate(link.value.expiresAt)) : ''))
const untilTail = computed(() => {
if (!link.value) return ''
@@ -395,7 +400,7 @@ watch(
v-if="link && view !== 'loading'"
class="share__report"
>
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ SHARE_PAGE_TEXT.reportAfter }}
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ reportAfter }}
</p>
<p
v-if="english"
+13 -5
View File
@@ -90,9 +90,12 @@ export const LARGE_FILE_TEXT = {
`받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다. 오늘 보내면 ${until}까지입니다.`,
/** 보관 사용량이 80%를 넘을 때 링크 묶음 머리 오른쪽 */
quotaWarning: (usage: string) => `보관 ${usage}`,
/** 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status) */
/**
* 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status).
* total 은 늘 "25MB" 꼴(formatMegabytes — 메가바이트로 읽어 받침이 없다)이라 조사를 '를'로 붙여 쓴다
*/
switchedByTotal: (name: string, size: string, total: string) =>
`‘${name}’(${size})는 메일에 담으면 합계가 ${total} 를 넘어 링크로 보냅니다.`,
`‘${name}’(${size})는 메일에 담으면 합계가 ${total}를 넘어 링크로 보냅니다.`,
sendAsLink: '링크로 보내기',
pause: '멈춤',
resume: '이어 올리기',
@@ -123,7 +126,7 @@ export const LARGE_FILE_TEXT = {
missingExpired: '기간이 지나 지워진 파일입니다. 빼고 다시 올려 주세요.',
missingDeleted: '[설정] → [대용량 첨부]에서 지운 파일입니다. 빼고 다시 올려 주세요.',
missingUnknown: '파일을 찾을 수 없습니다. 빼고 다시 올려 주세요.',
tooLarge: (max: string) => `파일 하나는 ${max} 까지 보낼 수 있습니다.`,
tooLarge: (max: string) => `파일 하나는 ${max}까지 보낼 수 있습니다.`,
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다.`,
overQuota: (limit: string) =>
`대용량 첨부는 ${limit}까지 보관할 수 있어 더 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
@@ -271,8 +274,9 @@ export const LARGE_FILE_SETTINGS_TEXT = {
more: '더 보기',
moreHint: '최근에 보낸 순서로 20개씩 보입니다. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
/** max 는 늘 "20MB" 꼴(formatMegabytes — 받침이 없다)이라 조사를 '가'로 붙여 쓴다 */
emptyHint: (max: string, days: string) =>
`메일 쓰기에서 ${max} 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
`메일 쓰기에서 ${max}가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
noSubject: '(제목 없음)',
} as const
@@ -300,7 +304,11 @@ export const SHARE_PAGE_TEXT = {
resumeHint: '내려받다 끊기면 브라우저의 다운로드 목록에서 다시 시도해 주세요. 받은 곳부터 이어서 받습니다.',
noLogin: '이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
reportBefore: '받을 이유가 없는 파일이면 ',
reportAfter: ' 로 알려 주세요.',
/**
* 신고 주소 뒤 — 조사는 주소에 붙여 쓰고 받침에 맞춘다(korean.ts 의 directionParticle).
* 주소는 운영 설정이고 영문으로 끝나 보통 "(으)로" — "postmaster@arasystem.it.kr(으)로 알려 주세요."
*/
reportAfter: (particle: string) => `${particle} 알려 주세요.`,
englishActive: (sender: string, email: string, until: string) =>
`Files from ${sender} (${email}). Download by ${until} (KST). This page will never ask you to log in or enter a password.`,
scanningTitle: '보안 검사 중입니다',
+2 -2
View File
@@ -60,8 +60,8 @@ export const MAIL_BLOCK_TEXT = {
scopeGroup: '차단할 범위',
scopeAddress: '이 주소만',
scopeDomain: '이 도메인 전체',
/** "@cloudbrief.example 에서 오는 메일 모두" */
scopeDomainHint: (domain: string) => `${domain} 에서 오는 메일 모두`,
/** "@cloudbrief.example에서 오는 메일 모두" — 조사는 붙여 쓴다 ('에서'는 받침과 상관없다) */
scopeDomainHint: (domain: string) => `${domain}에서 오는 메일 모두`,
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
+4
View File
@@ -344,11 +344,15 @@ export const MAIL_BULK_TEXT = {
*/
movedFrom: (from: string, count: string, place: string) => `${from}에 있는 메일 ${count}통을 ${place} 옮겼습니다.`,
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
/** 대화로 묶은 휴지통에서 줄에 한 영구 삭제 (14단계 — movedFrom 과 같은 꼴, 2026-11-07) — "휴지통에 있는 메일 2통을 영구 삭제했습니다." */
purgedFrom: (from: string, count: string) => `${from}에 있는 메일 ${count}통을 영구 삭제했습니다.`,
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
/** 한 메일함으로 되돌렸을 때 — place 에는 메일함 이름과 조사를 붙여 넘긴다 ("받은편지함으로"). 여러 곳이면 restored */
restoredTo: (count: string, place: string) => `메일 ${count}통을 ${place} 되돌렸습니다.`,
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
/** 대화로 묶은 휴지통에서 줄을 골랐을 때의 확인 창 제목 (purgedFrom 과 같은 꼴) — "휴지통에 있는 메일 2통을 영구 삭제할까요?" */
purgeTitleFrom: (from: string, count: string) => `${from}에 있는 메일 ${count}통을 영구 삭제할까요?`,
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
purgeConfirm: '영구 삭제',
purgeCancel: '취소',
@@ -6,6 +6,7 @@ import {
movedMessageText,
notSpamMovedText,
purgedMessageText,
purgeTitleText,
restoredMessageText,
sendInChunks,
} from './mail-bulk'
@@ -86,6 +87,21 @@ describe('mail-bulk (여러 통 처리 보조)', () => {
expect(movedMessageText(2, '휴지통', 0, { from: null })).toBe('메일 2통을 휴지통으로 옮겼습니다.')
})
it('대화로 묶은 휴지통의 영구 삭제 — 확인 창 · 알림이 "휴지통에 있는 메일 N통을 …" (2026-11-07), 뒷말은 그대로', () => {
expect(purgeTitleText(2, { from: '휴지통' })).toBe('휴지통에 있는 메일 2통을 영구 삭제할까요?')
expect(purgeTitleText(1_234, { from: '휴지통' })).toBe('휴지통에 있는 메일 1,234통을 영구 삭제할까요?')
expect(purgedMessageText(2, 0, { from: '휴지통' })).toBe('휴지통에 있는 메일 2통을 영구 삭제했습니다.')
expect(purgedMessageText(2, 1, { from: '휴지통' })).toBe(
'휴지통에 있는 메일 2통을 영구 삭제했습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
)
// 하나도 지우지 못했으면 뒷말만 (묶지 않은 목록과 같다)
expect(purgedMessageText(0, 2, { from: '휴지통' })).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
// 묶지 않은 목록 · 읽기 화면(from 없음)은 지금 글 그대로
expect(purgeTitleText(2)).toBe('메일 2통을 영구 삭제할까요?')
expect(purgeTitleText(1, { from: null })).toBe('메일 1통을 영구 삭제할까요?')
expect(purgedMessageText(2, 0, { from: null })).toBe('메일 2통을 영구 삭제했습니다.')
})
it('되돌린 알림 — 한 메일함이면 "메일 N통을 {메일함}으로 되돌렸습니다.", 여러 곳 · 모르면 지금 글 그대로', () => {
expect(restoredMessageText(2, 0, '받은편지함')).toBe('메일 2통을 받은편지함으로 되돌렸습니다.')
expect(restoredMessageText(1, 0, '프로젝트')).toBe('메일 1통을 프로젝트로 되돌렸습니다.')
+21 -6
View File
@@ -80,8 +80,11 @@ function placeOf(mailboxName: string): string {
return `${mailboxName}${directionParticle(mailboxName)}`
}
/** 옮기기 알림의 꾸밈 — from 이 있으면 "{from}에 있는 메일 …" (대화로 묶은 목록에서 줄에 한 일 — 14단계) */
export interface MovedTextOptions {
/**
* 옮기기 · 영구 삭제의 알림 · 확인 글의 꾸밈 — from 이 있으면 "{from}에 있는 메일 …"
* (대화로 묶은 목록에서 줄에 한 일 — 14단계. 줄의 통 수는 대화 전체라 그 메일함의 메일임을 밝힌다)
*/
export interface BulkTextOptions {
from?: string | null
}
@@ -89,7 +92,7 @@ export interface MovedTextOptions {
* "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로'.
* 대화로 묶은 목록에서 줄에 한 일이면 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." (줄의 통 수는 대화 전체라서)
*/
export function movedMessageText(done: number, mailboxName: string, skipped = 0, options: MovedTextOptions = {}): string {
export function movedMessageText(done: number, mailboxName: string, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from
? MAIL_BULK_TEXT.movedFrom(options.from, count, placeOf(mailboxName))
@@ -98,7 +101,7 @@ export function movedMessageText(done: number, mailboxName: string, skipped = 0,
}
/** 스팸 아님 — "메일 1통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다." (스팸 신고는 옮기기와 같은 문구 — movedMessageText) */
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0, options: MovedTextOptions = {}): string {
export function notSpamMovedText(done: number, mailboxName: string, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from
? MAIL_SPAM_TEXT.notSpamMovedFrom(options.from, count, placeOf(mailboxName))
@@ -106,8 +109,20 @@ export function notSpamMovedText(done: number, mailboxName: string, skipped = 0,
return withSkipped(text, done, skipped)
}
export function purgedMessageText(done: number, skipped = 0): string {
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
/**
* 영구 삭제 확인 창의 제목 — "메일 2통을 영구 삭제할까요?".
* 대화로 묶은 휴지통에서 줄을 골랐으면 "휴지통에 있는 메일 2통을 영구 삭제할까요?" (줄의 통 수는 대화 전체이고 지우는 것은 휴지통의 메일뿐이라서)
*/
export function purgeTitleText(count: number, options: BulkTextOptions = {}): string {
const countText = formatCount(count)
return options.from ? MAIL_BULK_TEXT.purgeTitleFrom(options.from, countText) : MAIL_BULK_TEXT.purgeTitle(countText)
}
/** "메일 2통을 영구 삭제했습니다." — 대화로 묶은 휴지통에서 줄에 한 일이면 "휴지통에 있는 메일 2통을 영구 삭제했습니다." */
export function purgedMessageText(done: number, skipped = 0, options: BulkTextOptions = {}): string {
const count = formatCount(done)
const text = options.from ? MAIL_BULK_TEXT.purgedFrom(options.from, count) : MAIL_BULK_TEXT.purged(count)
return withSkipped(text, done, skipped)
}
/**