feat(frontend): 대용량 첨부 — 쓰기 · 읽기 · 설정 · 공개 내려받기 화면

- 쓰기: 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). 고르자마자 서버에 만들고
  16MiB 조각(Content-Digest)을 파일 3개까지 함께, 끊기면 2 · 5 · 15 · 30초로 다시 · 1분 넘으면 멈춤 + [다시 시도],
  새로고침 뒤 같은 파일을 다시 고르면 잇는다. 다 올릴 때까지 [보내기]는 aria-disabled + 까닭, 위험 판정은 빨간 줄 + 실시간 알림.
  대용량 파일이 있으면 첫 원문 인용 앞을 data-ara-quote-start · bodyQuoteStart 로 알린다(서버가 그 자리에 덩이를 넣는다).
- 읽기: 첨부 칸의 대용량 항목(서버의 largeFiles), 본문 덩이 접기, 보낸 사람의 받은 수 · 링크 끊기(끊은 날 — revokedAt).
- 설정 › 메일 › 대용량 첨부: 보관 사용량 · 보내지 않은 파일 · 보낸 링크(끊기).
- 공개 화면 /s/:token: 세션 복원 없이(meta.public) · credentials omit · no-referrer, 라우트는 대소문자를 가린다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:55 +09:00
parent 93154ad9a5
commit 3461c361f8
84 changed files with 10084 additions and 137 deletions
+10 -2
View File
@@ -6,6 +6,7 @@ import { RouterView } from 'vue-router'
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
import ToastHost from '@/components/feedback/ToastHost.vue'
import { useClock } from '@/composables/useClock'
import { useLargeFileAlerts } from '@/composables/useLargeFileAlerts'
import { useLogoutSync } from '@/composables/useLogoutBroadcast'
import { useMailRealtime } from '@/composables/useMailRealtime'
import { useNewMailNotifier } from '@/composables/useNewMailNotifier'
@@ -15,9 +16,16 @@ useLogoutSync()
// 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게).
// 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling).
// 사용자가 웹메일 탭(어느 탭이든)으로 돌아오면 띄운 바탕화면 알림을 닫는다
// 사용자가 웹메일 탭(어느 탭이든)으로 돌아오면 띄운 바탕화면 알림을 닫는다.
// 올린 대용량 파일의 위험 판정(11단계)도 이 연결로 온다 — 탭마다 알림 · 쓰기 화면의 그 파일을 "위험"으로 (useLargeFileAlerts)
const newMailNotifier = useNewMailNotifier()
useMailRealtime({ clock: useClock(), onNewMail: newMailNotifier.notify, onUserReturn: newMailNotifier.dismiss })
const largeFileAlerts = useLargeFileAlerts()
useMailRealtime({
clock: useClock(),
onNewMail: newMailNotifier.notify,
onUserReturn: newMailNotifier.dismiss,
onLargeFile: largeFileAlerts.notify,
})
</script>
<template>
@@ -0,0 +1,202 @@
<script setup lang="ts">
// 쓰기 화면의 첨부 칸 — 「메일에 담는 파일」 · 「링크로 보내는 파일」 두 묶음 + 칸 아래 안내 (디자인 원본 ComposeLargeFiles)
// - 링크 파일이 있으면 링크 묶음 머리 "링크로 보내는 파일 N개" + "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지",
// 보관 사용량이 80%를 넘으면 그 오른쪽에 "보관 8.4 / 10 GB"(갈색 굵게). 두 묶음이 함께 있을 때만 메일 묶음 머리("메일에 담는 파일 N개" · "합계 18.1 MB / 25 MB").
// 일반 첨부만 있으면 지금처럼 묶음 이름 없이.
// - 칸 아래 ⓘ 한 줄 — 합계 때문에 저절로 링크로 바꿨을 때(role=status) · 인용 속 원래 링크의 상태(전달 · 답장 — 끝난 링크는 갈색, 보내기는 막지 않는다)
// - 할 일은 쓰기 화면에 넘긴다 — 빼기 · 링크로 보내기 · 멈춤 · 이어 올리기 · 다시 시도 · 파일 고르기
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeLargeFileItem from '@/components/mail/ComposeLargeFileItem.vue'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { formatCount } from '@/shared/utils/format'
import type { QuotedLinkNotice } from '@/shared/utils/share-links'
interface Props {
normalItems: UploadItem[]
largeItems: LargeUploadItem[]
/** 메일 묶음 머리의 "합계 18.1 MB / 25 MB" */
normalTotalText: string
/** 링크 묶음 머리의 안내 — "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지" */
linkHint: string
/** 보관 사용량이 많을 때 링크 묶음 머리 오른쪽 — "보관 8.4 / 10 GB" (아니면 null) */
quotaWarning: string | null
/** 합계 때문에 저절로 링크로 바꾼 파일의 안내 (없으면 null) */
switchNotice: string | null
/** 인용 속 원래 링크의 상태 */
quotedNotices: QuotedLinkNotice[]
/** 「링크로 보내기」 단추를 보일 일반 첨부인가 */
linkable: (item: UploadItem) => boolean
}
interface Emits {
(e: 'remove', key: number): void
(e: 'send-as-link', key: number): void
(e: 'large-pause', key: number): void
(e: 'large-resume', key: number): void
(e: 'large-remove', key: number): void
(e: 'large-repick', key: number, file: File): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const normalListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
/** 그 일반 첨부의 빼기 버튼으로 초점을 옮긴다 — 발송이 찾지 못한 전달 원본을 빼게 할 때 쓴다 */
function focusRemove(key: number) {
normalListRef.value?.focusRemove(key)
}
defineExpose({ focusRemove })
</script>
<template>
<div class="attachment-groups">
<template v-if="normalItems.length > 0">
<div
v-if="largeItems.length > 0"
class="attachment-groups__head"
>
<h3 class="attachment-groups__title">
{{ LARGE_FILE_TEXT.normalGroup(formatCount(normalItems.length)) }}
</h3>
<span class="attachment-groups__hint attachment-groups__hint--numeric">{{ normalTotalText }}</span>
</div>
<ComposeAttachmentList
ref="normalListRef"
:items="normalItems"
:linkable="linkable"
@remove="emit('remove', $event)"
@send-as-link="emit('send-as-link', $event)"
/>
</template>
<template v-if="largeItems.length > 0">
<div class="attachment-groups__head">
<h3 class="attachment-groups__title">
{{ LARGE_FILE_TEXT.linkGroup(formatCount(largeItems.length)) }}
</h3>
<span class="attachment-groups__hint">{{ linkHint }}</span>
<span
v-if="quotaWarning"
class="attachment-groups__quota"
data-large-quota
>{{ quotaWarning }}</span>
</div>
<ul class="attachment-groups__list">
<ComposeLargeFileItem
v-for="item in largeItems"
:key="item.key"
:item="item"
@pause="emit('large-pause', $event)"
@resume="emit('large-resume', $event)"
@remove="emit('large-remove', $event)"
@repick="(key: number, file: File) => emit('large-repick', key, file)"
/>
</ul>
</template>
<p
v-if="switchNotice"
class="attachment-groups__notice"
role="status"
data-large-switch
>
<AppIcon
name="info"
:size="15"
class="attachment-groups__notice-icon"
/>
<span>{{ switchNotice }}</span>
</p>
<p
v-for="notice in quotedNotices"
:key="notice.key"
:class="['attachment-groups__notice', `attachment-groups__notice--${notice.tone}`]"
role="status"
data-quoted-link
>
<AppIcon
:name="notice.tone === 'warning' ? 'alert-circle' : 'info'"
:size="15"
class="attachment-groups__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
</div>
</template>
<style scoped>
.attachment-groups {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* 묶음 머리 — 12.5 굵게 + 12 회색 (디자인 원본 ComposeLargeFiles) */
.attachment-groups__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
margin-top: var(--space-2);
}
.attachment-groups__title {
margin: 0;
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-strong);
}
.attachment-groups__hint {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.attachment-groups__hint--numeric {
font-variant-numeric: tabular-nums;
}
/* 보관 사용량이 많을 때 — 갈색 굵게, 머리 오른쪽 */
.attachment-groups__quota {
margin-left: auto;
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-warning-text);
font-variant-numeric: tabular-nums;
}
/* 링크 파일 — 일반 첨부와 같은 격자 (두 칸) */
.attachment-groups__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(17.5rem, 100%), 1fr));
gap: var(--space-6);
margin: 0;
padding: 0;
list-style: none;
}
/* 칸 아래 ⓘ 한 줄 — 12.5 회색, 끝난 인용 링크는 갈색 */
.attachment-groups__notice {
display: flex;
align-items: flex-start;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.attachment-groups__notice-icon {
margin-top: var(--space-2);
}
.attachment-groups__notice--warning {
color: var(--color-warning-text);
}
</style>
@@ -1,21 +1,30 @@
<script setup lang="ts">
// 쓰기 화면의 첨부 목록 — 파일마다 진행률, 실패한 파일은 사유와 함께 빨갛게 (디자인 원본 States "첨부 업로드")
// 어느 첨부든 할 수 있는 일은 "빼기"(✕) 하나다 — 올린 파일은 빼고 다시 고르면 되고, 전달 원본은 다시 올릴 수 없다.
// 11단계: 이 쓰기 화면에서 고른 파일(올리는 중 · 올림)에는 「링크로 보내기」 단추(40 — 마우스를 올리면 같은 글)가 더 있다 —
// 대용량 첨부(링크)로 옮긴다. 전달 원본 · 저장본에서 이어 온 첨부에는 없다 (디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
import { ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { formatFileSize } from '@/shared/utils/format'
interface Props {
items: UploadItem[]
/** 「링크로 보내기」 단추를 보일 첨부인가 — 주지 않으면 없다 */
linkable?: (item: UploadItem) => boolean
}
interface Emits {
(e: 'remove', key: number): void
/** 「링크로 보내기」 — 그 첨부를 대용량 첨부로 옮긴다 */
(e: 'send-as-link', key: number): void
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
linkable: () => false,
})
const emit = defineEmits<Emits>()
const listRef = ref<HTMLUListElement | null>(null)
@@ -51,6 +60,20 @@ defineExpose({ focusRemove })
<template v-if="item.status === 'uploading'">올리는 중 {{ item.progress }}%</template>
<template v-else>{{ formatFileSize(item.size) }}</template>
</span>
<button
v-if="linkable(item)"
type="button"
class="upload-item__link"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.sendAsLink}`"
:title="LARGE_FILE_TEXT.sendAsLink"
data-send-as-link
@click="emit('send-as-link', item.key)"
>
<AppIcon
name="link"
:size="15"
/>
</button>
<button
type="button"
class="upload-item__remove"
@@ -127,6 +150,7 @@ defineExpose({ focusRemove })
font-variant-numeric: tabular-nums;
}
.upload-item__link,
.upload-item__remove {
flex: 0 0 auto;
display: inline-flex;
@@ -140,6 +164,7 @@ defineExpose({ focusRemove })
color: var(--color-text-secondary);
}
.upload-item__link:hover,
.upload-item__remove:hover {
background: var(--color-surface-muted);
}
@@ -65,3 +65,85 @@ describe('ComposeFooter — [보내기 ▾] 메뉴 자리', () => {
expect(menu.classes()).not.toContain('compose__send-menu--up')
})
})
// 11단계 — 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled (초점은 받는다). 첨부 때문에 막히면 까닭 글을 두고,
// 누르면 보내지 않고 까닭을 읽어 준다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
describe('ComposeFooter — 첨부 때문에 막힌 [보내기]', () => {
const REASON = '올리는 중인 파일이 있습니다 — 다 올리면 보낼 수 있습니다.'
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
function mountFooter(props: Partial<typeof PROPS> & { blockedReason?: string | null } = {}) {
wrapper = mount(ComposeFooter, { props: { ...PROPS, ...props }, attachTo: document.body })
return wrapper
}
/** 누름 — 양식 제출을 막았는지 본다 */
function click(element: Element): MouseEvent {
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
element.dispatchEvent(event)
return event
}
it('두 단추는 aria-disabled(초점은 받는다)이고 까닭 글을 가리킨다 · 임시 저장은 그대로 된다', () => {
const view = mountFooter({ blockedReason: REASON })
for (const selector of ['.compose__send', '.compose__send-more']) {
const button = view.get(selector)
expect(button.attributes('disabled')).toBeUndefined()
expect(button.attributes('aria-disabled')).toBe('true')
expect(button.attributes('aria-describedby')).toBe('compose-send-why')
}
expect(view.get('#compose-send-why').text()).toBe(REASON)
expect(view.get('#compose-save-draft').attributes('disabled')).toBeUndefined()
})
it('[보내기]를 누르면 제출하지 않고 까닭을 읽어 준다 · ▾ 는 메뉴를 열지 않는다', async () => {
const view = mountFooter({ blockedReason: REASON })
expect(click(view.get('.compose__send').element).defaultPrevented).toBe(true)
await flushPromises()
expect(view.get('[data-send-blocked-status]').text()).toBe(REASON)
await view.get('.compose__send-more').trigger('click')
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(false)
})
it('막는 까닭이 없으면 그대로 제출된다 — 까닭 글도 없다', () => {
const view = mountFooter()
const send = view.get('.compose__send')
expect(send.attributes('aria-disabled')).toBeUndefined()
expect(send.attributes('aria-describedby')).toBeUndefined()
expect(view.find('#compose-send-why').exists()).toBe(false)
expect(click(send.element).defaultPrevented).toBe(false)
})
it('보내는 중(canSend 거짓)도 aria-disabled 로 막지만 읽어 줄 까닭은 없다', async () => {
const view = mountFooter({ canSend: false, sending: true })
const send = view.get('.compose__send')
expect(send.attributes('aria-disabled')).toBe('true')
expect(send.attributes('aria-busy')).toBe('true')
expect(click(send.element).defaultPrevented).toBe(true)
await flushPromises()
expect(view.get('[data-send-blocked-status]').text()).toBe('')
})
it('메뉴가 열린 채 막히면 메뉴를 닫는다', async () => {
const view = mountFooter()
await view.get('.compose__send-more').trigger('click')
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(true)
await view.setProps({ blockedReason: REASON })
await flushPromises()
expect(view.find('[role="menu"]').exists()).toBe(false)
})
})
+96 -22
View File
@@ -3,12 +3,15 @@
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// Ctrl+Enter 는 그대로 [보내기]다. 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 첨부를 올리는 중 등 — canSend)
// 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · ▾ .compose__send-more · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
import { computed, ref, watch } from 'vue'
// 상태 #compose-draft-status(ComposeDraftStatus) · 까닭 #compose-send-why · 줄 .compose__footer
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
@@ -19,7 +22,10 @@ import { usePopupPlacement } from '@/composables/usePopupPlacement'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
interface Props {
/** 지금 보낼 수 있는가 — 편집기 준비 · 보내는 중 아님 등 (첨부 때문에 막힌 것은 blockedReason) */
canSend: boolean
/** 첨부 때문에 보내기를 막는 까닭 글 — 없으면 null */
blockedReason?: string | null
sending: boolean
canSaveDraft: boolean
draftStatus: DraftSaveStatus
@@ -36,9 +42,14 @@ interface Emits {
(e: 'schedule'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
blockedReason: null,
})
const emit = defineEmits<Emits>()
/** 까닭 글의 id — 두 단추가 가리킨다 */
const REASON_ID = 'compose-send-why'
const moreRef = ref<HTMLButtonElement | null>(null)
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
const menuOpen = ref<boolean>(false)
@@ -56,9 +67,28 @@ const { upward } = usePopupPlacement({
const items: readonly EditorMenuItem[] = [{ id: 'schedule', label: SEND_LATER_TEXT.scheduleItem, icon: 'clock' }]
const trigger = computed<HTMLElement | null>(() => moreRef.value)
/** 두 단추를 누를 수 없다 — 지금 보낼 수 없거나 첨부 때문에 막혔다 */
const unavailable = computed(() => !props.canSend || props.blockedReason !== null)
/** 누른 뒤 읽어 줄 까닭 (role=status) */
const announcement = ref<string>('')
/** 막힌 단추를 눌렀다 — 보내지 않고 까닭을 읽어 준다 (같은 글이어도 다시 읽게 비웠다가 넣는다) */
async function announceBlocked() {
if (!props.blockedReason) return
announcement.value = ''
await nextTick()
announcement.value = props.blockedReason
}
/** [보내기] — 막혔으면 양식을 제출하지 않는다 */
function onSendClick(event: MouseEvent) {
if (!unavailable.value) return
event.preventDefault()
void announceBlocked()
}
function openMenu(keyboard: boolean) {
if (!props.canSend) return
if (unavailable.value) return
byKeyboard.value = keyboard
menuOpen.value = true
}
@@ -68,6 +98,10 @@ function onMoreClick(event: MouseEvent) {
menuOpen.value = false
return
}
if (unavailable.value) {
void announceBlocked()
return
}
// detail 0 — Enter · Space 로 눌렀다
openMenu(event.detail === 0)
}
@@ -86,16 +120,13 @@ function onMenuClose(restoreFocus: boolean) {
function onSelect(id: string) {
menuOpen.value = false
moreRef.value?.focus()
if (id === 'schedule' && props.canSend) emit('schedule')
if (id === 'schedule' && !unavailable.value) emit('schedule')
}
// 보내는 중 등으로 꺼지면 메뉴도 닫는다
watch(
() => props.canSend,
(can) => {
if (!can) menuOpen.value = false
},
)
// 보내는 중 · 첨부를 올리기 시작함 등으로 꺼지면 메뉴도 닫는다
watch(unavailable, (off) => {
if (off) menuOpen.value = false
})
</script>
<template>
@@ -104,8 +135,10 @@ watch(
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="blockedReason ? REASON_ID : undefined"
:aria-busy="sending"
@click="onSendClick"
>
<AppIcon
name="send"
@@ -126,7 +159,8 @@ watch(
:title="SEND_LATER_TEXT.menuButton"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:disabled="!canSend"
:aria-disabled="unavailable ? 'true' : undefined"
:aria-describedby="blockedReason ? REASON_ID : undefined"
@click="onMoreClick"
@keydown="onMoreKeydown"
>
@@ -157,6 +191,22 @@ watch(
>
임시 저장
</button>
<p
v-if="blockedReason"
:id="REASON_ID"
class="compose__send-why"
>
<AppIcon
name="info"
:size="15"
/>
<span>{{ blockedReason }}</span>
</p>
<span
class="visually-hidden"
role="status"
data-send-blocked-status
>{{ announcement }}</span>
<ComposeDraftStatus
:status="draftStatus"
:saved-at="draftSavedAt"
@@ -211,8 +261,9 @@ watch(
box-shadow: inset 0 0 0 var(--border-width) var(--color-on-brand-divider);
}
.compose__send:disabled + .compose__send-divider {
opacity: 0.6;
/* 막힌 동안은 두 단추와 사이 선을 회색으로 (디자인 원본 ComposeLargeFiles: 바탕 #D0D5DD) */
.compose__send[aria-disabled='true'] + .compose__send-divider {
background: var(--color-border-strong);
}
/* ▾ — 44×44 (보드의 40 은 새 밀도 예외가 되므로 44 — README "9단계") */
@@ -233,17 +284,30 @@ watch(
background: var(--color-brand-strong);
}
.compose__send:hover:not(:disabled),
.compose__send-more:hover:not(:disabled) {
.compose__send:hover:not([aria-disabled='true']),
.compose__send-more:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
}
.compose__send:disabled,
.compose__send-more:disabled {
opacity: 0.6;
/* 꺼짐 — disabled 가 아니라 aria-disabled 라 초점은 받는다. 마우스를 올려도 그대로 */
.compose__send[aria-disabled='true'],
.compose__send-more[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
/* 보내는 중 — 브랜드색 그대로 흐리게 ("보내는 중…") */
.compose__send[aria-busy='true'] {
background: var(--color-brand);
opacity: 0.6;
}
.compose__send[aria-busy='true'] + .compose__send-divider,
.compose__send[aria-busy='true'] ~ .compose__send-more {
background: var(--color-brand);
opacity: 0.6;
}
/* 메뉴 폭 260 (디자인 원본 MailSendLater) — 단추 덩어리 왼쪽에 맞춘다 */
.compose__send-menu {
min-width: 16.25rem;
@@ -278,6 +342,16 @@ watch(
cursor: not-allowed;
}
/* 보내기를 막는 까닭 — ⓘ 12.5 (디자인 원본 ComposeLargeFiles) */
.compose__send-why {
display: flex;
align-items: center;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.compose__sender {
margin-left: auto;
font-size: var(--font-size-small);
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { createTestRouter } from '@/test-utils/router'
import ComposeLargeFileItem from './ComposeLargeFileItem.vue'
// 쓰기 화면의 링크 파일 한 줄 — 올리는 줄 오른쪽 글 (리뷰 L7 · L4 · L8)
// 조각을 다 보낸 뒤 끝내기를 기다리는 동안은 100% 에 "시간 계산 중"이 아니라 "마무리하는 중", 차례를 기다리는 파일은 "차례를 기다리는 중"
const MB = 1024 * 1024
function item(changes: Partial<LargeUploadItem>): LargeUploadItem {
return {
key: 1,
uploadId: 'u1',
name: '설계도_v3.dwg',
size: 64 * MB,
phase: 'uploading',
receivedBytes: 16 * MB,
sendingBytes: 0,
finishing: false,
waitingTurn: false,
scanStatus: null,
keptUntil: null,
missingReason: null,
errorText: null,
errorLinksSettings: false,
repickMismatch: false,
restarted: false,
etaMs: null,
...changes,
}
}
function render(changes: Partial<LargeUploadItem>) {
return mount(ComposeLargeFileItem, {
props: { item: item(changes) },
global: { plugins: [createTestRouter()] },
})
}
describe('ComposeLargeFileItem — 올리는 줄 오른쪽 글', () => {
it('올리는 중 — 처음에는 "시간 계산 중", 속도를 알면 남은 시간', () => {
expect(render({}).get('.large-upload__aside').text()).toBe('시간 계산 중')
expect(render({ etaMs: 3 * 60_000 }).get('.large-upload__aside').text()).toBe('약 3분 남음')
})
it('조각을 다 보냈으면 끝내기를 기다리는 동안(진행 중 409 의 기다림 포함) "마무리하는 중"', () => {
const view = render({ receivedBytes: 64 * MB, etaMs: null })
expect(view.get('.large-upload__aside').text()).toBe('마무리하는 중')
expect(view.get('.large-upload__line-text').text()).toBe('올리는 중 · 64 MB / 64 MB')
// 진행 알림(10% 마다)도 멈춘다
expect(view.find('[role="status"]').exists()).toBe(false)
})
it('만들었고 차례를 기다리는 파일 — "차례를 기다리는 중"', () => {
const view = render({ receivedBytes: 0, waitingTurn: true })
expect(view.get('.large-upload__aside').text()).toBe('차례를 기다리는 중')
})
it('[멈춤] 단추 이름은 "{파일} 올리기 멈춤"', () => {
expect(render({}).get('[data-large-pause]').attributes('aria-label')).toBe('설계도_v3.dwg 올리기 멈춤')
})
})
@@ -0,0 +1,519 @@
<script setup lang="ts">
// 쓰기 화면의 링크 파일 한 줄 (디자인 원본 ComposeLargeFiles · README "11단계 › 쓰기 화면 — 첨부 칸")
// 두 줄 — 아이콘(링크 · 청록) · 이름 · 단추(40) / 크기 · 상태(12px, 아이콘 선만큼 26px 들여). 올리는 동안 3px 막대. 뱃지 · 알약 없이 글자만.
// 상태: 올리는 중(+ 남은 시간 · 처음 몇 초는 "시간 계산 중" · 차례를 기다리는 중 · 조각을 다 보낸 뒤 "마무리하는 중") ·
// 다시 잇는 중(갈색) · 멈춤(회색 막대) · 오래 끊김(빨간 줄 + [다시 시도]) ·
// 다 올림(검사 중 · 검사하지 못함 · 깨끗하면 크기만) · 위험(빨간 줄 · 경고 삼각형) · 다시 열어 멈춘 파일(노란 줄 + [파일 고르기]) · 없는 파일 · 올리기 전 거절.
// ✕ 는 「빼기」 — 올리던 것을 멈추고 서버의 조각도 지운다. 화면 낭독기에는 진행을 10% 마다 알린다(보이지 않는 status).
// [파일 고르기]는 보이지 않는 파일 입력을 연다 — 고른 파일은 쓰기 화면이 같은 파일인지 맞춘다(repick)
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import type { LargeUploadItem } from '@/composables/useLargeFileUploads'
import { COMPOSE_ATTACHMENT_ACCEPT, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { formatFileSize } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
import { formatEta, formatUploadProgress, progressPercent } from '@/shared/utils/large-file'
interface Props {
item: LargeUploadItem
}
interface Emits {
(e: 'pause', key: number): void
(e: 'resume', key: number): void
(e: 'remove', key: number): void
(e: 'repick', key: number, file: File): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 줄 모양 — 보통 · 노랑(다시 골라야 이어진다) · 빨강(빼야 한다 · 오래 끊김 · 거절) */
type RowTone = 'normal' | 'warning' | 'danger'
/** 막대 색 — 청록(올리는 중) · 회색(멈춤) · 빨강(오래 끊김) · 갈색(다시 골라야 한다) */
type BarTone = 'brand' | 'muted' | 'danger' | 'warning'
interface ItemView {
tone: RowTone
/** 둘째 줄 글 */
line: string
/** 둘째 줄의 글 색 — 회색 · 갈색(다시 잇는 중 · 다시 고르기) · 빨강(role=alert) */
lineTone: 'muted' | 'warning' | 'danger'
/** 둘째 줄 오른쪽 — 남은 시간 · 마무리하는 중 */
aside: string | null
bar: BarTone | null
/** 첫 줄 오른쪽의 크기 — 거절된 줄만 (이름 옆) */
sizeBeside: boolean
}
const uploaded = computed(() => props.item.receivedBytes + props.item.sendingBytes)
const progressText = computed(() => formatUploadProgress(uploaded.value, props.item.size))
const percent = computed(() => progressPercent(uploaded.value, props.item.size))
/** 화면 낭독기에 알릴 진행 — 10% 단위로만 바뀐다 */
const announcedPercent = computed(() => Math.floor(percent.value / 10) * 10)
/** 조각은 다 갔고 서버가 파일로 만드는 중 — 끝내기를 기다리는 동안(진행 중 409 의 기다림 포함) 100% 에 "시간 계산 중"이 아니다 */
const finishingNow = computed(
() => props.item.finishing || (props.item.uploadId !== null && props.item.size > 0 && props.item.receivedBytes >= props.item.size),
)
/** 올리는 줄 오른쪽 — 마무리하는 중 · 차례를 기다리는 중 · 남은 시간(처음 몇 초는 "시간 계산 중") */
const uploadingAside = computed(() => {
if (finishingNow.value) return LARGE_FILE_TEXT.finishing
if (props.item.waitingTurn) return LARGE_FILE_TEXT.queued
return props.item.etaMs === null ? LARGE_FILE_TEXT.etaCalculating : formatEta(props.item.etaMs)
})
const view = computed<ItemView>(() => {
const item = props.item
const size = formatFileSize(item.size)
switch (item.phase) {
case 'uploading':
return {
tone: 'normal',
line: (item.restarted ? LARGE_FILE_TEXT.restarting : LARGE_FILE_TEXT.uploading)(progressText.value),
lineTone: 'muted',
aside: uploadingAside.value,
bar: 'brand',
sizeBeside: false,
}
case 'reconnecting':
return {
tone: 'normal',
line: LARGE_FILE_TEXT.reconnecting(progressText.value),
lineTone: 'warning',
aside: null,
bar: 'brand',
sizeBeside: false,
}
case 'paused':
return {
tone: 'normal',
line: LARGE_FILE_TEXT.paused(progressText.value),
lineTone: 'muted',
aside: null,
bar: 'muted',
sizeBeside: false,
}
case 'stalled':
return {
tone: 'danger',
line:
item.errorText ??
(item.receivedBytes > 0
? LARGE_FILE_TEXT.stalled(formatFileSize(item.receivedBytes))
: LARGE_FILE_TEXT.stalledEmpty),
lineTone: 'danger',
aside: null,
bar: 'danger',
sizeBeside: false,
}
case 'waiting-file':
return {
tone: 'warning',
line: LARGE_FILE_TEXT.waitingFile(progressText.value),
lineTone: 'warning',
aside: null,
bar: 'warning',
sizeBeside: false,
}
case 'ready': {
if (item.scanStatus === 'infected') {
return { tone: 'danger', line: LARGE_FILE_TEXT.infected, lineTone: 'danger', aside: null, bar: null, sizeBeside: false }
}
const line =
item.scanStatus === 'pending'
? LARGE_FILE_TEXT.scanning(size)
: item.scanStatus === 'unscannable'
? LARGE_FILE_TEXT.unscannable(size)
: item.keptUntil
? LARGE_FILE_TEXT.keptInDraft(size, formatKstMonthDay(item.keptUntil))
: size
return { tone: 'normal', line, lineTone: 'muted', aside: null, bar: null, sizeBeside: false }
}
case 'missing':
return {
tone: 'danger',
line:
item.missingReason === 'expired'
? LARGE_FILE_TEXT.missingExpired
: item.missingReason === 'deleted'
? LARGE_FILE_TEXT.missingDeleted
: LARGE_FILE_TEXT.missingUnknown,
lineTone: 'danger',
aside: null,
bar: null,
sizeBeside: false,
}
// 'rejected' — 올리기 전 검사 · 만들기에서 거절 (이름 옆에 크기)
default:
return {
tone: 'danger',
line: item.errorText ?? LARGE_FILE_TEXT.stalledEmpty,
lineTone: 'danger',
aside: null,
bar: null,
sizeBeside: true,
}
}
})
/** 올리는 동안만 진행을 알린다 (차례를 기다리거나 마무리하는 동안은 바뀌지 않는다) */
const announcing = computed(() => props.item.phase === 'uploading' && !finishingNow.value && !props.item.waitingTurn)
function pickFile() {
fileInputRef.value?.click()
}
function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 같은 파일을 다시 고를 수 있게 비운다
input.value = ''
if (file) emit('repick', props.item.key, file)
}
</script>
<template>
<li
:class="['large-upload', `large-upload--${view.tone}`]"
:data-large-upload="item.key"
:data-phase="item.phase"
>
<div class="large-upload__row">
<AppIcon
:name="view.tone === 'danger' && item.phase === 'ready' ? 'alert-triangle' : 'link'"
class="large-upload__icon"
/>
<span class="large-upload__name">{{ item.name }}</span>
<span
v-if="view.sizeBeside"
class="large-upload__size"
>{{ formatFileSize(item.size) }}</span>
<button
v-if="item.phase === 'uploading' || item.phase === 'reconnecting'"
type="button"
class="large-upload__icon-button"
:aria-label="LARGE_FILE_TEXT.pauseLabel(item.name)"
:title="LARGE_FILE_TEXT.pause"
data-large-pause
@click="emit('pause', item.key)"
>
<AppIcon
name="pause"
:size="14"
:stroke-width="2"
/>
</button>
<button
v-else-if="item.phase === 'paused'"
type="button"
class="large-upload__icon-button"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.resume}`"
:title="LARGE_FILE_TEXT.resume"
data-large-resume
@click="emit('resume', item.key)"
>
<AppIcon
name="play"
:size="14"
:stroke-width="2"
/>
</button>
<button
v-else-if="item.phase === 'stalled'"
type="button"
class="large-upload__text-button large-upload__text-button--danger"
data-large-retry
@click="emit('resume', item.key)"
>
{{ LARGE_FILE_TEXT.retry }}
</button>
<template v-else-if="item.phase === 'waiting-file'">
<button
type="button"
class="large-upload__text-button large-upload__text-button--warning"
data-large-repick
@click="pickFile"
>
{{ LARGE_FILE_TEXT.repick }}
</button>
<input
ref="fileInputRef"
type="file"
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="COMPOSE_ATTACHMENT_ACCEPT"
:aria-label="LARGE_FILE_TEXT.repickLabel(item.name)"
@change="onFileChosen"
>
</template>
<button
type="button"
class="large-upload__icon-button"
:aria-label="`${item.name} ${LARGE_FILE_TEXT.remove}`"
:title="LARGE_FILE_TEXT.remove"
data-large-remove
@click="emit('remove', item.key)"
>
<AppIcon
name="x"
:size="14"
:stroke-width="2"
/>
</button>
</div>
<p
:class="['large-upload__line', `large-upload__line--${view.lineTone}`]"
:role="view.lineTone === 'danger' ? 'alert' : undefined"
>
<span class="large-upload__line-text">
{{ view.line }}
<RouterLink
v-if="item.phase === 'rejected' && item.errorLinksSettings"
class="large-upload__link"
:to="{ name: SETTINGS_ROUTES.largeFiles }"
>{{ LARGE_FILE_TEXT.quotaLink }}</RouterLink>
</span>
<span
v-if="view.aside"
class="large-upload__aside"
>{{ view.aside }}</span>
</p>
<progress
v-if="view.bar"
:class="['large-upload__progress', `large-upload__progress--${view.bar}`]"
max="100"
:value="percent"
aria-hidden="true"
/>
<p
v-if="item.phase === 'waiting-file' && item.repickMismatch"
class="large-upload__line large-upload__line--danger large-upload__mismatch"
role="alert"
>
{{ LARGE_FILE_TEXT.repickMismatch }}
</p>
<span
v-if="announcing"
class="visually-hidden"
role="status"
>{{ LARGE_FILE_TEXT.progressAnnounce(item.name, String(announcedPercent)) }}</span>
</li>
</template>
<style scoped>
.large-upload {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-2) var(--space-8) var(--space-10);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
.large-upload__row {
display: flex;
align-items: center;
gap: var(--space-10);
}
/* 링크 아이콘 — 청록 (일반 첨부의 파일 아이콘과 가른다) */
.large-upload__icon {
color: var(--color-brand-strong);
}
.large-upload__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.large-upload__size {
flex: 0 0 auto;
font-size: var(--font-size-small);
font-variant-numeric: tabular-nums;
}
/* 40×40 — 일반 첨부의 ✕ 와 같은 크기 */
.large-upload__icon-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-40);
height: var(--space-40);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
}
.large-upload__icon-button:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* [다시 시도] · [파일 고르기] — 높이 40, 흰 바탕 테두리 단추 */
.large-upload__text-button {
flex: 0 0 auto;
min-height: var(--space-40);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
font-weight: 600;
}
.large-upload__text-button--danger {
border-color: var(--color-danger-border);
color: var(--color-danger);
}
.large-upload__text-button--danger:hover {
background: var(--color-danger-surface);
}
.large-upload__text-button--warning {
border-color: var(--color-warning-border);
color: var(--color-warning-text);
}
.large-upload__text-button--warning:hover {
background: var(--color-warning-hover);
}
/* 둘째 줄 — 아이콘 선만큼 26px 들여 (아이콘 16 + 사이 10) */
.large-upload__line {
display: flex;
gap: var(--space-8);
margin: 0;
padding: 0 var(--space-10) 0 calc(var(--space-16) + var(--space-10));
font-size: var(--font-size-small);
font-variant-numeric: tabular-nums;
}
.large-upload__line-text {
flex: 1 1 auto;
min-width: 0;
}
.large-upload__aside {
flex: 0 0 auto;
}
.large-upload__line--muted {
color: var(--color-text-muted);
}
.large-upload__line--warning {
color: var(--color-warning-text);
}
/* 빨간 글 — 12.5 (일반 첨부의 실패 글과 같다) */
.large-upload__line--danger {
font-size: var(--font-size-label);
color: var(--color-danger);
}
.large-upload__mismatch {
margin-top: var(--space-4);
}
.large-upload__link {
margin-left: var(--space-4);
color: inherit;
font-weight: 600;
}
/* 3px 막대 — 인라인 스타일 대신 <progress> 를 꾸민다 (일반 첨부의 진행률과 같은 방법) */
.large-upload__progress {
appearance: none;
width: calc(100% - var(--space-16) - var(--space-10) - var(--space-10));
height: var(--space-3);
margin: var(--space-4) var(--space-10) 0 calc(var(--space-16) + var(--space-10));
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.large-upload__progress::-webkit-progress-bar {
border-radius: var(--space-2);
background: var(--color-skeleton);
}
.large-upload__progress::-webkit-progress-value {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-upload__progress::-moz-progress-bar {
border-radius: var(--space-2);
background: var(--color-brand);
}
/* 멈춤 — 회색 막대 */
.large-upload__progress--muted::-webkit-progress-value {
background: var(--color-icon-muted);
}
.large-upload__progress--muted::-moz-progress-bar {
background: var(--color-icon-muted);
}
/* 오래 끊김 — 빨간 막대 · 옅은 빨강 바탕 */
.large-upload__progress--danger,
.large-upload__progress--danger::-webkit-progress-bar {
background: var(--color-danger-border);
}
.large-upload__progress--danger::-webkit-progress-value {
background: var(--color-danger);
}
.large-upload__progress--danger::-moz-progress-bar {
background: var(--color-danger);
}
/* 다시 골라야 이어지는 파일 — 갈색 막대(#B7791F — 같은 값의 토큰 --color-star 를 다시 쓴다) · 노란 테두리 바탕 */
.large-upload__progress--warning,
.large-upload__progress--warning::-webkit-progress-bar {
background: var(--color-warning-border);
}
.large-upload__progress--warning::-webkit-progress-value {
background: var(--color-star);
}
.large-upload__progress--warning::-moz-progress-bar {
background: var(--color-star);
}
/* 노란 줄 — 저장본에서 이어 온 올리다 멈춘 파일 */
.large-upload--warning {
border-color: var(--color-warning-border);
background: var(--color-warning-surface);
}
/* 빨간 줄 — 빼야 하는 파일 · 오래 끊김 · 올리기 전 거절 */
.large-upload--danger {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.large-upload--danger .large-upload__name,
.large-upload--danger .large-upload__size,
.large-upload--danger .large-upload__icon {
color: var(--color-danger);
}
</style>
@@ -2,13 +2,17 @@
// 쓰기 화면 나가기 전 확인 — 디자인 README "2단계 › 임시 저장 › 나가기 확인" (공통 틀 LeaveConfirmDialog · ModalDialog)
// 떠나기 전에 남은 변경을 저장하고, 저장하지 못한 내용이 있을 때만 쓰기 화면이 연다.
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
// 대용량 파일을 올리는 중이면 글이 바뀐다 — "올리는 중인 파일이 있습니다" / 나가면 올리기가 멈추고, 저장본을 다시 열어 같은 파일을 고르면 잇는다 (11단계)
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import { COMPOSE_LEAVE_UPLOADING_DIALOG } from '@/shared/constants/large-file'
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
open: boolean
/** 대용량 파일을 올리는 중이다 — 올리기가 멈춘다는 글로 묻는다 */
uploading?: boolean
}
interface Emits {
@@ -18,7 +22,9 @@ interface Emits {
(e: 'leave'): void
}
defineProps<Props>()
withDefaults(defineProps<Props>(), {
uploading: false,
})
const emit = defineEmits<Emits>()
</script>
@@ -26,7 +32,7 @@ const emit = defineEmits<Emits>()
<LeaveConfirmDialog
:open="open"
id-prefix="compose-leave"
:copy="COMPOSE_LEAVE_DIALOG"
:copy="uploading ? COMPOSE_LEAVE_UPLOADING_DIALOG : COMPOSE_LEAVE_DIALOG"
@stay="emit('stay')"
@leave="emit('leave')"
/>
@@ -0,0 +1,67 @@
<script setup lang="ts">
// 대용량 첨부 링크 끊기 확인 — alertdialog 400 (디자인 원본 MailReadLargeFiles · SettingsLargeFiles)
// "대용량 첨부 링크를 끊을까요?" + 글(읽기 화면은 파일 이름, 설정은 메일 제목) · [링크 끊기](빨강 채움) [취소]
// 되돌릴 수 없는 일이라 처음 초점은 「취소」, Esc 도 취소다. 끊는 동안은 [링크 끊기]를 aria-disabled 로 막는다(초점을 지킨다).
// 쓰는 곳: 읽기 화면의 보낸 사람 자신 줄(MessageLargeFileLinkBar) · 설정 › 대용량 첨부(LargeFileLinkList) — 둘이 함께 보일 수 없지만 id 앞부분을 따로 받는다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { LARGE_FILE_REVOKE_TEXT } from '@/shared/constants/large-file'
interface Props {
open: boolean
/** 창 글 — 읽기 화면은 파일 이름을, 설정은 메일 제목을 넣어 만든다 */
message: string
/** 끊는 중 — [링크 끊기]를 막는다 */
busy?: boolean
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onConfirm() {
if (!props.busy) emit('confirm')
}
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
medium
:title="LARGE_FILE_REVOKE_TEXT.title"
:message="message"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('cancel')"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
data-revoke-confirm
@click="onConfirm"
>
{{ LARGE_FILE_REVOKE_TEXT.confirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
data-revoke-cancel
@click="emit('cancel')"
>
{{ LARGE_FILE_REVOKE_TEXT.cancel }}
</button>
</template>
</ModalDialog>
</template>
+14 -2
View File
@@ -13,6 +13,7 @@
// 외부 이미지를 보이는 본문(imagesShown — 서버가 중계 주소로 바꿔 넣었다, 9단계)일 때만 CSP 의 그림에 우리 출처의 중계 경로
// (<출처>/api/mail-images/ — 'self' 전체가 아니다)를 더하고 정화가 중계 주소의 그림을 남긴다 — sandbox 는 그대로다 (mail-html.ts).
// 내용 높이를 잴 때마다 measured 를 알린다 — 인쇄 화면이 다 잰 뒤(그림까지 받은 뒤 — iframe 의 load) 인쇄 창을 띄운다
// 대용량 첨부 덩이(11단계)는 foldLargeFiles 면 접어 그린다 — 본문 틀 안의 details 를 열고 닫으면(toggle) 높이를 다시 잰다
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { buildMailDocument } from '@/shared/utils/mail-html'
@@ -26,6 +27,8 @@ interface Props {
imagesShown?: boolean
/** iframe 이름 (스크린리더) */
title?: string
/** 대용량 첨부 덩이를 접는다 — 읽기 화면이 첨부 칸에 그 파일을 보일 때 (인쇄 화면은 펼친 채) */
foldLargeFiles?: boolean
}
interface Emits {
@@ -43,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
textBody: null,
imagesShown: false,
title: '메일 본문',
foldLargeFiles: false,
})
const emit = defineEmits<Emits>()
@@ -54,7 +58,12 @@ const frameRef = ref<HTMLIFrameElement | null>(null)
const contentHeight = ref<number | null>(null)
const srcdoc = computed(() =>
buildMailDocument({ htmlBody: props.htmlBody, textBody: props.textBody, imagesShown: props.imagesShown }),
buildMailDocument({
htmlBody: props.htmlBody,
textBody: props.textBody,
imagesShown: props.imagesShown,
foldLargeFiles: props.foldLargeFiles,
}),
)
let resizeObserver: ResizeObserver | null = null
@@ -88,7 +97,10 @@ function onFrameKeydown(event: KeyboardEvent) {
function onLoad() {
documentLoaded = true
// 문서를 새로 열 때마다 단다 — 옛 문서와 함께 옛 처리기도 사라진다
frameRef.value?.contentDocument?.addEventListener('keydown', onFrameKeydown)
const doc = frameRef.value?.contentDocument
doc?.addEventListener('keydown', onFrameKeydown)
// 접은 덩이를 열고 닫으면 높이가 바뀐다 — toggle 은 거품이 일지 않아 잡는 단계(capture)에서 받는다
doc?.addEventListener('toggle', scheduleMeasure, true)
measure()
}
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { CLOCK } from '@/composables/useClock'
import type { MessageLargeFile, SharedFileStatus, ShareLink } from '@/shared/types/large-file'
import type { MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
import MessageAttachments from './MessageAttachments.vue'
// 읽기 화면의 대용량 첨부 (11단계 — 디자인 원본 MailReadLargeFiles · 계약 4절 "우리 웹메일에서 읽기")
// 첨부 칸에 첨부처럼 · 끝나는 날 · 상태 글 · 보낸 사람 자신의 받은 수와 [링크 복사] · [링크 끊기](확인 창 — 처음 초점 「취소」)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const GB = 1024 * 1024 * 1024
const MB = 1024 * 1024
const URL = 'https://files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
function largeFile(changes: Partial<MessageLargeFile>): MessageLargeFile {
return {
linkId: 'link1',
fileId: 'f1',
name: '설계도_v3.dwg',
size: 1.2 * GB,
status: 'available',
expiresAt: '2026-11-29T14:59:59.999Z',
revokedAt: null,
url: URL,
downloads: null,
...changes,
}
}
const DESIGN = largeFile({})
const VIDEO = largeFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB })
describe('MessageAttachments — 대용량 첨부', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
let downloads: string[]
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
downloads = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function mountWith(largeFiles: MessageLargeFile[], handler: MockHandler = () => ok(USER)) {
const requests = installMockApi(handler)
wrapper = mount(MessageAttachments, {
props: { messageId: 'm1', attachments: [], largeFiles },
global: { plugins: [pinia] },
attachTo: document.body,
})
return { wrapper, requests }
}
it('사내 받는 사람 — 첨부처럼 "크기 · 끝나는 날", 「첨부 파일 N개」는 둘을 합한 수 · 받기는 세션을 확인한 뒤 그 메일의 경로로', async () => {
const { wrapper: view, requests } = mountWith([DESIGN, VIDEO])
expect(view.get('#reader-attachments-title').text()).toBe('첨부 파일 2개')
expect(view.findAll('.large-item__info').map((info) => info.text())).toEqual([
'1.2 GB · 11월 29일까지',
'612 MB · 11월 29일까지',
])
// 받는 사람에게는 링크 줄이 없다
expect(view.find('[data-large-link]').exists()).toBe(false)
const link = view.get<HTMLAnchorElement>('[data-large-file="f1"] a.large-item__download')
expect(link.attributes('href')).toBe('/api/messages/m1/large-files/f1')
await link.trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
expect(downloads).toEqual(['/api/messages/m1/large-files/f1'])
})
it('검사 중이면 받기 단추를 aria-disabled 로 끄고, 누르면 까닭을 읽어 준다 · 위험 · 끝난 링크는 단추가 없다', async () => {
const { wrapper: view } = mountWith([
largeFile({ status: 'scanning' }),
largeFile({ fileId: 'f2', status: 'infected', name: '견적_요청서.zip' }),
largeFile({ fileId: 'f3', status: 'revoked' }),
])
const blocked = view.get('[data-large-file="f1"] .large-item__download')
expect(blocked.element.tagName).toBe('BUTTON')
expect(blocked.attributes('aria-disabled')).toBe('true')
await blocked.trigger('click')
await flushPromises()
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')
expect(view.find('[data-large-file="f2"] .large-item__download').exists()).toBe(false)
expect(view.get('[data-large-file="f3"]').classes()).toContain('large-item--dim')
expect(view.find('[data-large-file="f3"] .large-item__download').exists()).toBe(false)
})
it('보낸 사람 자신 — 받은 수와 링크 줄. [링크 복사]는 덩이의 주소를 복사한다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const own = { count: 3, lastDownloadedAt: '2026-10-30T05:41:00Z' }
const { wrapper: view } = mountWith([
largeFile({ downloads: own }),
largeFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, downloads: { count: 0, lastDownloadedAt: null } }),
])
expect(view.findAll('.large-item__info').map((info) => info.text())).toEqual([
'1.2 GB · 3회 받음 · 11월 29일까지',
'612 MB · 아직 받지 않음 · 11월 29일까지',
])
expect(view.get('.link-bar__note').text()).toBe(
'링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다.',
)
await view.get('[data-link-copy]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(URL)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['링크를 복사했습니다.'])
})
it('[링크 끊기] — 확인 창(처음 초점 「취소」) 뒤 끊고, 첨부 칸은 "공유 멈춤" · 알림 하나(되돌리기 없음)', async () => {
const revoked: ShareLink = {
id: 'link1',
sentEmailId: 'm1',
subject: '11월 현장 점검 자료',
sentAt: '2026-10-30T05:15:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
status: 'revoked',
revokedAt: '2026-10-31T01:00:00Z',
files: [
{ id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'revoked', downloadCount: 3, lastDownloadedAt: null },
],
}
const own = { count: 3, lastDownloadedAt: null }
const mailStore = useMailStore()
mailStore.selected = { id: 'm1', largeFiles: [largeFile({ downloads: own })] } as unknown as MessageDetail
const { wrapper: view, requests } = mountWith([largeFile({ downloads: own })], ({ method, url }) =>
method === 'POST' && url === '/share-links/link1/revocation' ? ok(revoked) : ok({}),
)
await view.get('[data-link-revoke]').trigger('click')
await flushPromises()
const dialog = view.get('dialog[role="alertdialog"]')
expect(dialog.text()).toContain('대용량 첨부 링크를 끊을까요?')
expect(dialog.text()).toContain('받는 사람 모두 이 메일의 파일 1개(설계도_v3.dwg)를 더는 내려받을 수 없습니다.')
expect(document.activeElement?.hasAttribute('data-revoke-cancel')).toBe(true)
await view.get('[data-revoke-confirm]').trigger('click')
await flushPromises()
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['POST /share-links/link1/revocation'])
expect(mailStore.selected?.largeFiles?.[0]?.status).toBe('revoked')
expect(mailStore.selected?.largeFiles?.[0]?.revokedAt).toBe('2026-10-31T01:00:00Z')
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
['링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.', null],
])
})
it('검사 중인 파일 — 막힌 [다운로드]를 누르면 한 번 바로 묻고, 15초 뒤 다시 묻는다 · 끝나면 받기 단추가 생기고 더 묻지 않는다 (리뷰 L5)', async () => {
const clock = createManualClock(Date.parse('2026-10-30T05:15:00Z'))
const mailStore = useMailStore()
mailStore.selected = { id: 'm1', largeFiles: [largeFile({ status: 'scanning' })] } as unknown as MessageDetail
let status: SharedFileStatus = 'scanning'
const requests = installMockApi(({ method, url }) =>
method === 'GET' && url === '/messages/m1'
? ok({ id: 'm1', largeFiles: [largeFile({ status })] })
: ok(USER),
)
// 읽기 화면처럼 열린 메일의 대용량 첨부를 그대로 넘긴다
const Reader = defineComponent({
setup() {
return () =>
h(MessageAttachments, { messageId: 'm1', attachments: [], largeFiles: mailStore.selected?.largeFiles ?? [] })
},
})
wrapper = mount(Reader, {
global: { plugins: [pinia], provide: { [CLOCK as symbol]: clock } },
attachTo: document.body,
})
const refetches = () => requests.filter((request) => request.method === 'GET' && request.url === '/messages/m1')
await wrapper.get('[data-large-file="f1"] .large-item__download').trigger('click')
await flushPromises()
expect(refetches()).toHaveLength(1)
expect(refetches()[0]?.config.silent).toBe(true)
status = 'available'
clock.advance(15_000)
await flushPromises()
expect(refetches()).toHaveLength(2)
expect(wrapper.get('[data-large-file="f1"] .large-item__download').element.tagName).toBe('A')
clock.advance(600_000)
await flushPromises()
expect(refetches()).toHaveLength(2)
})
})
@@ -3,19 +3,82 @@
// 주소는 axios baseURL 과 같은 규칙으로 만들어야 같은 출처·쿠키 경로로 요청이 간다 (useMail.attachmentDownloadUrl)
// 이동은 axios 의 401 갱신 규칙을 타지 않으므로, 누르면 바로 이동하지 않고 세션부터 확인한 뒤 받는다.
// href 는 그대로 둔다 — "링크 주소 복사" 같은 브라우저 기본 기능을 지키기 위해서다.
// 대용량 첨부(11단계 — MessageDetail.largeFiles)도 같은 칸에 첨부처럼 — 「첨부 파일 N개」는 둘을 합한 수 (MessageLargeFileItem).
// 보낸 사람 자신(링크 주인)에게는 칸 아래에 링크 줄 — 받은 수 · [링크 복사] · [링크 끊기] (MessageLargeFileLinkBar — 끝난 링크는 없다)
// 보안 검사 중인 대용량 첨부가 있는 동안은 상태를 가끔 다시 묻고, 막힌 [다운로드]를 누르면 한 번 바로 묻는다 (useLargeFileScanRefresh)
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MessageLargeFileItem from '@/components/mail/MessageLargeFileItem.vue'
import MessageLargeFileLinkBar from '@/components/mail/MessageLargeFileLinkBar.vue'
import { useClock } from '@/composables/useClock'
import { useLargeFileScanRefresh } from '@/composables/useLargeFileScanRefresh'
import { useMail } from '@/composables/useMail'
import type { MessageLargeFile } from '@/shared/types/large-file'
import type { Attachment } from '@/shared/types/mail'
import { formatFileSize } from '@/shared/utils/format'
import { isEndedLinkStatus } from '@/shared/utils/large-file-status'
import { useMailStore } from '@/stores/mail.store'
interface Props {
messageId: string
attachments: Attachment[]
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 온다 */
largeFiles?: MessageLargeFile[]
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
largeFiles: () => [],
})
const { attachmentDownloadUrl, downloadAttachment } = useMail()
const mailStore = useMailStore()
/** 검사 중인 파일이 있는 동안 열린 메일의 대용량 첨부를 다시 받는다 (메일을 바꾸거나 닫으면 · 세션이 바뀌면 멈춘다) */
const scanRefresh = useLargeFileScanRefresh({
messageId: () => props.messageId,
scanning: () => props.largeFiles.some((file) => file.status === 'scanning'),
refresh: () => mailStore.refreshOpenedLargeFiles(),
clock: useClock(),
})
const titleRef = ref<HTMLHeadingElement | null>(null)
/** 받을 수 없는 단추를 눌렀을 때 읽어 줄 까닭 (role=status) */
const announcement = ref<string>('')
const totalCount = computed(() => props.attachments.length + props.largeFiles.length)
/**
* 보낸 사람 자신의 살아 있는 링크 — 링크(linkId)마다 파일을 묶는다 (보통 한 통에 하나).
* 받은 수(downloads)가 오면 보는 사람이 링크 주인이다. 끝난 링크(끊음 · 기간 지남)는 줄이 없다
*/
const ownLinks = computed(() => {
const byLink = new Map<string, MessageLargeFile[]>()
for (const file of props.largeFiles) {
if (file.downloads === null) continue
byLink.set(file.linkId, [...(byLink.get(file.linkId) ?? []), file])
}
return [...byLink.values()].filter((files) => !files.some((file) => isEndedLinkStatus(file.status)))
})
/** 까닭을 다시 읽게 비웠다가 넣는다 */
async function announce(reason: string) {
announcement.value = ''
await nextTick()
announcement.value = reason
}
/** 받을 수 없는 대용량 첨부(검사 중)를 눌렀다 — 까닭을 읽어 주고, 끝났는지 한 번 바로 묻는다 */
function onLargeFileBlocked(reason: string) {
void announce(reason)
scanRefresh.refreshNow()
}
/** 링크를 끊어 줄이 사라졌다 — 초점을 칸 제목으로 (누른 단추가 없어졌다) */
async function onRevoked() {
await nextTick()
const active = document.activeElement
if (active === null || active === document.body) titleRef.value?.focus()
}
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
@@ -44,9 +107,11 @@ function onDownload(event: MouseEvent, attachmentId: string) {
>
<h3
id="reader-attachments-title"
ref="titleRef"
class="attachments__title"
tabindex="-1"
>
첨부 파일 {{ attachments.length }}개
첨부 파일 {{ totalCount }}개
</h3>
<ul class="attachments__list">
<li
@@ -75,7 +140,26 @@ function onDownload(event: MouseEvent, attachmentId: string) {
<AppIcon name="download" />
</a>
</li>
<MessageLargeFileItem
v-for="file in largeFiles"
:key="`${file.linkId}/${file.fileId}`"
:message-id="messageId"
:file="file"
@blocked="onLargeFileBlocked"
/>
</ul>
<MessageLargeFileLinkBar
v-for="files in ownLinks"
:key="files[0]?.linkId"
:files="files"
@revoked="onRevoked"
/>
<p
class="visually-hidden"
role="status"
>
{{ announcement }}
</p>
</section>
</template>
@@ -90,6 +174,7 @@ function onDownload(event: MouseEvent, attachmentId: string) {
.attachments__title {
margin: 0;
outline-offset: var(--focus-ring-offset);
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
@@ -0,0 +1,198 @@
<script setup lang="ts">
// 읽기 화면의 대용량 첨부 한 줄 — 첨부 칸에 일반 첨부와 같은 모양으로 (디자인 원본 MailReadLargeFiles · 계약 4절 "우리 웹메일에서 읽기")
// 이름 아래 "1.2 GB · 11월 29일까지"(끝나는 날 — 한국 시간). 보낸 사람 자신에게는 받은 수를 더한다.
// [다운로드](44)는 내려받기 화면을 거치지 않고 바로 받는다 — 첨부처럼 <a href> 이동이고, 이동 전에 세션을 확인한다(confirmSession).
// 검사 중이면 [다운로드]를 aria-disabled 로 끈다(초점은 받는다 — 누르면 까닭을 읽어 준다, blocked). 위험 · 끝난 링크는 단추가 없다.
// 흐리게(기간 지남 · 공유 멈춤 · 횟수 초과 · 찾을 수 없음)는 투명도가 아니라 글자색이다 — 이름 #667085 · 아이콘 #98A2B3 (대비 4.5:1 이상)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { messageLargeFileView } from '@/shared/utils/large-file-status'
interface Props {
messageId: string
file: MessageLargeFile
}
interface Emits {
/** 검사 중이라 받을 수 없는 단추를 눌렀다 — 까닭을 화면 낭독기에 읽어 준다 */
(e: 'blocked', reason: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { messageLargeFileUrl, downloadMessageLargeFile } = useLargeFiles()
/** 가운데 버튼(새 탭으로 열기) — 열어도 결국 파일을 받으므로 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const view = computed(() => messageLargeFileView(props.file))
const metaId = computed(() => `reader-large-file-${props.file.fileId}`)
const label = computed(() => MESSAGE_LARGE_FILE_TEXT.download(props.file.name))
/** 세션을 확인하는 중 — 기다리는 사이 다시 눌러도 한 번만 받는다 */
let pending = false
function onDownload(event: MouseEvent) {
// 오른쪽 버튼(메뉴)은 브라우저에 맡긴다
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (pending) return
pending = true
void downloadMessageLargeFile(props.messageId, props.file.fileId).finally(() => {
pending = false
})
}
</script>
<template>
<li
:class="['large-item', `large-item--${view.tone}`]"
:data-large-file="file.fileId"
>
<span
class="large-item__icon"
aria-hidden="true"
>
<AppIcon :name="view.tone === 'danger' ? 'alert-triangle' : 'file'" />
</span>
<div class="large-item__meta">
<span class="large-item__name">{{ file.name }}</span>
<span
:id="metaId"
class="large-item__info"
>{{ view.meta }}</span>
</div>
<a
v-if="view.download === 'ready'"
class="large-item__download"
:href="messageLargeFileUrl(messageId, file.fileId)"
:aria-label="label"
download
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon name="download" />
</a>
<button
v-else-if="view.download === 'blocked'"
type="button"
class="large-item__download"
aria-disabled="true"
:aria-label="label"
:aria-describedby="metaId"
:title="view.meta"
@click="emit('blocked', view.meta)"
>
<AppIcon name="download" />
</button>
</li>
</template>
<style scoped>
.large-item {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
padding: var(--space-4) var(--space-2) var(--space-4) var(--space-8);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
}
/* 단추가 없는 줄 — 오른쪽 여백을 단추 대신 둔다 */
.large-item--dim,
.large-item--danger {
padding-right: var(--space-12);
}
.large-item__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
.large-item__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.large-item__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.large-item__info {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.large-item__download {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
}
.large-item__download:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 검사 중 — 초점은 받되 흐린 아이콘 (누르면 까닭을 읽어 준다) */
.large-item__download[aria-disabled='true'] {
color: var(--color-icon-muted);
cursor: not-allowed;
}
/* 끝난 링크 · 받을 수 없는 파일 — 흐린 글자색 (투명도가 아니다) */
.large-item--dim {
border-color: var(--color-border-subtle);
}
.large-item--dim .large-item__name {
color: var(--color-text-muted);
}
.large-item--dim .large-item__icon {
color: var(--color-icon-muted);
}
/* 위험 — 빨간 줄 */
.large-item--danger {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.large-item--danger .large-item__icon {
background: transparent;
color: var(--color-danger);
}
.large-item--danger .large-item__name,
.large-item--danger .large-item__info {
color: var(--color-danger);
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
// 보낸 사람 자신의 링크 줄 — 첨부 칸 아래 (디자인 원본 MailReadLargeFiles "보낸 사람 자신")
// "링크로 보낸 파일 2개 — 받는 사람 모두 같은 링크로 받습니다. 끊으면 둘 다 받을 수 없습니다." + [링크 복사] [링크 끊기]
// 단추는 36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외(--size-notice-action, 새 토큰 없음). 링크가 메일 한 통에 하나라 파일마다 두지 않는다.
// [링크 복사]는 이 메일 본문의 덩이 주소(MessageLargeFile.url) — 설정 목록에는 없다(서버는 링크를 해시로만 안다).
// [링크 끊기]는 확인 창(처음 초점 「취소」) 뒤 POST /share-links/{id}/revocation — 끊으면 알림 하나(되돌리기 없음), 첨부 칸은 "공유 멈춤"이 된다.
// 끊은 줄은 사라진다 — 초점은 쓰는 쪽(첨부 칸)이 옮긴다(revoked)
import { computed, ref } from 'vue'
import LargeFileRevokeDialog from '@/components/mail/LargeFileRevokeDialog.vue'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_REVOKE_TEXT, MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { copyText } from '@/shared/utils/clipboard'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
interface Props {
/** 한 링크(메일)의 파일 — 모두 같은 linkId · url */
files: MessageLargeFile[]
}
interface Emits {
/** 링크를 끊었다 — 이 줄이 사라진다 */
(e: 'revoked'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { revokeShareLink } = useLargeFiles()
const toast = useToast()
const authStore = useAuthStore()
const mailStore = useMailStore()
const confirming = ref<boolean>(false)
const revoking = ref<boolean>(false)
const linkId = computed(() => props.files[0]?.linkId ?? '')
const noteId = computed(() => `reader-large-link-${linkId.value}`)
const note = computed(() => {
const count = props.files.length
return `${MESSAGE_LARGE_FILE_TEXT.linkNote(formatCount(count))} ${MESSAGE_LARGE_FILE_TEXT.linkNoteCut(count)}`
})
const dialogMessage = computed(() =>
LARGE_FILE_REVOKE_TEXT.messageWithFiles(
formatCount(props.files.length),
props.files.map((file) => file.name).join(LARGE_FILE_REVOKE_TEXT.nameSeparator),
),
)
async function copyLink() {
const url = props.files[0]?.url
if (url && (await copyText(url))) toast.success(MESSAGE_LARGE_FILE_TEXT.copied)
else toast.error(MESSAGE_LARGE_FILE_TEXT.copyFailed)
}
async function revoke() {
if (revoking.value || !linkId.value) return
const session = authStore.generation
revoking.value = true
try {
const link = await revokeShareLink(linkId.value)
if (authStore.generation !== session) return
confirming.value = false
mailStore.applyLargeFileLink(link)
toast.success(LARGE_FILE_REVOKE_TEXT.done)
emit('revoked')
} catch {
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
} finally {
revoking.value = false
}
}
</script>
<template>
<div
class="link-bar"
:data-large-link="linkId"
>
<span
:id="noteId"
class="link-bar__note"
>{{ note }}</span>
<span class="link-bar__actions">
<button
type="button"
class="link-bar__button"
data-link-copy
@click="copyLink"
>
{{ MESSAGE_LARGE_FILE_TEXT.copyLink }}
</button>
<button
type="button"
class="link-bar__button link-bar__button--danger"
:aria-describedby="noteId"
data-link-revoke
@click="confirming = true"
>
{{ MESSAGE_LARGE_FILE_TEXT.revoke }}
</button>
</span>
<LargeFileRevokeDialog
:open="confirming"
:message="dialogMessage"
:busy="revoking"
id-prefix="reader-large-revoke"
@confirm="revoke"
@cancel="confirming = false"
/>
</div>
</template>
<style scoped>
.link-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
padding-top: var(--space-4);
}
.link-bar__note {
flex: 1 1 20rem;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.link-bar__actions {
flex: 0 0 auto;
display: inline-flex;
gap: var(--space-6);
}
/* 36 — 밀도 예외 (--size-notice-action, 차단한 주소의 [차단 풀기]와 같다) */
.link-bar__button {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.link-bar__button:hover {
background: var(--color-surface-muted);
}
/* [링크 끊기] — 빨간 글 테두리 단추 */
.link-bar__button--danger {
color: var(--color-danger);
}
.link-bar__button--danger:hover {
background: var(--color-danger-surface);
}
</style>
@@ -12,6 +12,7 @@
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
// Ctrl+P(맥 ⌘P)는 브라우저 인쇄 대신 인쇄 화면을 새 탭에 연다(⋯ › 인쇄와 같다 — 본문을 눌러 초점이 본문 안에 있어도).
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 · 검색 결과 · 예약됨)이다.
// 대용량 첨부(11단계 — largeFiles)는 첨부 칸에 첨부처럼 보이고, 본문의 덩이는 접는다(본문 틀 안 — 스크립트 없이 열고 닫는다).
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -70,6 +71,8 @@ const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
/** 대용량 첨부 — 링크 주인이 이 메일의 보낸 사람일 때만 서버가 준다 (필드가 없는 서버면 없다) */
const largeFiles = computed(() => message.value?.largeFiles ?? [])
function retry() {
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
@@ -261,14 +264,16 @@ onBeforeUnmount(() => {
:html-body="message.htmlBody"
:text-body="message.textBody"
:images-shown="message.imagesShown === true"
:fold-large-files="largeFiles.length > 0"
:title="`메일 본문: ${subject}`"
@frame-keydown="onPrintKey"
/>
<MessageAttachments
v-if="attachments.length > 0"
v-if="attachments.length > 0 || largeFiles.length > 0"
:message-id="message.id"
:attachments="attachments"
:large-files="largeFiles"
/>
</div>
</article>
@@ -57,10 +57,10 @@ describe('MailEditor (서식 편집기)', () => {
it('빈 본문이면 텍스트 메일 — 서식 본문(html)을 보내지 않는다 (빈 줄만 있어도)', () => {
const view = mountEditor()
expect(view.vm.getContent()).toEqual({ text: '', html: null })
expect(view.vm.getContent()).toEqual({ text: '', html: null, quoteStart: null })
editorOf(view.element).commands.setContent('<p></p><p> </p><p></p>')
expect(view.vm.getContent()).toEqual({ text: '', html: null })
expect(view.vm.getContent()).toEqual({ text: '', html: null, quoteStart: null })
})
it('보낼 본문 — 서식 본문은 기본 글꼴 div 로 감싸고, 같은 내용의 텍스트를 함께 만든다', () => {
@@ -25,14 +25,15 @@ import { markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorToolbar from '@/components/mail/editor/EditorToolbar.vue'
import { INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits } from '@/components/mail/editor/fit-draft'
import { DRAFT_LIMITS, INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits, type DraftLimits } from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import type {
EditorSavePoint,
MailEditorContent,
MailEditorReserve,
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { docToTextWithQuoteStart, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { hasSignatureImage } from '@/components/mail/editor/signature-image'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
@@ -145,17 +146,29 @@ const editor = markRaw(
}),
)
/** 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트 */
/**
* 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트. 원문 인용이 있으면 서식 본문에 인용 경계 표시를 두고
* 텍스트에서 인용이 시작하는 자리를 함께 준다 (11단계 — 대용량 첨부 덩이 자리)
*/
function contentOf(doc: ProseMirrorNode): MailEditorContent {
const text = docToText(doc)
const { text, quoteStart } = docToTextWithQuoteStart(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null, quoteStart: null }
return {
text,
html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema), { markQuoteStart: true }),
quoteStart,
}
}
function contentFits(content: MailEditorContent): boolean {
return fitsLimits({ text: content.text.length, html: content.html?.length ?? 0 })
/** 보내기 한도 — 남겨 둘 자리(대용량 첨부 덩이)만큼 텍스트 한도를 줄인다 */
function sendLimits(reserve?: MailEditorReserve): DraftLimits {
return reserve ? { text: DRAFT_LIMITS.text - reserve.text, html: DRAFT_LIMITS.html } : DRAFT_LIMITS
}
function contentFits(content: MailEditorContent, limits: DraftLimits = DRAFT_LIMITS): boolean {
return fitsLimits({ text: content.text.length, html: content.html?.length ?? 0 }, limits)
}
function getContent(): MailEditorContent {
@@ -180,14 +193,15 @@ function applyDoc(next: ProseMirrorNode) {
* 넘으면 원문 인용을 더 줄여(생략 표시) 맞추고 편집기에도 그 모양을 보인다 — 보낸 것과 화면이 같게.
* 원문 인용이 없거나 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 편집기는 그대로 둔다.
*/
function getContentForSend(): MailEditorContent | null {
function getContentForSend(reserve?: MailEditorReserve): MailEditorContent | null {
const limits = sendLimits(reserve)
const current = getContent()
if (contentFits(current)) return current
if (contentFits(current, limits)) return current
const { doc } = editor.state
const fitted = fitDraftDoc(doc, editor.schema)
const fitted = fitDraftDoc(doc, editor.schema, limits)
if (fitted === doc) return null
const content = contentOf(fitted)
if (!contentFits(content)) return null
if (!contentFits(content, limits)) return null
applyDoc(fitted)
return content
}
@@ -1,4 +1,4 @@
import { createDocument, getSchema } from '@tiptap/core'
import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'
@@ -12,7 +12,7 @@ import {
type DraftLimits,
} from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText } from '@/components/mail/editor/mail-serialize'
import { docToText, docToTextWithQuoteStart, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
@@ -128,3 +128,59 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
expect(docToText(fitted).endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
})
/** 보낸 · 저장한 모양 — 편집기 HTML 에 인라인 style 을 입히고 data-mail-quote · data-quote-header 를 지운다(서버 정화도 data-* 를 지운다) */
function savedHtml(doc: ProseMirrorNode): string {
return toMailHtml(getHTMLFromFragment(doc.content, schema))
}
describe('저장본 · 보내기 취소로 되돌린 초안 — 정화로 표시가 지워진 원문 인용 (11단계 리뷰 M1)', () => {
it('다시 읽으면 맨 위 인용의 첫 자식이 "원본 메일" 머리 문단 + 머리 표이면 원문 인용이다', () => {
const html = savedHtml(draft(lines(3), '내 글'))
expect(html).not.toContain('data-mail-quote')
expect(html).not.toContain('data-quote-header')
const reopened = createDocument(html, schema)
expect(reopened.child(0).textContent).toBe('내 글')
const quote = reopened.child(1)
expect(quote.type.name).toBe('blockquote')
expect(quote.attrs.mailQuote).toBe(true)
expect(quote.child(0).attrs.quoteHeader).toBe(true)
expect(quote.child(1).attrs.quoteHeader).toBe(true)
// 덩이 자리(bodyQuoteStart)도 다시 센다 — 인용 첫 줄의 처음
const { text, quoteStart } = docToTextWithQuoteStart(reopened)
expect(quoteStart).not.toBeNull()
expect(text.slice(quoteStart ?? 0).startsWith('> 원본 메일')).toBe(true)
})
it('그래서 한도를 넘는 저장본도 보내기 직전에 원문 인용을 줄인다 (내 글은 그대로)', () => {
const reopened = createDocument(savedHtml(draft(lines(200), '내 글')), schema)
const fitted = fitDraftDoc(reopened, schema, { text: 600, html: 100_000 })
expect(fitted).not.toBe(reopened)
expect(fitted.child(0).textContent).toBe('내 글')
expect(quoteTexts(fitted).at(-1)).toBe(MAIL_QUOTE_OMITTED_MARK)
})
it('내가 쓴 인용 · 원문 인용 안의 옛 원문(답장의 답장)은 원문 인용이 아니다', () => {
const older = buildQuoteHtml(HEADER, '<p>옛 원문</p>').replace('<p></p>', '')
const nested = createDocument(
'<p>내 글</p><blockquote><p>내가 인용한 글</p></blockquote>' +
savedHtml(createDocument(buildQuoteHtml(HEADER, older), schema)),
schema,
)
const quotes: ProseMirrorNode[] = []
nested.forEach((child) => {
if (child.type.name === 'blockquote') quotes.push(child)
})
expect(quotes.map((quote) => quote.attrs.mailQuote)).toEqual([false, true])
const inner: ProseMirrorNode[] = []
quotes[1]?.descendants((node) => {
if (node.type.name === 'blockquote') inner.push(node)
})
expect(inner.map((quote) => quote.attrs.mailQuote)).toEqual([false])
})
})
@@ -42,11 +42,11 @@ export interface DraftSize {
html: number
}
/** 보낼 모양 그대로의 길이 */
/** 보낼 모양 그대로의 길이 — 서식 본문은 인용 경계 표시까지 (편집기가 보내는 모양 — MailEditor 의 contentOf 와 같다) */
export function measureDraft(doc: ProseMirrorNode, schema: Schema): DraftSize {
return {
text: docToText(doc).length,
html: toMailHtml(getHTMLFromFragment(doc.content, schema)).length,
html: toMailHtml(getHTMLFromFragment(doc.content, schema), { markQuoteStart: true }).length,
}
}
@@ -5,8 +5,19 @@ import type { InjectionKey } from 'vue'
export interface MailEditorContent {
/** 일반 텍스트 (body) — 서식 본문의 대체 본문. 비었으면 '' */
text: string
/** 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다) */
/**
* 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다).
* 원문 인용이 있으면 그 바로 앞에 인용 경계 표시(<div data-ara-quote-start></div>)가 있다 — 대용량 파일이 없는 메일은
* 쓰기 화면이 지우고 보낸다 (outgoing-html-rules.ts 의 withoutQuoteStartMark · 계약 4절 "발송 · 임시 저장과 함께")
*/
html: string | null
/** text 에서 원문 인용이 시작하는 자리(줄의 처음) — 인용이 없으면 null. 대용량 파일이 있을 때 bodyQuoteStart 로 보낸다 */
quoteStart: number | null
}
/** 보내기 직전에 본문 한도에서 남겨 둘 자리 — 서버가 넣을 대용량 첨부 덩이의 텍스트 길이 (11단계) */
export interface MailEditorReserve {
text: number
}
/**
@@ -29,8 +40,9 @@ export interface MailEditorHandle {
/**
* 보내기 직전의 본문 — 한도(텍스트 100,000 · 서식 500,000)를 최종 직렬화 결과로 잰다.
* 넘으면 원문 인용을 더 줄여 맞추고 편집기에도 그 모양을 보인다(생략 표시 유지). 내 글만으로 넘으면 null — 보내지 않는다.
* reserve 를 주면 텍스트 한도에서 그만큼을 남긴다 — 서버가 대용량 첨부 덩이를 넣은 뒤 다시 재기 때문이다 (계약 4절 "덩이")
*/
getContentForSend: () => MailEditorContent | null
getContentForSend: (reserve?: MailEditorReserve) => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
/**
@@ -79,6 +79,19 @@ function isQuoteHeaderTable(element: HTMLElement): boolean {
return previous?.tagName === 'P' && previous.textContent?.trim() === MAIL_QUOTE_HEADING
}
/**
* 원문 인용인가 — 우리가 만든 초안은 data-mail-quote 로 표시한다.
* 임시 저장본 · 보내기 취소로 되돌린 초안은 서버 정화가 data-* 를 지우므로, 다시 열면 "맨 위 인용의 첫 자식이
* '원본 메일' 머리 문단 + 바로 뒤 머리 표"로 알아본다 (isQuoteHeadingParagraph 와 같은 방식). 그래야 인용 경계 표시 ·
* bodyQuoteStart(11단계 — 덩이 자리)와 한도를 넘는 초안의 인용 줄이기(fitDraftDoc)가 저장본에서도 돈다.
* 인용 안의 인용(답장의 답장 속 옛 원문)은 원문 인용이 아니다 — 맨 위 것만
*/
function isMailQuoteBlock(element: HTMLElement): boolean {
if (element.parentElement?.closest('blockquote')) return false
const first = element.firstElementChild
return first?.tagName === 'P' && isQuoteHeadingParagraph(first as HTMLElement)
}
/** 참/거짓 표시 속성 — HTML 에서는 data-* 로 오간다 (renderHTML 은 노드의 속성 전체를 받는다) */
function flagAttribute(name: string, dataName: string, detect: (element: HTMLElement) => boolean) {
return {
@@ -142,7 +155,7 @@ const MailEditorBehavior = Extension.create<MailEditorHooks>({
},
{
types: ['blockquote'],
attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', () => false) },
attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', isMailQuoteBlock) },
},
]
},
@@ -5,11 +5,14 @@ import { createMailEditorExtensions } from '@/components/mail/editor/mail-extens
import {
MAIL_HTML_STYLES,
docToText,
docToTextWithQuoteStart,
normalizeIncomingHtml,
toMailHtml,
toMailHtmlFragment,
} from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_LINE_HEIGHT } from '@/shared/constants/mail-editor'
import { buildQuoteHtml } from '@/shared/utils/mail-quote'
import { QUOTE_START_MARK, withoutQuoteStartMark } from '@/shared/utils/outgoing-html-rules'
const schema = getSchema(createMailEditorExtensions())
@@ -169,6 +172,52 @@ describe('toMailHtml — 받는 쪽 모양을 인라인 style 로 (문단 사이
})
})
describe('인용 경계 — 대용량 첨부 덩이 자리 (11단계 — 계약 4절 "발송 · 임시 저장과 함께")', () => {
const quoted = (signature = '') =>
createDocument(
`<p>확인했습니다.</p>${signature}` +
buildQuoteHtml([{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' }], '<p>원문</p>'),
schema,
)
it('서식 본문 — 원문 인용 바로 앞에 빈 표시 하나 (markQuoteStart). 표시를 빼면 지금 모양 그대로', () => {
const doc = quoted('<p>김아라 · 인프라팀</p>')
const editorHtml = getHTMLFromFragment(doc.content, schema)
const html = toMailHtml(editorHtml, { markQuoteStart: true })
expect(html.match(/data-ara-quote-start/g)).toHaveLength(1)
expect(html).toContain(`${QUOTE_START_MARK}<blockquote`)
// 내 글 · 서명 아래
expect(html.indexOf('김아라 · 인프라팀')).toBeLessThan(html.indexOf(QUOTE_START_MARK))
expect(withoutQuoteStartMark(html)).toBe(toMailHtml(editorHtml))
})
it('원문 인용이 없으면 표시도 없다 — 사용자가 쓴 인용(막대)은 원문 인용이 아니다', () => {
const doc = createDocument('<p>글</p><blockquote><p>내가 쓴 인용</p></blockquote>', schema)
expect(toMailHtml(getHTMLFromFragment(doc.content, schema), { markQuoteStart: true })).not.toContain(
'data-ara-quote-start',
)
expect(docToTextWithQuoteStart(doc).quoteStart).toBeNull()
})
it('텍스트 — 인용 첫 줄의 처음(줄의 처음)을 알린다. 글은 docToText 와 같다', () => {
const doc = quoted('<p>김아라 · 인프라팀</p>')
const { text: body, quoteStart } = docToTextWithQuoteStart(doc)
expect(body).toBe(docToText(doc))
expect(quoteStart).not.toBeNull()
expect(body.slice(0, quoteStart ?? 0)).toBe(['확인했습니다.', '김아라 · 인프라팀', '', ''].join('\n'))
expect(body.slice(quoteStart ?? 0).startsWith('> 원본 메일')).toBe(true)
})
it('인용이 맨 앞이면 0', () => {
const doc = createDocument(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>').replace('<p></p>', ''), schema)
expect(docToTextWithQuoteStart(doc).quoteStart).toBe(0)
})
})
describe('남의 HTML 을 화면 문서로 옮기지 않는다 (붙여 넣기 · 보낼 모양 — 처리기 실행 방지)', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -7,6 +7,8 @@
// 서버는 받은 HTML 을 다시 허용 목록으로 정화한다(sanitize-outgoing-html.ts). 여기서 넣는 style 은 모두 그 목록 안의 값이다
// (shared/utils/outgoing-html-rules.ts — 서버와 같은 형식, outgoing-html-rules.spec.ts 가 지킨다).
// HTML 조각은 화면과 떨어진 문서에서만 다룬다 — 남의 HTML 의 처리기가 돌지 않게 (rewriteInert).
// 인용 경계(11단계 — 계약 4절 "발송 · 임시 저장과 함께"): 서버가 대용량 첨부 덩이를 내 글 · 서명 아래, 인용 위에 넣도록
// 서식 본문은 원문 인용 바로 앞에 <div data-ara-quote-start></div>(markQuoteStart), 텍스트는 인용이 시작하는 자리(docToTextWithQuoteStart)를 알린다.
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { MAIL_QUOTE_LINE_PREFIX } from '@/shared/constants/mail'
@@ -15,7 +17,7 @@ import {
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { listStartOf } from '@/shared/utils/outgoing-html-rules'
import { QUOTE_START_ATTRIBUTE, listStartOf } from '@/shared/utils/outgoing-html-rules'
// ── 텍스트 ───────────────────────────────────────────────
@@ -126,6 +128,31 @@ export function docToText(doc: ProseMirrorNode): string {
return childLines(doc).join('\n').replace(/\s+$/, '')
}
/** 원문 인용 — 맨 위 단계의 data-mail-quote 인용 (사용자가 쓴 인용과 다르다) */
function isMailQuote(node: ProseMirrorNode): boolean {
return node.type.name === 'blockquote' && Boolean(node.attrs.mailQuote)
}
/**
* 텍스트 본문과 원문 인용이 시작하는 자리 — 인용 첫 줄의 처음(JS 문자열 위치)이다. 인용이 없으면 null.
* 서버는 이 자리에 대용량 첨부 덩이를 넣는다 (bodyQuoteStart — 0 이거나 바로 앞이 줄바꿈이어야 한다). 글은 docToText 와 같다
*/
export function docToTextWithQuoteStart(doc: ProseMirrorNode): { text: string; quoteStart: number | null } {
const lines: string[] = []
let quoteLine = -1
for (let index = 0; index < doc.childCount; index += 1) {
const child = doc.child(index)
if (quoteLine < 0 && isMailQuote(child)) quoteLine = lines.length
lines.push(...blockLines(child))
// 인용 머리 표 뒤 한 줄 — childLines 와 같다
if (isQuoteHeaderTable(child)) lines.push('')
}
const text = lines.join('\n').replace(/\s+$/, '')
if (quoteLine < 0) return { text, quoteStart: null }
const start = lines.slice(0, quoteLine).reduce((sum, line) => sum + line.length + 1, 0)
return { text, quoteStart: start <= text.length ? start : null }
}
// ── HTML ─────────────────────────────────────────────────
/** 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style (디자인 README "답장 · 전달의 원문 인용" 값) */
@@ -289,9 +316,32 @@ function trimTrailingEmptyParagraphs(editorHtml: string): string {
return editorHtml.replace(/(?:<p(?: [^>]*)?><\/p>)+$/, '')
}
/** 원문 인용 바로 앞에 인용 경계 표시를 둔다 — 맨 위 단계의 첫 원문 인용 (서버는 첫 표시 자리를 쓴다) */
function insertQuoteStartMark(root: DocumentFragment, inert: Document) {
const quote = Array.from(root.children).find(
(element) => element.tagName === 'BLOCKQUOTE' && element.hasAttribute('data-mail-quote'),
)
if (!quote) return
const mark = inert.createElement('div')
mark.setAttribute(QUOTE_START_ATTRIBUTE, '')
quote.before(mark)
}
export interface MailHtmlOptions {
/**
* 원문 인용 바로 앞에 인용 경계 표시(<div data-ara-quote-start></div>)를 둔다 — 대용량 첨부 덩이 자리 (11단계).
* 대용량 파일이 없는 메일은 쓰기 화면이 표시를 지우고 보낸다 (withoutQuoteStartMark)
*/
markQuoteStart?: boolean
}
/** 보내는 서식 본문 — 기본 글꼴 · 글자 크기 · 줄 간격 div 로 감싼다 (계약 2절 "서식 본문", 편집기 화면과 같은 값) */
export function toMailHtml(editorHtml: string): string {
const inner = toMailHtmlFragment(trimTrailingEmptyParagraphs(editorHtml))
export function toMailHtml(editorHtml: string, options: MailHtmlOptions = {}): string {
const inner = rewriteInert(trimTrailingEmptyParagraphs(editorHtml), (root, inert) => {
// 표시를 먼저 둔다 — 아래 모양 입히기가 data-mail-quote 를 지운다
if (options.markQuoteStart) insertQuoteStartMark(root, inert)
applyMailStyles(root, inert)
})
const wrapper = [
`font-family: ${MAIL_BODY_FONT_FAMILY}`,
`font-size: ${MAIL_BODY_FONT_SIZE_PX}px`,
@@ -172,12 +172,12 @@ function insertImage(image: SignatureImage) {
.run()
}
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 */
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 (서명에는 원문 인용이 없다) */
function getContent(): MailEditorContent {
const { doc } = editor.state
const text = docToText(doc)
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null, quoteStart: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)), quoteStart: null }
}
/** 서명이 쓰는 그림 id */
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 보내지 않은 파일 지우기 확인 — alertdialog 400 (디자인 원본 SettingsLargeFiles)
// "보내지 않은 파일을 지울까요?" / "‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. …지운 파일은 되돌릴 수 없습니다." [지우기](빨강) [취소]
// 되돌릴 수 없는 일이라 처음 초점은 「취소」, Esc 도 취소다. 지우는 동안은 [지우기]를 aria-disabled 로 막는다
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
interface Props {
open: boolean
message: string
busy?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
function onConfirm() {
if (!props.busy) emit('confirm')
}
</script>
<template>
<ModalDialog
:open="open"
role="alertdialog"
medium
:title="LARGE_FILE_SETTINGS_TEXT.deleteTitle"
:message="message"
title-id="large-delete-title"
message-id="large-delete-message"
@cancel="emit('cancel')"
>
<template #actions>
<button
type="button"
class="modal-dialog__action modal-dialog__action--danger"
:aria-disabled="busy ? 'true' : undefined"
:aria-busy="busy ? 'true' : undefined"
data-delete-confirm
@click="onConfirm"
>
{{ LARGE_FILE_SETTINGS_TEXT.deleteConfirm }}
</button>
<button
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
autofocus
data-delete-cancel
@click="emit('cancel')"
>
{{ LARGE_FILE_SETTINGS_TEXT.deleteCancel }}
</button>
</template>
</ModalDialog>
</template>
@@ -0,0 +1,529 @@
<script setup lang="ts">
// 설정 › 대용량 첨부 › 보낸 링크 (디자인 원본 SettingsLargeFiles) — 메일(링크)마다 한 덩이, 최근에 보낸 차례 · 20개씩 [더 보기]
// 머리: 메일 제목(청록 링크 → 보낸편지함의 그 메일, 보낸 메일을 모르면 글자만) / "10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB" ·
// 오른쪽 "11월 29일까지" / "30일 남음"(3일 이하면 갈색 굵게) · [링크 끊기](36 — 링크가 메일 한 통에 하나라 덩이에 한 번)
// 파일 줄(44 · 바탕 #FBFBFC): 아이콘 · 이름 · 크기 · "3회 받음" / "마지막 오늘 오후 2:41"(받지 않았으면 "아직 받지 않음") · 상태(뱃지 없이 글자만)
// 끝난 링크는 덩이째 흐리게 — 머리 오른쪽 "공유 멈춤 / 10월 22일에 끊음"(끊은 때가 없는 옛 응답은 "공유 멈춤"만) · "기간 지남 / 10월 25일까지였습니다", 단추 없음.
// [링크 끊기] → 읽기 화면과 같은 확인 창(창 글에 메일 제목) · 같은 알림. 끊는 동안 그 덩이의 단추를 막고, 끝나면 초점을 덩이 머리로.
// [링크 복사]는 두지 않는다 — 서버는 링크를 해시로만 알아 원래 주소를 모른다(보낸 메일의 읽기 화면에서 복사한다)
import { computed, nextTick, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import LargeFileRevokeDialog from '@/components/mail/LargeFileRevokeDialog.vue'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_REVOKE_TEXT, LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { ShareLink, SharedFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize, formatRelativeDayTime } from '@/shared/utils/format'
import { formatKstMonthDay, formatKstSentDay, kstDaysLeft } from '@/shared/utils/kst-date'
import { sharedFileStatusView } from '@/shared/utils/large-file-status'
import { useLargeFilesStore } from '@/stores/large-files.store'
interface Props {
links: ShareLink[]
/** 보낸 링크 수 — 서버의 total */
total: number
hasMore: boolean
loadingMore: boolean
/** 지금 시각(ms) — 남은 날 · "오늘 오후 2:41" */
now: number
/** 보낸편지함 id — 메일 제목을 그 메일로 잇는다 (모르면 글자만) */
sentMailboxId: string | null
}
interface Emits {
(e: 'more'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const largeFilesStore = useLargeFilesStore()
const toast = useToast()
/** 남은 날이 이만큼 이하면 갈색 굵게 */
const SOON_DAYS = 3
const listRef = ref<HTMLUListElement | null>(null)
/** 끊을지 묻는 링크 */
const target = ref<ShareLink | null>(null)
const revoking = ref<boolean>(false)
function subjectOf(link: ShareLink): string {
return link.subject || LARGE_FILE_SETTINGS_TEXT.noSubject
}
function sentLineOf(link: ShareLink): string {
const total = link.files.reduce((sum, file) => sum + file.size, 0)
return LARGE_FILE_SETTINGS_TEXT.sentLine(
formatKstSentDay(link.sentAt),
formatCount(link.files.length),
formatFileSize(total),
)
}
/** 제목을 누르면 갈 보낸 메일 — 메일을 지웠으면(서버가 제목을 읽지 못해 null) 글자만 (README "메일을 지웠으면 글자만") */
function mailLocationOf(link: ShareLink) {
if (!link.sentEmailId || link.subject === null || !props.sentMailboxId) return null
return { name: 'mail-message', params: { mailboxId: props.sentMailboxId, messageId: link.sentEmailId } }
}
/**
* 머리 오른쪽 두 줄 — 끝나는 날 · 남은 날 / 공유 멈춤 · 끊은 날 / 기간 지남 · 끝난 날.
* 끊은 날은 한국 날짜("10월 22일에 끊음") — 응답에 끊은 때가 없으면(옛 서버) "공유 멈춤"만
*/
function untilOf(link: ShareLink): { main: string; note: string | null; soon: boolean } {
if (link.status === 'revoked') {
const revokedDay = link.revokedAt ? formatKstMonthDay(link.revokedAt) : ''
return {
main: LARGE_FILE_SETTINGS_TEXT.linkRevoked,
note: revokedDay ? LARGE_FILE_SETTINGS_TEXT.revokedOn(revokedDay) : null,
soon: false,
}
}
const date = formatKstMonthDay(link.expiresAt)
if (link.status === 'expired') {
return { main: LARGE_FILE_SETTINGS_TEXT.linkExpired, note: LARGE_FILE_SETTINGS_TEXT.expiredUntil(date), soon: false }
}
const days = kstDaysLeft(link.expiresAt, props.now) ?? 0
return {
main: LARGE_FILE_SETTINGS_TEXT.until(date),
note: days > 0 ? LARGE_FILE_SETTINGS_TEXT.daysLeft(formatCount(days)) : LARGE_FILE_SETTINGS_TEXT.lastDay,
soon: days <= SOON_DAYS,
}
}
function downloadsOf(file: SharedFile): { main: string; note: string | null } {
if (file.downloadCount <= 0) return { main: LARGE_FILE_SETTINGS_TEXT.notDownloaded, note: null }
return {
main: LARGE_FILE_SETTINGS_TEXT.downloads(formatCount(file.downloadCount)),
note: file.lastDownloadedAt
? LARGE_FILE_SETTINGS_TEXT.lastDownloaded(formatRelativeDayTime(file.lastDownloadedAt, new Date(props.now)))
: null,
}
}
const dialogMessage = computed(() =>
target.value
? LARGE_FILE_REVOKE_TEXT.messageWithSubject(subjectOf(target.value), formatCount(target.value.files.length))
: '',
)
/** 끊은 덩이의 머리로 초점 — 누른 단추가 사라졌다 */
async function focusHead(linkId: string) {
await nextTick()
const block = Array.from(listRef.value?.querySelectorAll<HTMLElement>('[data-share-link]') ?? []).find(
(element) => element.dataset.shareLink === linkId,
)
block?.querySelector<HTMLElement>('.link__head')?.focus()
}
async function confirmRevoke() {
const link = target.value
if (!link || revoking.value) return
revoking.value = true
try {
await largeFilesStore.revokeLink(link.id)
target.value = null
toast.success(LARGE_FILE_REVOKE_TEXT.done)
await focusHead(link.id)
} catch {
// 인터셉터가 알렸다 — 창은 그대로 두어 다시 누를 수 있게 한다
} finally {
revoking.value = false
}
}
</script>
<template>
<section
class="links"
aria-labelledby="large-links-title"
>
<div class="links__head">
<h2
id="large-links-title"
class="links__title"
>
{{ LARGE_FILE_SETTINGS_TEXT.linksTitle(formatCount(total)) }}
</h2>
<span class="links__hint">{{ LARGE_FILE_SETTINGS_TEXT.linksHint }}</span>
</div>
<ul
ref="listRef"
class="links__list"
:aria-label="LARGE_FILE_SETTINGS_TEXT.linksList"
>
<li
v-for="link in links"
:key="link.id"
:class="['link', { 'link--ended': link.status !== 'active' }]"
:data-share-link="link.id"
>
<div
class="link__head"
tabindex="-1"
>
<div class="link__title-col">
<RouterLink
v-if="mailLocationOf(link)"
class="link__subject"
:to="mailLocationOf(link) ?? ''"
>
{{ subjectOf(link) }}
</RouterLink>
<span
v-else
class="link__subject link__subject--plain"
>{{ subjectOf(link) }}</span>
<span class="link__sent">{{ sentLineOf(link) }}</span>
</div>
<div class="link__until">
<span class="link__until-main">{{ untilOf(link).main }}</span>
<span
v-if="untilOf(link).note"
:class="['link__until-note', { 'link__until-note--soon': untilOf(link).soon }]"
>{{ untilOf(link).note }}</span>
</div>
<button
v-if="link.status === 'active'"
type="button"
class="link__revoke"
:aria-label="LARGE_FILE_SETTINGS_TEXT.revokeLabel(subjectOf(link))"
:aria-disabled="revoking && target?.id === link.id ? 'true' : undefined"
data-link-revoke
@click="target = link"
>
{{ LARGE_FILE_REVOKE_TEXT.confirm }}
</button>
</div>
<ul class="link__files">
<li
v-for="file in link.files"
:key="file.id"
:class="['link__file', { 'link__file--danger': file.status === 'infected' }]"
:data-shared-file="file.id"
>
<AppIcon
:name="file.status === 'infected' ? 'alert-triangle' : 'file'"
class="link__file-icon"
/>
<span class="link__file-name">{{ file.name }}</span>
<span class="link__file-size">{{ formatFileSize(file.size) }}</span>
<span class="link__file-downloads">
<span>{{ downloadsOf(file).main }}</span>
<span
v-if="downloadsOf(file).note"
class="link__file-note"
>{{ downloadsOf(file).note }}</span>
</span>
<span :class="['link__file-status', `link__file-status--${sharedFileStatusView(file).tone}`]">
<span>{{ sharedFileStatusView(file).label }}</span>
<span
v-if="sharedFileStatusView(file).note"
class="link__file-status-note"
>{{ sharedFileStatusView(file).note }}</span>
</span>
</li>
</ul>
</li>
</ul>
<div
v-if="hasMore"
class="links__more"
>
<button
type="button"
class="links__more-button"
:aria-disabled="loadingMore ? 'true' : undefined"
:aria-busy="loadingMore ? 'true' : undefined"
data-links-more
@click="loadingMore || emit('more')"
>
{{ LARGE_FILE_SETTINGS_TEXT.more }}
</button>
<span class="links__more-hint">{{ LARGE_FILE_SETTINGS_TEXT.moreHint }}</span>
</div>
<LargeFileRevokeDialog
:open="target !== null"
:message="dialogMessage"
:busy="revoking"
id-prefix="settings-large-revoke"
@confirm="confirmRevoke"
@cancel="target = null"
/>
</section>
</template>
<style scoped>
.links {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.links__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.links__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.links__hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.links__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.link {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-14);
}
.link + .link {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.link__head {
display: flex;
align-items: center;
gap: var(--space-12);
border-radius: var(--radius-sm);
outline-offset: var(--focus-ring-offset);
}
.link__title-col {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.link__subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--color-brand-strong);
text-decoration: none;
}
.link__subject:hover {
text-decoration: underline;
}
.link__subject--plain {
color: var(--color-text);
}
.link__sent {
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.link__until {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--line-height-tight);
font-variant-numeric: tabular-nums;
}
.link__until-main {
font-weight: 500;
}
.link__until-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* 남은 날이 3일 이하 — 갈색 굵게 */
.link__until-note--soon {
font-weight: 600;
color: var(--color-warning-text);
}
/* [링크 끊기] 36 — 밀도 예외 (--size-notice-action), 빨간 글 테두리 단추 */
.link__revoke {
flex: 0 0 auto;
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-danger);
font-weight: 600;
}
.link__revoke:hover:not([aria-disabled='true']) {
background: var(--color-danger-surface);
}
.link__revoke[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.link__files {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: var(--space-4) 0 0;
padding: 0;
list-style: none;
}
/* 파일 줄 44 — 아이콘 16 · 이름 · 크기 64 · 받은 수 160 · 상태 112 (디자인 원본 SettingsLargeFiles) */
.link__file {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) 4rem 10rem 7rem;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
padding: 0 var(--space-8);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.link__file--danger {
background: var(--color-danger-surface);
}
.link__file-icon {
color: var(--color-text-secondary);
}
.link__file--danger .link__file-icon {
color: var(--color-danger);
}
.link__file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.link__file-size {
font-size: var(--font-size-small);
color: var(--color-text-muted);
text-align: right;
font-variant-numeric: tabular-nums;
}
.link__file-downloads,
.link__file-status {
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
font-variant-numeric: tabular-nums;
}
.link__file-note,
.link__file-status-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.link__file-status--normal {
color: var(--color-text-strong);
}
.link__file-status--muted {
color: var(--color-text-muted);
}
/* 검사하지 못함 — 상태 아래 갈색 */
.link__file-status--warning {
color: var(--color-text-strong);
}
.link__file-status--warning .link__file-status-note {
color: var(--color-warning-text);
}
/* 위험 — 빨강 굵게 + "내려받기를 막음" */
.link__file-status--danger {
font-weight: 600;
color: var(--color-danger);
}
.link__file-status--danger .link__file-status-note {
font-weight: 400;
color: var(--color-danger);
}
/* 끝난 링크 — 덩이째 흐리게 (투명도가 아니라 글자색) */
.link--ended .link__subject,
.link--ended .link__file-name,
.link--ended .link__until-main {
color: var(--color-text-muted);
}
.link--ended .link__file-icon {
color: var(--color-icon-muted);
}
.links__more {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-12);
}
.links__more-button {
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-strong);
font-weight: 600;
}
.links__more-button:hover:not([aria-disabled='true']) {
background: var(--color-surface-muted);
}
.links__more-button[aria-disabled='true'] {
opacity: 0.6;
cursor: progress;
}
.links__more-hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 좁으면 파일 줄의 받은 수 · 상태를 이름 아래로 */
@media (max-width: 37.5rem) {
.link__file {
grid-template-columns: 1rem minmax(0, 1fr) auto;
padding: var(--space-6) var(--space-8);
}
.link__file-downloads,
.link__file-status {
grid-column: 2 / span 2;
}
}
</style>
@@ -0,0 +1,324 @@
<script setup lang="ts">
// 설정 › 대용량 첨부 › 보내지 않은 파일 (디자인 원본 SettingsLargeFiles — 사용량 상자 바로 아래: 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위)
// 「보내지 않은 파일 3개」 + "1.9 GB · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다". 없으면 쓰는 쪽이 묶음째 그리지 않는다.
// 줄(52): 아이콘 · 이름 / 상태(올리다 멈춤 — 갈색 · 다 올림 · 검사 상태) · 크기(멈춘 파일은 "1.2 GB" / "420 MB 올림") ·
// 남는 기한("11월 29일까지 남음", 멈춘 파일은 "내일 오후 2:41까지 남음" / "마지막으로 올린 뒤 24시간") · [지우기](36, 빨간 글)
// [지우기] → 확인 창(처음 초점 「취소」) → DELETE /file-uploads/{id} → "‘…’ 파일을 지웠습니다." — 줄이 사라지고 사용량이 바로 준다(스토어).
// 초점은 그 자리의 다음 줄(없으면 앞 줄 · 묶음 제목)로.
// 실린 저장본의 제목은 보이지 않는다 — GET /file-uploads 응답에 저장본이 없다(계약 4절 LargeFile). 확인 창 글도 저장본 제목 없이 쓴다
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LargeFileDeleteDialog from '@/components/settings/LargeFileDeleteDialog.vue'
import { toApiError } from '@/composables/useApi'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import type { LargeFile } from '@/shared/types/large-file'
import { formatCount, formatFileSize, formatRelativeDayTime } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
import { receivedBytesOf } from '@/shared/utils/large-file'
import { useLargeFilesStore } from '@/stores/large-files.store'
interface Props {
files: LargeFile[]
/** 지금 시각(ms) — "내일 오후 2:41" 을 셈한다 */
now: number
}
const props = defineProps<Props>()
const largeFilesStore = useLargeFilesStore()
const toast = useToast()
const titleRef = ref<HTMLHeadingElement | null>(null)
const listRef = ref<HTMLUListElement | null>(null)
/** 지울지 묻는 파일 */
const target = ref<LargeFile | null>(null)
const deleting = ref<boolean>(false)
/** 보관량을 잡는 양 — 위험 판정 파일은 곧 지워져 빠진다 */
const totalBytes = computed(() =>
props.files.reduce((sum, file) => (file.scanStatus === 'infected' ? sum : sum + file.size), 0),
)
interface RowView {
status: string
tone: 'muted' | 'warning' | 'danger'
/** 크기 아래 "420 MB 올림" — 올리다 멈춘 파일만 */
uploaded: string | null
until: string
untilNote: string | null
}
function rowOf(file: LargeFile): RowView {
const uploading = file.status === 'uploading'
const until = uploading
? LARGE_FILE_SETTINGS_TEXT.remainUntil(formatRelativeDayTime(file.expiresAt, new Date(props.now)))
: LARGE_FILE_SETTINGS_TEXT.remainUntil(formatKstMonthDay(file.expiresAt))
if (uploading) {
return {
status: LARGE_FILE_SETTINGS_TEXT.unsentUploading,
tone: 'warning',
uploaded: LARGE_FILE_SETTINGS_TEXT.partUploaded(
formatFileSize(receivedBytesOf(file.receivedChunks, file.chunkSize, file.size)),
),
until,
untilNote: LARGE_FILE_SETTINGS_TEXT.afterLastChunk,
}
}
const scan = file.scanStatus
if (scan === 'infected') {
return { status: LARGE_FILE_SETTINGS_TEXT.unsentInfected, tone: 'danger', uploaded: null, until, untilNote: null }
}
const status =
scan === 'pending'
? `${LARGE_FILE_SETTINGS_TEXT.unsentReady} · ${LARGE_FILE_SETTINGS_TEXT.unsentScanning}`
: scan === 'unscannable'
? `${LARGE_FILE_SETTINGS_TEXT.unsentReady} · ${LARGE_FILE_SETTINGS_TEXT.unsentUnscannable}`
: LARGE_FILE_SETTINGS_TEXT.unsentReady
return { status, tone: scan === 'unscannable' ? 'warning' : 'muted', uploaded: null, until, untilNote: null }
}
const deleteMessage = computed(() =>
target.value ? LARGE_FILE_SETTINGS_TEXT.deleteMessage(target.value.name, formatFileSize(target.value.size)) : '',
)
/** 사라진 줄 대신 초점을 둘 곳 — 그 자리의 다음 줄, 없으면 앞 줄, 그것도 없으면 묶음 제목 */
async function focusAfterRemoval(index: number) {
await nextTick()
const active = document.activeElement
if (active !== null && active !== document.body) return
const buttons = Array.from(listRef.value?.querySelectorAll<HTMLButtonElement>('[data-unsent-delete]') ?? [])
const next = buttons[index] ?? buttons[index - 1]
if (next) next.focus()
else titleRef.value?.focus()
}
async function confirmDelete() {
const file = target.value
if (!file || deleting.value) return
const index = props.files.findIndex((candidate) => candidate.id === file.id)
deleting.value = true
try {
await largeFilesStore.deleteUnsent(file.id)
target.value = null
toast.success(LARGE_FILE_SETTINGS_TEXT.deleted(file.name))
await focusAfterRemoval(index)
} catch (caught) {
// 인터셉터가 알렸다. 그사이 보낸 메일의 링크에 실린 파일(FILE_003)이면 목록을 다시 받았다 — 다시 눌러도 같으니 창을 닫는다.
// 그 밖은 창을 그대로 두어 다시 누를 수 있게 한다
if (toApiError(caught).code === 'FILE_003') {
target.value = null
await focusAfterRemoval(index)
}
} finally {
deleting.value = false
}
}
</script>
<template>
<section
class="unsent"
aria-labelledby="large-unsent-title"
>
<div class="unsent__head">
<h2
id="large-unsent-title"
ref="titleRef"
class="unsent__title"
tabindex="-1"
>
{{ LARGE_FILE_SETTINGS_TEXT.unsentTitle(formatCount(files.length)) }}
</h2>
<span class="unsent__hint">{{ LARGE_FILE_SETTINGS_TEXT.unsentHint(formatFileSize(totalBytes)) }}</span>
</div>
<ul
ref="listRef"
class="unsent__list"
:aria-label="LARGE_FILE_SETTINGS_TEXT.unsentList"
>
<li
v-for="file in files"
:key="file.id"
class="unsent__row"
:data-unsent="file.id"
>
<AppIcon
name="file"
class="unsent__icon"
/>
<span class="unsent__main">
<span class="unsent__name">{{ file.name }}</span>
<span :class="['unsent__status', `unsent__status--${rowOf(file).tone}`]">{{ rowOf(file).status }}</span>
</span>
<span class="unsent__size">
<span>{{ formatFileSize(file.size) }}</span>
<span v-if="rowOf(file).uploaded">{{ rowOf(file).uploaded }}</span>
</span>
<span class="unsent__until">
<span>{{ rowOf(file).until }}</span>
<span
v-if="rowOf(file).untilNote"
class="unsent__until-note"
>{{ rowOf(file).untilNote }}</span>
</span>
<button
type="button"
class="unsent__delete"
:aria-label="LARGE_FILE_SETTINGS_TEXT.deleteLabel(file.name)"
data-unsent-delete
@click="target = file"
>
{{ LARGE_FILE_SETTINGS_TEXT.delete }}
</button>
</li>
</ul>
<LargeFileDeleteDialog
:open="target !== null"
:message="deleteMessage"
:busy="deleting"
@confirm="confirmDelete"
@cancel="target = null"
/>
</section>
</template>
<style scoped>
.unsent {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.unsent__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-8);
}
.unsent__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
outline-offset: var(--focus-ring-offset);
}
.unsent__hint {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.unsent__list {
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
/* 줄 52 — 아이콘 16 · 이름 · 크기 84 · 남는 기한 176 · [지우기] (디자인 원본 SettingsLargeFiles) */
.unsent__row {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) 5.25rem 11rem auto;
align-items: center;
gap: var(--space-10);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-14);
}
.unsent__row + .unsent__row {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.unsent__icon {
color: var(--color-text-secondary);
}
.unsent__main,
.unsent__until {
min-width: 0;
display: flex;
flex-direction: column;
line-height: var(--line-height-tight);
}
.unsent__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.unsent__status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
}
.unsent__status--muted {
color: var(--color-text-muted);
}
.unsent__status--warning {
color: var(--color-warning-text);
}
.unsent__status--danger {
color: var(--color-danger);
}
.unsent__size {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--line-height-tight);
font-size: var(--font-size-small);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.unsent__until {
font-variant-numeric: tabular-nums;
}
.unsent__until-note {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
/* [지우기] 36 — 밀도 예외 (--size-notice-action, 차단한 주소의 [차단 풀기]와 같다), 빨간 글 */
.unsent__delete {
min-height: var(--size-notice-action);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-danger);
font-weight: 600;
}
.unsent__delete:hover {
background: var(--color-danger-surface);
}
/* 좁으면 크기 · 남는 기한을 이름 아래로 */
@media (max-width: 37.5rem) {
.unsent__row {
grid-template-columns: 1rem minmax(0, 1fr) auto;
}
.unsent__size,
.unsent__until {
grid-column: 2;
align-items: flex-start;
}
.unsent__delete {
grid-column: 3;
grid-row: 1;
}
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// 설정 › 대용량 첨부의 사용량 상자 (디자인 원본 SettingsLargeFiles)
// "보관 사용량 6.2 / 10 GB"(메일함 사용량과 같은 표기) + 4px 막대(90% 를 넘으면 빨강 — 메일함 사용량과 같다) +
// "보낸 링크 4.3 GB · 보내지 않은 파일 1.9 GB(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다."
// 둘로 나누는 값은 보내지 않은 파일을 다 받았을 때만 (split) — 못 나누면 뒤 문장만. 막대는 <meter> 를 꾸민다 (값 · 범위가 화면 낭독기에 그대로 간다)
import { computed } from 'vue'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import { MAIL_QUOTA_WARNING_RATIO } from '@/shared/constants/mail'
import { formatFileSize, formatQuotaUsage } from '@/shared/utils/format'
interface Props {
usedBytes: number
limitBytes: number
/** 보낸 링크 · 보내지 않은 파일로 나눈 양 — 모르면 null */
split: { sentBytes: number; unsentBytes: number } | null
}
const props = defineProps<Props>()
const PERCENT = 100
const usage = computed(() => formatQuotaUsage(props.usedBytes, props.limitBytes))
const used = computed(() => Math.min(Math.max(props.usedBytes, 0), props.limitBytes))
const warnAt = computed(() => props.limitBytes * MAIL_QUOTA_WARNING_RATIO)
const percentText = computed(() =>
props.limitBytes > 0 ? `${Math.round((used.value / props.limitBytes) * PERCENT)}%` : '',
)
const note = computed(() => {
const tail = LARGE_FILE_SETTINGS_TEXT.usageNote
if (!props.split) return tail
const head = LARGE_FILE_SETTINGS_TEXT.usageSplit(
formatFileSize(props.split.sentBytes),
formatFileSize(props.split.unsentBytes),
)
return `${head} ${tail}`
})
</script>
<template>
<section
class="large-usage"
aria-labelledby="large-usage-label"
>
<div class="large-usage__row">
<span
id="large-usage-label"
class="large-usage__label"
>{{ LARGE_FILE_SETTINGS_TEXT.usageLabel }}</span>
<span
class="large-usage__value"
data-large-usage
>{{ usage }}</span>
</div>
<meter
v-if="limitBytes > 0"
class="large-usage__meter"
min="0"
:max="limitBytes"
:low="warnAt"
:high="warnAt"
optimum="0"
:value="used"
aria-labelledby="large-usage-label"
:aria-valuetext="percentText"
:title="usage"
/>
<p class="large-usage__note">
{{ note }}
</p>
</section>
</template>
<style scoped>
.large-usage {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-14) var(--space-16);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.large-usage__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-12);
}
.large-usage__label,
.large-usage__value {
font-weight: 600;
}
.large-usage__value {
font-variant-numeric: tabular-nums;
}
/* 4px 막대 — 메일함 사용량(MailQuotaMeter)과 같은 모양 */
.large-usage__meter {
appearance: none;
display: block;
width: 100%;
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
overflow: hidden;
}
.large-usage__meter::-webkit-meter-bar {
height: var(--space-4);
border: 0;
border-radius: var(--space-2);
background: var(--color-skeleton);
}
.large-usage__meter::-webkit-meter-optimum-value {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-usage__meter::-webkit-meter-suboptimum-value,
.large-usage__meter::-webkit-meter-even-less-good-value {
border-radius: var(--space-2);
background: var(--color-danger);
}
.large-usage__meter::-moz-meter-bar {
border-radius: var(--space-2);
background: var(--color-brand);
}
.large-usage__meter:-moz-meter-sub-optimum::-moz-meter-bar,
.large-usage__meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
background: var(--color-danger);
}
.large-usage__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,234 @@
<script setup lang="ts">
// 공개 내려받기 화면의 파일 한 줄 (디자인 원본 SharePage) — 아이콘 36 · 이름 · "1.2 GB · 보안 검사 · 이상 없음" · [내려받기](채운 단추 44)
// 상태마다 글: 검사 중(단추 aria-disabled — 초점은 받고 누르면 까닭을 읽어 준다) · 위험(빨간 줄, 단추 없음) ·
// 검사하지 못함(갈색 글, 받을 수 있다) · 횟수 초과 · 찾을 수 없음(단추 없음).
// [내려받기]는 <a href> — 링크 주소 복사 같은 브라우저 기본 기능을 지킨다. 누르면 화면이 상태를 한 번 더 묻고 받는다(download).
// 좁은 폭(휴대폰)에서는 단추가 이름 아래 전체 폭이다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { SHARE_PAGE_TEXT } from '@/shared/constants/large-file'
import type { PublicLinkFile } from '@/shared/types/large-file'
import { formatFileSize } from '@/shared/utils/format'
import { isDownloadable } from '@/shared/utils/large-file-status'
interface Props {
file: PublicLinkFile
/** 파일 주소 — 단추의 href */
href: string
/** 상태를 다시 묻는 중 */
busy?: boolean
}
interface Emits {
/** [내려받기] — 화면이 상태를 한 번 더 묻고 받는다 */
(e: 'download', file: PublicLinkFile): void
/** 검사 중이라 받을 수 없는 단추를 눌렀다 — 까닭을 읽어 준다 */
(e: 'blocked', reason: string): void
}
const props = withDefaults(defineProps<Props>(), {
busy: false,
})
const emit = defineEmits<Emits>()
/** 가운데 버튼(새 탭으로 열기) — 같은 흐름으로 받는다 */
const MIDDLE_BUTTON = 1
const metaId = computed(() => `share-file-${props.file.id}`)
const size = computed(() => formatFileSize(props.file.size))
const view = computed<{ meta: string; tone: 'normal' | 'warning' | 'danger' | 'muted'; button: 'ready' | 'blocked' | 'none' }>(
() => {
switch (props.file.status) {
case 'available':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.clean}`, tone: 'normal', button: 'ready' }
case 'unscannable':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.unscannable}`, tone: 'warning', button: 'ready' }
case 'scanning':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.scanning}`, tone: 'normal', button: 'blocked' }
case 'infected':
return { meta: `${size.value} · ${SHARE_PAGE_TEXT.infected}`, tone: 'danger', button: 'none' }
case 'limit-reached':
return { meta: SHARE_PAGE_TEXT.limitReached, tone: 'muted', button: 'none' }
// 'missing' — 파일을 찾을 수 없다 (저장소 복원 등 — 드묾)
default:
return { meta: SHARE_PAGE_TEXT.missing, tone: 'muted', button: 'none' }
}
},
)
function onDownload(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== MIDDLE_BUTTON) return
event.preventDefault()
if (!props.busy && isDownloadable(props.file.status)) emit('download', props.file)
}
</script>
<template>
<li
:class="['share-file', `share-file--${view.tone}`]"
:data-share-file="file.id"
>
<div class="share-file__main">
<span
class="share-file__icon"
aria-hidden="true"
>
<AppIcon :name="view.tone === 'danger' ? 'alert-triangle' : 'file'" />
</span>
<div class="share-file__text">
<span class="share-file__name">{{ file.name }}</span>
<span
:id="metaId"
class="share-file__meta"
>{{ view.meta }}</span>
</div>
</div>
<a
v-if="view.button === 'ready'"
class="share-file__download"
:href="href"
:aria-label="SHARE_PAGE_TEXT.downloadLabel(file.name)"
:aria-busy="busy ? 'true' : undefined"
download
rel="noreferrer"
@click="onDownload"
@auxclick="onDownload"
>
<AppIcon
name="download"
:size="15"
:stroke-width="2"
/>
<span>{{ SHARE_PAGE_TEXT.download }}</span>
</a>
<button
v-else-if="view.button === 'blocked'"
type="button"
class="share-file__download"
aria-disabled="true"
:aria-label="SHARE_PAGE_TEXT.downloadLabel(file.name)"
:aria-describedby="metaId"
@click="emit('blocked', view.meta)"
>
<span>{{ SHARE_PAGE_TEXT.download }}</span>
</button>
</li>
</template>
<style scoped>
.share-file {
display: flex;
align-items: center;
gap: var(--space-12);
padding: var(--space-12) var(--space-12) var(--space-12) var(--space-14);
}
.share-file + .share-file {
border-top: var(--border-width) solid var(--color-border-subtle);
}
.share-file__main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-12);
}
/* 아이콘 36 */
.share-file__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-36);
height: var(--space-36);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}
.share-file__text {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.4;
}
.share-file__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.share-file__meta {
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* [내려받기] — 채운 단추 44 */
.share-file__download {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
text-decoration: none;
}
.share-file__download:hover:not([aria-disabled='true']) {
background: var(--color-brand-strong);
color: var(--color-on-brand);
}
/* 검사 중 — 초점은 받되 회색 (누르면 까닭을 읽어 준다) */
.share-file__download[aria-disabled='true'] {
background: var(--color-border-strong);
cursor: not-allowed;
}
.share-file--warning .share-file__meta {
color: var(--color-warning-text);
}
.share-file--danger {
margin: var(--space-4);
border: var(--border-width) solid var(--color-danger-border);
border-radius: var(--radius-md);
background: var(--color-danger-surface);
}
.share-file--danger .share-file__icon {
background: transparent;
color: var(--color-danger);
}
.share-file--danger .share-file__name,
.share-file--danger .share-file__meta {
color: var(--color-danger);
}
/* 좁은 폭(휴대폰) — 단추가 이름 아래 전체 폭 */
@media (max-width: 30rem) {
.share-file {
flex-direction: column;
align-items: stretch;
gap: var(--space-10);
}
.share-file__download {
width: 100%;
}
}
</style>
+6
View File
@@ -76,6 +76,12 @@ describe('에러 코드 사전', () => {
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
// 웹메일 8단계 (주소록)
'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다
// 웹메일 11단계 (대용량 첨부) — 조각 올리기 · 보내기가 이 코드로 분기한다 (FILE_008 은 분기하지 않는다 — 받기는 <a href> 이동이다)
'FILE_003', // 올리기 상태가 맞지 않음 — 상태를 다시 묻고 잇는다(받았다는 조각은 한 번 더 보내 보고, 또 409 면 멈춘다)
'FILE_004', // 조각 손상 — 그 조각만 다시 보낸다
'FILE_005', // 보관 한도 — 그 파일 줄에 사전 문구 + [대용량 첨부 보기]
'FILE_006', // 서버 저장 공간 — 그 파일 줄에 사전 문구, 멈추고 [다시 시도]
'MAIL_016', // 대용량 파일을 쓸 수 없음 — 쓰기 화면이 파일마다 상태를 다시 묻는다
]
for (const code of usedByClient) {
@@ -34,6 +34,11 @@ export interface UploadItem {
* 올린 파일은 빼고 다시 고르면 되지만, 전달 원본은 다시 올릴 수 없어 빼는 것만 할 수 있다 (계약 2절).
*/
missing: boolean
/**
* 이 쓰기 화면에서 고른 파일 — 「링크로 보내기」(11단계)가 대용량 첨부로 옮길 때 다시 올린다.
* 전달 원본 · 저장본의 첨부는 null (처음 판에서 링크로 바꾸지 않는다)
*/
file: File | null
}
export interface AttachmentUploadOptions {
@@ -153,6 +158,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachment: null,
errorMessage: rejected ? getErrorMessage(rejected) : null,
missing: false,
file,
},
]
if (!rejected) {
@@ -187,6 +193,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
attachment: rejected ? null : attachment,
errorMessage: rejected ? getErrorMessage(rejected) : null,
missing: false,
file: null,
}
})
items.value = [...items.value, ...added]
@@ -0,0 +1,267 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { MailEditorContent, MailEditorReserve } from '@/components/mail/editor/mail-editor-handle'
import type { UploadItem } from '@/composables/useAttachmentUploads'
import type { Clock } from '@/composables/useClock'
import { useLargeFileUploads } from '@/composables/useLargeFileUploads'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { useToast } from '@/composables/useToast'
import { LARGE_FILE_QUOTA_WARNING_RATIO, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { MessageLargeFile } from '@/shared/types/large-file'
import type { SendMessagePayload } from '@/shared/types/mail'
import { formatCount, formatFileSize, formatMegabytes, formatQuotaUsage } from '@/shared/utils/format'
import { formatKstMonthDayWeekday, kstRetentionEnd } from '@/shared/utils/kst-date'
import {
composeAttachmentHint,
largeFileBlockTextReserve,
routeAttachment,
sendBlockOf,
} from '@/shared/utils/large-file'
import { withoutQuoteStartMark } from '@/shared/utils/outgoing-html-rules'
import { filesByShareUrl, quotedLinkNotices, shareUrlsIn, type QuotedLinkNotice } from '@/shared/utils/share-links'
import { useLargeFilesStore } from '@/stores/large-files.store'
import { useMailStore } from '@/stores/mail.store'
// 쓰기 화면의 대용량 첨부를 잇는 곳 (11단계 — 계약 4절 "대용량 첨부" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// 쓰기 화면(ComposePage)에서 떼어 냈다 — 화면은 이것이 돌려주는 값 · 함수만 쓴다. 조각 올리기 자체는 useLargeFileUploads.
// - 고른 파일을 메일에 담거나 링크로 보낸다 — 20MB 넘는 파일 · 합계를 25MB 넘게 만드는 파일 · 메일에 담지 못하는 형식은 링크로
// (routeAttachment — 고른 차례대로. 합계 때문에 바꿨으면 칸 아래 한 줄). 이 화면에서 고른 일반 첨부는 「링크로 보내기」로 옮길 수 있다.
// - 첨부 칸의 글: 안내문 · 묶음 머리(메일 합계 · 링크 기간) · 보관 사용량 경고 · 합계 때문에 바꾼 안내 · 인용 속 원래 링크의 상태.
// - [보내기]를 막는 까닭(sendBlockOf) — 올리는 중 · 다 올리지 못한 파일 · 빼야 하는 파일 · 한 통의 대용량 파일 수 (일반 첨부의 올리는 중 · 찾지 못함 포함).
// - 보내는 · 저장하는 본문 필드 — 대용량 파일이 있으면 그 id 와 덩이 자리(서식 본문의 인용 경계 표시 · bodyQuoteStart), 없으면 표시를 지운다.
// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 그만큼 텍스트 한도를 남긴다(sendReserve).
// - 실시간 알림의 위험 판정은 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다 (useLargeFileAlerts 가 스토어에 신호를 남긴다)
/** 쓰기 화면의 일반 첨부 — useAttachmentUploads 에서 이것만 쓴다 */
export interface ComposeNormalAttachments {
items: Ref<UploadItem[]>
/** 메일에 담은(올리는 중 포함) 파일의 합계 */
attachedBytes: Readonly<Ref<number>>
/** 서버가 찾지 못한 첨부(MAIL_004)가 남았다 */
hasMissing: Readonly<Ref<boolean>>
addFiles: (files: Iterable<File>) => File[]
remove: (key: number) => void
}
export interface ComposeLargeFilesOptions {
clock: Clock
normal: ComposeNormalAttachments
}
/** 본문 필드 — 발송 · 임시 저장 같다 */
export type ComposeBodyFields = Pick<SendMessagePayload, 'body' | 'bodyHtml' | 'largeFileIds' | 'bodyQuoteStart'>
export function useComposeLargeFiles(options: ComposeLargeFilesOptions) {
const { clock, normal } = options
const mailStore = useMailStore()
const largeFilesStore = useLargeFilesStore()
const { lookupShareLinks } = useLargeFiles()
const toast = useToast()
/**
* 대용량 첨부(링크) — 한도는 GET /file-quota (쓰기 화면을 새로 채울 때마다 받는다, 못 받으면 서버 기본값).
* 사용량은 받은 값에 이 화면에서 새로 만든 올리기를 더해 센다
*/
const uploads = useLargeFileUploads({
clock,
limits: () => ({
maxFileBytes: largeFilesStore.limits.maxFileBytes,
maxFilesPerMessage: largeFilesStore.limits.maxFilesPerMessage,
limitBytes: largeFilesStore.limits.limitBytes,
baseUsedBytes: largeFilesStore.limits.usedBytes,
}),
})
/** 합계 때문에 저절로 링크로 바꾼 파일 — 칸 아래 한 줄 (그 줄을 빼면 사라진다) */
const switchedByTotal = ref<{ key: number; name: string; size: number } | null>(null)
/** 인용 속 원래 대용량 첨부 링크의 상태 — 칸 아래 글자로만 (답장 · 전달) */
const quotedNotices = ref<QuotedLinkNotice[]>([])
/** 양식을 새로 채운 횟수 — 늦게 온 인용 링크 상태를 버린다 */
let formSeq = 0
/** 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)" */
const hint = computed(() =>
composeAttachmentHint(
{
maxBytes: mailStore.attachmentMaxBytes,
totalMaxBytes: mailStore.attachmentTotalMaxBytes,
maxCount: MAIL_MAX_ATTACHMENTS,
},
largeFilesStore.limits,
),
)
/** 메일 묶음 머리의 "합계 18.1 MB / 25 MB" */
const normalTotalText = computed(() =>
LARGE_FILE_TEXT.normalTotal(
formatFileSize(normal.attachedBytes.value),
formatFileSize(mailStore.attachmentTotalMaxBytes),
),
)
/** 링크 묶음 머리 — "받는 사람은 보낸 날부터 30일 동안 내려받습니다 — 오늘 보내면 11월 29일(일)까지" */
const linkGroupHint = computed(() => {
const days = largeFilesStore.limits.retentionDays
return LARGE_FILE_TEXT.linkGroupHint(formatCount(days), formatKstMonthDayWeekday(kstRetentionEnd(clock.now(), days)))
})
/** 보관 사용량이 80%를 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB" */
const quotaWarning = computed(() => {
const { limitBytes } = largeFilesStore.limits
const used = uploads.usedBytes.value
return limitBytes > 0 && used / limitBytes > LARGE_FILE_QUOTA_WARNING_RATIO
? LARGE_FILE_TEXT.quotaWarning(formatQuotaUsage(used, limitBytes))
: null
})
/** 합계 때문에 저절로 링크로 바꾼 안내 — 그 파일이 링크 묶음에 있는 동안 */
const switchNotice = computed(() => {
const switched = switchedByTotal.value
if (!switched || !uploads.items.value.some((item) => item.key === switched.key)) return null
return LARGE_FILE_TEXT.switchedByTotal(
switched.name,
formatFileSize(switched.size),
formatMegabytes(mailStore.attachmentTotalMaxBytes),
)
})
/**
* 첨부 때문에 보내기를 막는 까닭 — 올리는 중 · 다 올리지 못한 대용량 파일 · 빼야 하는 파일(서버가 찾지 못한 첨부 MAIL_004 ·
* 지운 · 기간이 지난 · 위험 판정 대용량 파일) · 한 통의 대용량 파일 수. 모르는 사이 첨부 없이 나가지 않게 (계약 4절 "보내기를 막는 까닭")
*/
const sendBlock = computed(() =>
sendBlockOf({
normalUploading: normal.items.value.filter((item) => item.status === 'uploading').length,
normalMissing: normal.hasMissing.value,
largeActive: uploads.activeCount.value,
largeIncomplete: uploads.hasIncomplete.value,
largeMissing: uploads.hasMissing.value,
largeInfected: uploads.hasInfected.value,
largeCount: uploads.carried.value.length,
maxLargeFiles: largeFilesStore.limits.maxFilesPerMessage,
}),
)
/**
* 본문 필드 — 대용량 파일이 있으면 그 id 와 덩이 자리(서식 본문의 인용 경계 표시 · 텍스트의 bodyQuoteStart)를 싣는다.
* 없으면 표시를 지우고 둘 다 보내지 않는다 — 지금까지와 같은 본문이다 (계약 4절 "발송 · 임시 저장과 함께")
*/
function bodyFields(content: MailEditorContent): ComposeBodyFields {
const ids = uploads.uploadIds.value
const withLarge = ids.length > 0
const html = content.html !== null && !withLarge ? withoutQuoteStartMark(content.html) : content.html
return {
body: content.text,
...(html ? { bodyHtml: html } : {}),
...(withLarge ? { largeFileIds: [...ids] } : {}),
...(withLarge && content.quoteStart !== null ? { bodyQuoteStart: content.quoteStart } : {}),
}
}
/** 보내기 직전에 남길 텍스트 몫 — 서버가 덩이를 넣은 뒤 다시 잰다 (대용량 파일이 없으면 남기지 않는다) */
function sendReserve(): MailEditorReserve | undefined {
const carried = uploads.carried.value
return carried.length > 0 ? { text: largeFileBlockTextReserve(carried) } : undefined
}
/**
* 고른 파일을 메일에 담거나 링크로 보낸다 — 고른 차례대로 (routeAttachment, 계약 4절 "링크로 바뀌는 때").
* 20MB 넘는 파일 · 합계를 25MB 넘게 만드는 파일 · 메일에 담지 못하는 형식은 링크로. 합계 때문에 바꿨으면 칸 아래에 한 줄.
* @returns 올리기 시작한 파일 (사전 검사에 걸린 것은 빼고)
*/
function attachFiles(files: readonly File[]): File[] {
const limits = { maxBytes: mailStore.attachmentMaxBytes, totalMaxBytes: mailStore.attachmentTotalMaxBytes }
const accepted: File[] = []
for (const file of files) {
const route = routeAttachment(file, limits, normal.attachedBytes.value)
if (route === 'normal') {
accepted.push(...normal.addFiles([file]))
continue
}
const [item] = uploads.addFiles([file])
if (!item || item.phase === 'rejected') continue
accepted.push(file)
if (route === 'large-total') switchedByTotal.value = { key: item.key, name: file.name, size: file.size }
}
return accepted
}
/** 「링크로 보내기」를 보일 일반 첨부 — 이 화면에서 고른 파일(올리는 중 · 올림)만. 전달 원본 · 저장본의 첨부는 처음 판에서 바꾸지 않는다 */
function isLinkable(item: UploadItem): boolean {
return item.origin === 'upload' && item.file !== null && item.status !== 'failed' && !item.missing
}
/**
* 「링크로 보내기」 — 링크로 올리기 전 검사(한 통의 대용량 파일 수 · 보관량)를 먼저 본다. 통과할 때만 메일에 담던 파일을 빼고
* 대용량 첨부로 다시 올린다. 걸리면 일반 첨부는 그대로 두고 까닭을 알린다(보관량이면 [대용량 첨부 보기])
*/
function sendAsLink(key: number) {
const item = normal.items.value.find((candidate) => candidate.key === key)
if (!item?.file || !isLinkable(item)) return
const { file } = item
const problem = uploads.precheck(file)
if (problem) {
toast.error(
problem.text,
problem.linksSettings ? { label: LARGE_FILE_TEXT.quotaLink, to: { name: SETTINGS_ROUTES.largeFiles } } : undefined,
)
return
}
normal.remove(key)
uploads.addFiles([file])
}
/**
* 인용 속 원래 대용량 첨부 링크의 상태를 묻는다 — 알림 없이, 실패하면 글자를 두지 않는다 (계약 4절 "인용 속 링크의 상태").
* 그사이 양식이 바뀌었으면 버린다
*/
async function lookupQuotedLinks(
html: string | null,
text: string | null,
files: readonly MessageLargeFile[],
): Promise<void> {
const urls = shareUrlsIn(html, text)
if (urls.length === 0) return
const mark = formSeq
try {
const result = await lookupShareLinks(urls, { silent: true })
if (mark !== formSeq) return
quotedNotices.value = quotedLinkNotices(result.links, filesByShareUrl(files))
} catch {
// 알리지 않는다 — 원래 링크는 인용 속 글로 그대로 간다
}
}
/** 양식을 새로 채웠다 — 올리던 것을 멈추고(서버의 올리기는 저장본이 이어 쓴다) 칸 아래 글을 지운다. 한도 · 사용량은 새로 받는다 */
function reset() {
uploads.reset()
switchedByTotal.value = null
quotedNotices.value = []
formSeq += 1
void largeFilesStore.loadQuota()
}
// 실시간 알림의 위험 판정 — 그 파일이 이 메일에 있으면 "위험 — 보낼 수 없음"으로 (useLargeFileAlerts 가 신호를 남긴다)
watch(
() => largeFilesStore.infectedSignal,
(signal) => {
if (signal) uploads.markInfected(signal.fileId)
},
)
return {
uploads,
quotedNotices,
hint,
normalTotalText,
linkGroupHint,
quotaWarning,
switchNotice,
sendBlock,
bodyFields,
sendReserve,
attachFiles,
isLinkable,
sendAsLink,
lookupQuotedLinks,
reset,
}
}
@@ -0,0 +1,26 @@
import { useToast } from './useToast'
import { LARGE_FILE_ALERT_TEXT, LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import type { LargeFileSignal } from '@/shared/utils/mail-events'
import { useLargeFilesStore } from '@/stores/large-files.store'
// 올린 대용량 파일의 위험 판정 — 실시간 알림 large-file 을 받은 탭마다 (계약 4절 "악성 검사" · 디자인 README "11단계 › 보낸 뒤 위험이 나오면")
// 빨간 알림 "올린 대용량 파일 ‘…’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기](10초 — 단추가 있는 알림, 마우스 · 초점이 있으면 멈춤).
// 그 파일이 쓰기 화면에 있으면 "위험 — 보낼 수 없음"으로 바뀌고(스토어의 신호), 설정 › 대용량 첨부의 목록도 바뀐다.
// 연결이 없던 동안의 판정은 목록 상태로만 안다 (계약 4절 — 열린 질문)
export function useLargeFileAlerts() {
const toast = useToast()
const largeFilesStore = useLargeFilesStore()
/** useMailRealtime 의 onLargeFile — 위험 판정만 알린다 (그 밖의 판정은 아직 오지 않는다) */
function notify(signal: LargeFileSignal): void {
if (signal.scanStatus !== 'infected') return
largeFilesStore.noteInfected(signal.fileId, signal.name)
toast.error(LARGE_FILE_ALERT_TEXT.infected(signal.name || LARGE_FILE_TEXT.unnamed), {
label: LARGE_FILE_ALERT_TEXT.action,
to: { name: SETTINGS_ROUTES.largeFiles },
})
}
return { notify }
}
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, nextTick, ref, type EffectScope } from 'vue'
import { LARGE_FILE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises } from '@/test-utils/mock-api'
import { useLargeFileScanRefresh } from './useLargeFileScanRefresh'
// 읽기 화면 — 보안 검사 중인 대용량 첨부가 있는 동안 상태를 다시 묻는다 (11단계 리뷰 L5)
// 쓰기 · 공개 화면과 같은 간격(15 · 30 · 60초) · 상한, 막힌 [다운로드]는 한 번 바로, 메일을 바꾸면 처음부터, 세션이 바뀌거나 닫으면 멈춘다
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
describe('useLargeFileScanRefresh', () => {
let clock: ManualClock
let scope: EffectScope
const messageId = ref('m1')
const scanning = ref(true)
let refresh: ReturnType<typeof vi.fn<() => Promise<void>>>
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
clock = createManualClock(Date.UTC(2026, 9, 30, 5, 0, 0))
scope = effectScope()
messageId.value = 'm1'
scanning.value = true
refresh = vi.fn<() => Promise<void>>(() => Promise.resolve())
})
afterEach(() => {
scope.stop()
})
function start() {
const refresher = scope.run(() =>
useLargeFileScanRefresh({
messageId: () => messageId.value,
scanning: () => scanning.value,
refresh,
clock,
}),
)
if (!refresher) throw new Error('스코프를 만들지 못함')
return refresher
}
async function advance(ms: number) {
clock.advance(ms)
await flushPromises()
}
it('검사 중인 동안 15 · 30 · 60초 간격으로 다시 묻는다 — 끝나면 멈춘다', async () => {
start()
await advance(14_999)
expect(refresh).not.toHaveBeenCalled()
await advance(1)
expect(refresh).toHaveBeenCalledTimes(1)
await advance(30_000)
expect(refresh).toHaveBeenCalledTimes(2)
await advance(60_000)
expect(refresh).toHaveBeenCalledTimes(3)
scanning.value = false
await nextTick()
await advance(600_000)
expect(refresh).toHaveBeenCalledTimes(3)
expect(clock.pendingCount()).toBe(0)
})
it('검사 중인 파일이 없으면 묻지 않고, 묻는 수에는 상한이 있다', async () => {
scanning.value = false
start()
await advance(600_000)
expect(refresh).not.toHaveBeenCalled()
scanning.value = true
await nextTick()
for (let index = 0; index < LARGE_FILE_SCAN_POLL_LIMIT + 5; index += 1) await advance(60_000)
expect(refresh).toHaveBeenCalledTimes(LARGE_FILE_SCAN_POLL_LIMIT)
})
it('막힌 [다운로드]를 누르면 한 번 바로 묻는다 — 묻는 중이면 겹쳐 묻지 않는다', async () => {
let resolve: () => void = () => undefined
refresh.mockImplementation(
() =>
new Promise<void>((done) => {
resolve = done
}),
)
const refresher = start()
refresher.refreshNow()
refresher.refreshNow()
expect(refresh).toHaveBeenCalledTimes(1)
resolve()
await flushPromises()
// 바로 물은 뒤에도 간격은 이어서 센다
await advance(15_000)
expect(refresh).toHaveBeenCalledTimes(2)
})
it('메일을 바꾸면 처음 간격부터 다시 센다', async () => {
start()
await advance(15_000)
await advance(30_000)
expect(refresh).toHaveBeenCalledTimes(2)
messageId.value = 'm2'
await nextTick()
await advance(15_000)
expect(refresh).toHaveBeenCalledTimes(3)
})
it('세션이 바뀌거나(로그아웃 · 다른 계정) 화면을 닫으면 멈춘다', async () => {
const refresher = start()
useAuthStore().startSession({ email: 'junho.park@arasystem.it.kr', name: '박준호' })
await nextTick()
await advance(600_000)
refresher.refreshNow()
expect(refresh).not.toHaveBeenCalled()
scope.stop()
scope = effectScope()
useAuthStore().markAuthenticated(USER)
start()
scope.stop()
await advance(600_000)
expect(refresh).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
import { onScopeDispose, watch } from 'vue'
import { systemClock, type Clock, type ClockTimer } from '@/composables/useClock'
import { LARGE_FILE_SCAN_POLL_DELAYS_MS, LARGE_FILE_SCAN_POLL_LIMIT } from '@/shared/constants/large-file'
import { useAuthStore } from '@/stores/auth.store'
// 읽기 화면의 대용량 첨부 — 보안 검사 중인 파일이 있는 동안 상태를 다시 묻는다 (11단계 리뷰 L5)
// 검사가 끝나도 메일 상태(새 메일 확인 · 실시간 알림의 state)는 바뀌지 않아, 묻지 않으면 "검사 중"이 그대로 남는다.
// - 쓰기 · 공개 화면과 같은 간격(15 · 30 · 60초, 그 뒤 60초마다)으로 LARGE_FILE_SCAN_POLL_LIMIT 번까지.
// - 막힌 [다운로드](검사 중)를 누르면 한 번 바로 묻는다 — 기다리지 않고 끝났는지 보게.
// - 한 번에 하나. 메일을 바꾸거나 닫으면(스코프가 사라짐) · 세션이 바뀌면 멈춘다. 늦게 온 응답은 묻는 쪽(refresh)이 버린다
export interface LargeFileScanRefreshOptions {
/** 지금 보고 있는 메일 — 바뀌면 처음부터 다시 센다 */
messageId: () => string
/** 검사 중인 파일이 있다 */
scanning: () => boolean
/** 다시 묻는다 — 열린 메일의 대용량 첨부만 바꾼다 (실패는 알리지 않는다) */
refresh: () => Promise<void>
clock?: Clock
}
export function useLargeFileScanRefresh(options: LargeFileScanRefreshOptions) {
const clock = options.clock ?? systemClock
const authStore = useAuthStore()
let timer: ClockTimer | null = null
let polls = 0
let inFlight = false
/** 멈췄다 — 세션이 바뀌었다 (다시 시작하지 않는다) */
let stopped = false
function clear() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
}
function schedule() {
clear()
if (stopped || inFlight || !options.scanning() || polls >= LARGE_FILE_SCAN_POLL_LIMIT) return
const delays = LARGE_FILE_SCAN_POLL_DELAYS_MS
const delay = delays[Math.min(polls, delays.length - 1)] ?? 0
timer = clock.setTimeout(() => {
timer = null
polls += 1
void run()
}, delay)
}
async function run() {
if (stopped || inFlight) return
inFlight = true
const messageId = options.messageId()
try {
await options.refresh()
} finally {
inFlight = false
}
// 그사이 다른 메일로 바뀌었으면 그 메일의 셈이 이미 새로 시작했다
if (options.messageId() === messageId) schedule()
}
/** 막힌 [다운로드]를 눌렀다 — 기다리던 묻기를 앞당긴다 (셈은 늘리지 않는다) */
function refreshNow() {
if (stopped || inFlight || !options.scanning()) return
clear()
void run()
}
function stop() {
stopped = true
clear()
}
// 메일이 바뀌면 처음부터 — 검사 중인 파일이 없으면 묻지 않는다
watch(
options.messageId,
() => {
polls = 0
schedule()
},
{ immediate: true },
)
// 검사가 끝났으면(응답 · 실시간 소식) 멈추고, 다시 검사 중이 되면(다른 덩이) 이어서 센다
watch(options.scanning, (scanning) => {
if (scanning) {
if (timer === null) schedule()
} else {
clear()
}
})
// 세션이 바뀌면(로그아웃 · 만료 · 다른 계정) 멈춘다 — 앞 사용자의 메일을 묻지 않게
watch(
() => authStore.generation,
() => stop(),
)
onScopeDispose(stop)
return { refreshNow }
}
@@ -0,0 +1,664 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, type EffectScope } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { LargeFile } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useLargeFileUploads } from './useLargeFileUploads'
// 쓰기 화면의 대용량 첨부 — 조각 올리기 (계약 4절 "올리기" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// 서버는 가짜다: 조각 크기를 4바이트로 알려 작은 파일로 여러 조각을 본다 (클라이언트는 응답의 chunkSize 를 따른다)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const CHUNK = 4
const GB = 1024 * 1024 * 1024
const LIMITS = { maxFileBytes: 2047 * 1024 * 1024, maxFilesPerMessage: 10, limitBytes: 10 * GB, baseUsedBytes: 0 }
/** 조각마다 다른 "지문" — 실제 SHA-256 대신 크기 · 처음 글자로 (테스트는 같은지만 본다) */
async function fakeDigest(chunk: Blob): Promise<string> {
return `sha-${chunk.size}-${(await chunk.text()).slice(0, 1)}`
}
function makeFile(name: string, content: string, lastModified = 1_700_000_000_000): File {
return new File([content], name, { lastModified })
}
interface FakeUpload extends LargeFile {
/** 조각 번호 → 받은 내용 */
chunks: Map<number, string>
}
/** 서버가 내보내는 모양 — 받은 조각의 내용(chunks)은 빼고 번호만 */
function largeFileOf(upload: FakeUpload): LargeFile {
const copy: Partial<FakeUpload> = { ...upload }
delete copy.chunks
return { ...(copy as LargeFile), receivedChunks: [...upload.chunks.keys()].sort((a, b) => a - b) }
}
/** 붙잡힌 조각 요청 — 풀리거나(released) 요청이 취소되면(aborted) 끝난다 */
function waitGate(gate: Promise<void>, request: MockRequest): Promise<'released' | 'aborted'> {
const signal = request.config.signal as AbortSignal | undefined
const aborted = new Promise<'aborted'>((resolve) => {
if (signal?.aborted) resolve('aborted')
signal?.addEventListener('abort', () => resolve('aborted'), { once: true })
})
return Promise.race([gate.then(() => 'released' as const), aborted])
}
/** 가짜 서버 — 만들기 · 조각 · 끝내기 · 상태 · 지우기. fault 로 조각마다 실패를 끼운다 */
function createFakeServer() {
const uploads = new Map<string, FakeUpload>()
let nextId = 1
let fault: ((request: MockRequest, index: number) => MockResult | null) | null = null
let createFault: (() => MockResult | null) | null = null
let completionFault: (() => MockResult | null) | null = null
/** 조각 요청을 붙잡아 둔다 — 풀 때까지 응답하지 않는다 (동시에 몇 개가 나갔는지 본다) */
let chunkGate: Promise<void> | null = null
function view(upload: FakeUpload): LargeFile {
return largeFileOf(upload)
}
async function handle(request: MockRequest): Promise<MockResult> {
if (request.method === 'POST' && request.url === '/file-uploads') {
const blocked = createFault?.()
if (blocked) return blocked
const body = request.data as { name: string; size: number; lastModified: number }
const id = `u${nextId}`
nextId += 1
const upload: FakeUpload = {
id,
name: body.name,
size: body.size,
status: 'uploading',
chunkSize: CHUNK,
chunkCount: Math.ceil(body.size / CHUNK),
receivedChunks: [],
firstChunkSha256: null,
scanStatus: null,
createdAt: '2026-10-30T05:00:00Z',
expiresAt: '2026-10-31T05:00:00Z',
lastModified: body.lastModified,
chunks: new Map(),
}
uploads.set(id, upload)
return ok(view(upload), 201)
}
const chunk = /^\/file-uploads\/([^/]+)\/chunks\/(\d+)$/.exec(request.url)
if (request.method === 'PUT' && chunk) {
const upload = uploads.get(chunk[1] ?? '')
if (!upload) return fail(404, 'RES_001')
const index = Number(chunk[2])
// 붙잡힌 동안 멈춤 · 빼기(요청 취소)가 오면 브라우저처럼 바로 취소로 끝난다
if (chunkGate && (await waitGate(chunkGate, request)) === 'aborted') return 'cancel'
const injected = fault?.(request, index)
if (injected) return injected
const content = await (request.data as Blob).text()
upload.chunks.set(index, content)
if (index === 0) upload.firstChunkSha256 = `sha-${content.length}-${content.slice(0, 1)}`
return ok(view(upload))
}
const completion = /^\/file-uploads\/([^/]+)\/completion$/.exec(request.url)
if (request.method === 'POST' && completion) {
const upload = uploads.get(completion[1] ?? '')
if (!upload) return fail(404, 'RES_001')
if (upload.chunks.size < upload.chunkCount) return fail(409, 'FILE_003')
const busy = completionFault?.()
if (busy) return busy
upload.status = 'ready'
upload.scanStatus = 'pending'
return ok(view(upload))
}
const single = /^\/file-uploads\/([^/]+)$/.exec(request.url)
if (single && request.method === 'GET') {
const upload = uploads.get(single[1] ?? '')
return upload ? ok(view(upload)) : fail(404, 'RES_001')
}
if (single && request.method === 'DELETE') {
return uploads.delete(single[1] ?? '') ? { status: 204 } : fail(404, 'RES_001')
}
return fail(500, 'SYS_001')
}
return {
uploads,
handle,
setFault(next: typeof fault) {
fault = next
},
setCreateFault(next: typeof createFault) {
createFault = next
},
setCompletionFault(next: typeof completionFault) {
completionFault = next
},
/** 조각 요청을 붙잡는다 — 돌려준 함수로 푼다 */
holdChunks(): () => void {
let release: () => void = () => undefined
chunkGate = new Promise<void>((resolve) => {
release = () => {
chunkGate = null
resolve()
}
})
return release
},
}
}
describe('useLargeFileUploads', () => {
let clock: ManualClock
let scope: EffectScope
let server: ReturnType<typeof createFakeServer>
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().markAuthenticated(USER)
clock = createManualClock(Date.UTC(2026, 9, 30, 5, 0, 0))
server = createFakeServer()
requests = installMockApi((request) => server.handle(request))
scope = effectScope()
})
afterEach(() => {
scope.stop()
useToastStore().clear()
restoreApiAdapter()
})
function start(limits: Partial<typeof LIMITS> = {}) {
const uploads = scope.run(() =>
useLargeFileUploads({ clock, limits: () => ({ ...LIMITS, ...limits }), digest: fakeDigest }),
)
if (!uploads) throw new Error('스코프를 만들지 못함')
return uploads
}
const lines = () => requests.map((request) => `${request.method} ${request.url}`)
it('만들기 → 조각마다(파일마다 한 번에 하나) Content-Digest → 끝내기 → 다 올림(검사 중)', async () => {
const uploads = start()
uploads.addFiles([makeFile('설계도_v3.dwg', 'abcdefghij')])
await flushPromises()
expect(lines()).toEqual([
'POST /file-uploads',
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u1/chunks/1',
'PUT /file-uploads/u1/chunks/2',
'POST /file-uploads/u1/completion',
])
expect(requests[0]?.data).toEqual({ name: '설계도_v3.dwg', size: 10, purpose: 'mail', lastModified: 1_700_000_000_000 })
const chunk = requests[1]
expect(chunk?.config.headers['Content-Type']).toBe('application/octet-stream')
expect(chunk?.config.headers['Content-Digest']).toBe('sha-256=:sha-4-a:')
expect(chunk?.config.silent).toBe(true)
expect(server.uploads.get('u1')?.chunks.get(2)).toBe('ij')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u1', phase: 'ready', scanStatus: 'pending', receivedBytes: 10 })
expect(uploads.uploadIds.value).toEqual(['u1'])
expect(uploads.isUploading.value).toBe(false)
})
it('잠깐 끊기면 2 · 5 · 15 · 30초 간격으로 저절로 다시 — 1분 넘게 못 하면 멈추고, [다시 시도]는 받은 조각부터', async () => {
const uploads = start()
let failing = true
server.setFault((_request, index) => (failing && index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('현장영상.mp4', 'abcdefghij')])
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('reconnecting')
expect(uploads.items.value[0]?.receivedBytes).toBe(4)
for (const delay of [2_000, 5_000, 15_000, 30_000]) {
clock.advance(delay)
await flushPromises()
}
// 처음 실패한 뒤 52초 — 아직 1분이 안 됐다
expect(uploads.items.value[0]?.phase).toBe('reconnecting')
clock.advance(30_000)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('stalled')
expect(uploads.isUploading.value).toBe(false)
expect(uploads.hasIncomplete.value).toBe(true)
const chunkOneTries = lines().filter((line) => line.endsWith('/chunks/1')).length
expect(chunkOneTries).toBe(6)
failing = false
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
// 받은 조각(0번)은 다시 보내지 않았다
expect(lines().filter((line) => line.endsWith('/chunks/0'))).toHaveLength(1)
})
it('조각이 손상되면(FILE_004) 그 조각만 다시 — 세 번 거듭되면 멈춘다', async () => {
const uploads = start()
let corrupt = 2
server.setFault((_request, index) => {
if (index !== 0 || corrupt <= 0) return null
corrupt -= 1
return fail(400, 'FILE_004')
})
uploads.addFiles([makeFile('a.zip', 'abcdef')])
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
expect(lines().filter((line) => line.endsWith('/chunks/0'))).toHaveLength(3)
server.setFault((_request, index) => (index === 0 ? fail(400, 'FILE_004') : null))
uploads.addFiles([makeFile('b.zip', 'abcdef')])
await flushPromises()
expect(uploads.items.value[1]?.phase).toBe('stalled')
})
it('끝내기가 진행 중이면(FILE_003 — 조각은 다 있음) 쉬지 않고 묻지 않는다 — 2 · 5 · 15 · 30초 뒤 다시, 1분 넘게 그대로면 멈춘다', async () => {
const uploads = start()
let busy = true
server.setCompletionFault(() => (busy ? fail(409, 'FILE_003') : null))
uploads.addFiles([makeFile('설계도_v3.dwg', 'abcdefgh')])
await flushPromises()
// 끝내기 → 409 → 상태를 한 번 묻고 기다린다 (바로 다시 끝내지 않는다)
expect(lines().slice(-2)).toEqual(['POST /file-uploads/u1/completion', 'GET /file-uploads/u1'])
const settled = lines().length
await flushPromises()
expect(lines()).toHaveLength(settled)
expect(uploads.items.value[0]?.phase).toBe('uploading')
clock.advance(2_000)
await flushPromises()
expect(lines().slice(settled)).toEqual(['POST /file-uploads/u1/completion', 'GET /file-uploads/u1'])
// 그사이 끝났다 — 다음 묻기에서 다 올림
busy = false
clock.advance(5_000)
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({ phase: 'ready', scanStatus: 'pending' })
// 끝내 끝나지 않으면 1분 뒤 멈춘다 — [다시 시도]
const stuck = start()
busy = true
stuck.addFiles([makeFile('현장영상.mp4', 'abcd')])
await flushPromises()
for (const delay of [2_000, 5_000, 15_000, 30_000, 30_000]) {
clock.advance(delay)
await flushPromises()
}
expect(stuck.items.value[0]?.phase).toBe('stalled')
// 처음 + 2 · 7 · 22 · 52초 뒤 + 82초 뒤(1분이 넘어 이것을 끝으로 멈춘다) — 잠깐 끊겼을 때와 같은 셈
expect(lines().filter((line) => line === 'POST /file-uploads/u2/completion')).toHaveLength(6)
})
it('[멈춤]은 그 파일만 · [이어 올리기]로 받은 조각부터 잇는다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.mov', 'abcdefghij')])
await flushPromises()
const key = uploads.items.value[0]?.key ?? 0
uploads.pause(key)
expect(uploads.items.value[0]?.phase).toBe('paused')
clock.advance(60_000)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('paused')
server.setFault(null)
uploads.resume(key)
await flushPromises()
expect(uploads.items.value[0]?.phase).toBe('ready')
})
it('올리기 전 검사 — 2GB 넘는 파일 · 11번째 파일 · 보관 한도 · 막힌 형식은 만들지 않고 빨간 줄 (보내지도 저장하지도 않는다)', async () => {
const uploads = start({ maxFilesPerMessage: 1, limitBytes: 15, maxFileBytes: 12 })
uploads.addFiles([
makeFile('too-big.zip', 'x'.repeat(13)),
makeFile('first.zip', 'x'.repeat(10)),
makeFile('second.zip', 'xx'),
makeFile('setup.exe', 'xx'),
])
await flushPromises()
expect(uploads.items.value.map((item) => [item.name, item.phase, item.errorText])).toEqual([
// 한도 글은 MB · GB 로 적는다 — 이 테스트의 12바이트 한도는 "0MB" 로 보인다
['too-big.zip', 'rejected', '파일 하나는 0MB 까지 보낼 수 있습니다.'],
['first.zip', 'ready', null],
['second.zip', 'rejected', '링크로 보내는 파일은 메일 한 통에 1개까지입니다.'],
['setup.exe', 'rejected', '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.'],
])
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
expect(uploads.uploadIds.value).toEqual(['u1'])
})
it('보관 한도 — 받은 사용량에 이 화면에서 만든 올리기를 더해 센다 · [대용량 첨부 보기]를 잇는다', async () => {
const uploads = start({ limitBytes: 20, baseUsedBytes: 8 })
uploads.addFiles([makeFile('a.zip', 'x'.repeat(10)), makeFile('b.zip', 'x'.repeat(3))])
await flushPromises()
expect(uploads.items.value[1]).toMatchObject({ phase: 'rejected', errorLinksSettings: true })
expect(uploads.usedBytes.value).toBe(18)
})
it('만들기를 서버가 거절하면(FILE_005 · FILE_006) 빨간 줄에 그 코드의 사전 문구 — 다시 하지 않는다', async () => {
const uploads = start()
server.setCreateFault(() => fail(409, 'FILE_005'))
uploads.addFiles([makeFile('a.zip', 'abc')])
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({
phase: 'rejected',
uploadId: null,
errorText: ERROR_CODE_MESSAGES.FILE_005,
errorLinksSettings: true,
})
clock.advance(60_000)
await flushPromises()
expect(lines()).toEqual(['POST /file-uploads'])
server.setCreateFault(() => fail(507, 'FILE_006'))
uploads.addFiles([makeFile('b.zip', 'abc')])
await flushPromises()
expect(uploads.items.value[1]).toMatchObject({
phase: 'rejected',
errorText: ERROR_CODE_MESSAGES.FILE_006,
errorLinksSettings: false,
})
})
it('조각을 보내다 서버 공간이 모자라면(FILE_006) 멈추고 사전 문구 — 저절로 다시 하지 않는다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(507, 'FILE_006') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(uploads.items.value[0]).toMatchObject({ phase: 'stalled', errorText: ERROR_CODE_MESSAGES.FILE_006 })
clock.advance(60_000)
await flushPromises()
expect(lines().filter((line) => line.endsWith('/chunks/1'))).toHaveLength(1)
})
it('빼기(✕) — 올리던 것을 멈추고 서버의 올리기를 지운다 (알림 없이)', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
uploads.remove(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value).toEqual([])
expect(lines().at(-1)).toBe('DELETE /file-uploads/u1')
expect(requests.at(-1)?.config.silent).toBe(true)
expect(useToastStore().toasts).toEqual([])
})
it('서버가 올리기를 잊었으면(404 — 24시간) 멈추고 알린다 — [다시 시도]를 누를 때만 처음부터 새로 만든다', async () => {
const uploads = start()
server.setFault((_request, index) => (index === 1 ? fail(503, 'MAIL_001') : null))
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
server.uploads.delete('u1')
server.setFault(null)
clock.advance(2_000)
await flushPromises()
// 저절로 다시 만들지 않는다 (계약 4절 "클라이언트" — 404 는 멈추고 알린다)
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u1', phase: 'stalled', errorText: LARGE_FILE_TEXT.restartNeeded })
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
clock.advance(60_000)
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(1)
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(2)
// 서버가 이미 잊은 올리기는 지울 것이 없다
expect(lines()).not.toContain('DELETE /file-uploads/u1')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: true, errorText: null })
})
it('조각이 409 인데 서버가 이미 받았다면 그 조각을 한 번 더 보내 보고, 200 이면 다음 조각으로 잇는다', async () => {
const uploads = start()
let first = true
server.setFault((_request, index) => {
if (index !== 1 || !first) return null
// 앞 요청(응답을 받지 못한 것)이 같은 내용으로 먼저 마쳤다
first = false
server.uploads.get('u1')?.chunks.set(1, 'efgh')
return fail(409, 'FILE_003')
})
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(lines()).toEqual([
'POST /file-uploads',
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u1/chunks/1',
'GET /file-uploads/u1',
'PUT /file-uploads/u1/chunks/1',
'PUT /file-uploads/u1/chunks/2',
'POST /file-uploads/u1/completion',
])
expect(uploads.items.value[0]).toMatchObject({ phase: 'ready', receivedBytes: 10 })
})
it('다시 보낸 조각도 409 면 받은 조각과 내용이 다르다 — 멈추고, [다시 시도]면 앞 올리기를 지우고 처음부터', async () => {
const uploads = start()
let changed = true
server.setFault((_request, index) => {
if (index !== 1 || !changed) return null
// 서버가 받은 1번 조각은 지금 파일과 내용이 다르다 (올리는 사이 파일이 바뀌었다)
server.uploads.get('u1')?.chunks.set(1, 'XXXX')
return fail(409, 'FILE_003')
})
uploads.addFiles([makeFile('a.zip', 'abcdefghij')])
await flushPromises()
expect(lines().slice(2)).toEqual([
'PUT /file-uploads/u1/chunks/1',
'GET /file-uploads/u1',
'PUT /file-uploads/u1/chunks/1',
])
expect(uploads.items.value[0]).toMatchObject({ phase: 'stalled', errorText: ERROR_CODE_MESSAGES.FILE_003 })
// 다시 보낸 조각은 진행에 더하지 않는다 — 받은 바이트를 넘지 않는다
expect(uploads.items.value[0]?.sendingBytes).toBe(0)
changed = false
uploads.resume(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(lines()).toContain('DELETE /file-uploads/u1')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: true })
})
it('여러 파일을 고르면 모두 바로 만든다(저장본에 실린다) — 조각은 3개 파일만 함께 보내고 나머지는 차례를 기다린다', async () => {
const uploads = start()
const release = server.holdChunks()
uploads.addFiles(['a', 'b', 'c', 'd', 'e'].map((name) => makeFile(`${name}.zip`, 'abcdefgh')))
await flushPromises()
expect(lines().filter((line) => line === 'POST /file-uploads')).toHaveLength(5)
// 조각 요청은 3개만 나갔다 — 파일마다 한 번에 하나
expect(lines().filter((line) => line.startsWith('PUT '))).toEqual([
'PUT /file-uploads/u1/chunks/0',
'PUT /file-uploads/u2/chunks/0',
'PUT /file-uploads/u3/chunks/0',
])
expect(uploads.items.value.map((item) => item.waitingTurn)).toEqual([false, false, false, true, true])
// 차례를 기다리는 파일도 id 가 있다 — 저장본 · 나가기 확인에 들어간다
expect(uploads.uploadIds.value).toEqual(['u1', 'u2', 'u3', 'u4', 'u5'])
expect(uploads.activeCount.value).toBe(5)
release()
await flushPromises()
await flushPromises()
expect(uploads.items.value.map((item) => item.phase)).toEqual(['ready', 'ready', 'ready', 'ready', 'ready'])
expect(uploads.items.value.some((item) => item.waitingTurn)).toBe(false)
})
it('[멈춤]으로 자리가 나면 차례를 기다리던 파일이 이어 받는다', async () => {
const uploads = start()
const release = server.holdChunks()
uploads.addFiles(['a', 'b', 'c', 'd'].map((name) => makeFile(`${name}.zip`, 'abcd')))
await flushPromises()
expect(uploads.items.value[3]?.waitingTurn).toBe(true)
uploads.pause(uploads.items.value[0]?.key ?? 0)
await flushPromises()
expect(uploads.items.value[3]?.waitingTurn).toBe(false)
expect(lines()).toContain('PUT /file-uploads/u4/chunks/0')
release()
await flushPromises()
expect(uploads.items.value.map((item) => item.phase)).toEqual(['paused', 'ready', 'ready', 'ready'])
})
it('조각의 SHA-256 을 셈할 수 없으면(보안 컨텍스트가 아님) Content-Digest 없이 보낸다', async () => {
const uploads = scope.run(() =>
useLargeFileUploads({ clock, limits: () => LIMITS, digest: () => Promise.resolve(null) }),
)
uploads?.addFiles([makeFile('a.zip', 'abcd')])
await flushPromises()
const chunk = requests.find((request) => request.method === 'PUT')
expect(chunk?.config.headers['Content-Digest']).toBeUndefined()
expect(uploads?.items.value[0]?.phase).toBe('ready')
})
describe('저장본에서 이어 온 파일 (계약 4절 "새로고침 · 창 닫힘 뒤 이어 올리기")', () => {
async function stoppedUpload(content: string, received: number) {
// 앞 화면에서 조각 received 개를 받은 채 멈춘 올리기를 서버에 만들어 둔다
const earlier = start()
server.setFault((_request, index) => (index >= received ? fail(503, 'MAIL_001') : null))
earlier.addFiles([makeFile('현장영상_1029.mp4', content)])
await flushPromises()
earlier.reset()
server.setFault(null)
const upload = server.uploads.get('u1')
if (!upload) throw new Error('올리기가 없음')
requests.length = 0
return largeFileOf(upload)
}
it('같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각)을 다시 고르면 빠진 조각부터 잇는다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
expect(uploads.items.value[0]).toMatchObject({ phase: 'waiting-file', receivedBytes: 8, uploadId: 'u1' })
expect(uploads.hasIncomplete.value).toBe(true)
expect(uploads.uploadIds.value).toEqual(['u1'])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('현장영상_1029.mp4', 'abcdefghij'))
await flushPromises()
expect(lines()).toEqual(['PUT /file-uploads/u1/chunks/2', 'POST /file-uploads/u1/completion'])
expect(uploads.items.value[0]?.phase).toBe('ready')
})
it('이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('다른파일.mp4', 'abcdefghij'))
expect(uploads.items.value[0]).toMatchObject({ phase: 'waiting-file', repickMismatch: true })
expect(requests).toEqual([])
})
it('같아 보여도 맨 앞 조각이 다르면 새로 만든다 — 앞 올리기는 지운다', async () => {
const draftFile = await stoppedUpload('abcdefghij', 2)
const uploads = start()
uploads.addFromDraft([draftFile])
await uploads.repick(uploads.items.value[0]?.key ?? 0, makeFile('현장영상_1029.mp4', 'zbcdefghij'))
await flushPromises()
expect(lines()[0]).toBe('DELETE /file-uploads/u1')
expect(lines()).toContain('POST /file-uploads')
expect(uploads.items.value[0]).toMatchObject({ uploadId: 'u2', phase: 'ready', restarted: false })
})
it('다 올린 파일은 남는 기한과 함께 · 없는 파일은 까닭과 함께 — 없는 파일은 실을 목록에서 빠진다', () => {
const uploads = start()
uploads.addFromDraft([
{
id: 'r1',
name: '설계도_v3.dwg',
size: 1.2 * GB,
status: 'ready',
chunkSize: CHUNK,
chunkCount: 3,
receivedChunks: [0, 1, 2],
firstChunkSha256: 'x',
scanStatus: 'clean',
createdAt: '2026-10-30T05:00:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
lastModified: null,
},
{ id: 'm1', status: 'missing', reason: 'deleted', name: '시공도면_v4.dwg', size: 620 },
])
expect(uploads.items.value.map((item) => [item.phase, item.keptUntil, item.missingReason])).toEqual([
['ready', '2026-11-29T14:59:59.999Z', null],
['missing', null, 'deleted'],
])
expect(uploads.uploadIds.value).toEqual(['r1'])
expect(uploads.hasMissing.value).toBe(true)
})
})
it('다 올린 파일의 보안 검사 — 쓰기 화면이 떠 있는 동안 다시 묻는다 · 실시간 위험 판정이면 "위험"', async () => {
const uploads = start()
uploads.addFiles([makeFile('a.zip', 'abc'), makeFile('b.zip', 'def')])
await flushPromises()
const upload = server.uploads.get('u1')
if (upload) upload.scanStatus = 'clean'
clock.advance(15_000)
await flushPromises()
expect(uploads.items.value[0]?.scanStatus).toBe('clean')
uploads.markInfected('u2')
expect(uploads.items.value[1]?.scanStatus).toBe('infected')
expect(uploads.hasInfected.value).toBe(true)
})
it('보내기가 MAIL_016 이면 다시 묻는다 — 404 는 찾을 수 없는 파일', async () => {
const uploads = start()
uploads.addFiles([makeFile('a.zip', 'abc')])
await flushPromises()
server.uploads.delete('u1')
await uploads.recheck()
expect(uploads.items.value[0]).toMatchObject({ phase: 'missing', missingReason: 'unknown' })
expect(uploads.hasMissing.value).toBe(true)
})
})
@@ -0,0 +1,979 @@
import { computed, onScopeDispose, ref } from 'vue'
import { getErrorMessage, isSessionEnd, toApiError, type ApiError } from './useApi'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useLargeFiles } from './useLargeFiles'
import {
LARGE_FILE_CHUNK_SIZE,
LARGE_FILE_DIGEST_RETRY_LIMIT,
LARGE_FILE_ETA_WARMUP_MS,
LARGE_FILE_ETA_WINDOW_MS,
LARGE_FILE_PARALLEL_UPLOADS,
LARGE_FILE_RETRY_DELAYS_MS,
LARGE_FILE_SCAN_POLL_DELAYS_MS,
LARGE_FILE_SCAN_POLL_LIMIT,
LARGE_FILE_STALL_AFTER_MS,
LARGE_FILE_TEXT,
} from '@/shared/constants/large-file'
import type { DraftLargeFile, LargeFile, ScanStatus } from '@/shared/types/large-file'
import { formatCount } from '@/shared/utils/format'
import {
chunkBounds,
chunkCountOf,
formatSizeLimit,
looksLikeSameFile,
missingChunksOf,
precheckLargeFile,
receivedBytesOf,
sha256Base64,
type LargeFileLimits,
type LargeFilePrecheckProblem,
} from '@/shared/utils/large-file'
// 쓰기 화면의 대용량 첨부 — 조각 올리기 (계약 4절 "올리기" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸")
// - 고른 파일은 올리기 전에 검사한다(형식 · 한 파일 · 한 통의 개수 · 보관량) — 걸리면 빨간 줄로 남고 만들지 않는다(보내기는 막지 않는다).
// 그 글은 화면 상수다. 서버가 거절한 것은 그 코드의 사전 문구다(getErrorMessage).
// - 만들기(POST /file-uploads) → 조각(PUT …/chunks/{n} — 16MiB, 파일마다 한 번에 한 조각, Content-Digest) → 끝내기(POST …/completion).
// 만들기는 고르자마자 한다 — 그래야 차례를 기다리는 파일도 저장본에 실린다(서버의 동시 한도는 조각 요청만 센다 — 직원마다 4개).
// 조각을 함께 보내는 파일은 LARGE_FILE_PARALLEL_UPLOADS 개 — 나머지는 만든 채 차례를 기다린다(waitingTurn).
// - 조각 요청은 중앙 axios(silent) — 401 이면 세션을 한 번 갱신하고 같은 조각을 다시 보낸다. 실패는 그 파일 줄에 보인다(알림 없음).
// 네트워크 · 시간 초과 · 429 · 502 · 503 · 504 는 2 · 5 · 15 · 30초 간격으로 저절로 다시("다시 잇는 중"), 1분 넘게 못 하면 멈추고 [다시 시도].
// FILE_004(조각 손상)는 그 조각만 다시(세 번 거듭되면 멈춘다).
// FILE_003 은 상태를 묻는다 — 하려던 조각을 서버가 이미 받았다면 그 조각을 한 번 더 보내 200 인지 보고(또 409 면 내용이 다른 조각 — 멈춘다),
// 빠진 조각이 있으면 바로 잇고, 그대로면(같은 조각 · 끝내기를 다른 요청이 하는 중) 잠깐 끊겼을 때와 같은 간격으로 기다린다.
// FILE_006(서버 공간) · 404(서버가 올리기를 잊음 — 마지막 조각 뒤 24시간 · 설정에서 지움)는 멈추고 알린다 (계약 4절 "클라이언트").
// 404 와 내용이 다른 조각은 [다시 시도]를 누를 때만 처음부터 새로 만든다 — 만들기의 FILE_001 · FILE_002 · FILE_005 · FILE_006 은 빨간 줄(만들지 않았다).
// - [멈춤] · [이어 올리기]는 그 파일만. ✕(빼기)는 올리던 것을 멈추고 서버의 올리기도 지운다(DELETE — 알림 없이, 404 는 지운 것으로 본다).
// - 저장본에서 이어 온 올리는 중 파일(새로고침 · 창 닫힘 뒤)은 같은 파일을 다시 골라야 이어진다 — 이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256 으로 맞춘다.
// 이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다. 그것은 같은데 내용이 다르거나 맨 앞 조각을 받기 전이면 새로 만든다(앞 올리기는 지운다).
// - 다 올린 파일의 보안 검사가 끝났는지는 화면이 떠 있는 동안 가끔 다시 묻는다 — 위험 판정은 실시간 알림이 먼저 알린다(markInfected).
// 화면(스코프)이 사라지거나 양식을 새로 채우면(reset) 올리던 요청을 모두 멈춘다 — 서버의 올리기는 지우지 않는다(임시 저장본이 이어 쓴다).
/**
* 줄의 상태
* - rejected: 올리기 전 검사 · 만들기에서 거절 — 빨간 줄 (만들지 않았다 · 보내기를 막지 않는다)
* - uploading: 만들기 · 조각 · 끝내기 (차례를 기다리는 동안 포함) · reconnecting: 잠깐 끊겨 저절로 다시 잇는 중
* - paused: 사용자가 멈춤 · stalled: 오래 끊김 · 거듭된 손상 · 서버 공간 · 서버가 잊음 · 내용이 다른 조각 — [다시 시도]
* - waiting-file: 저장본에서 이어 온 올리는 중 파일 — 같은 파일을 다시 골라야 이어진다
* - ready: 다 올림 (scanStatus — 검사 중 · 깨끗함 · 위험 · 검사하지 못함)
* - missing: 저장본의 지운 · 기간이 지난 · 찾을 수 없는 파일 — 빼야 보낼 수 있다
*/
export type LargeUploadPhase =
| 'rejected'
| 'uploading'
| 'reconnecting'
| 'paused'
| 'stalled'
| 'waiting-file'
| 'ready'
| 'missing'
/** 저장본의 없는 파일의 까닭 — 지움 · 기간 지남 · 모름(보내기가 MAIL_016 이라 다시 물었더니 없음) */
export type LargeUploadMissingReason = 'deleted' | 'expired' | 'unknown'
export interface LargeUploadItem {
/** 화면 키 — 일반 첨부와 따로 센다 */
key: number
/** 서버 올리기 id (= 다 올린 뒤의 파일 id) — 만들기 전 · 거절이면 null */
uploadId: string | null
name: string
size: number
phase: LargeUploadPhase
/** 서버가 받은 조각의 바이트 */
receivedBytes: number
/** 지금 보내는 조각에서 올린 바이트 (진행 표시) */
sendingBytes: number
/** 끝내기 요청 중 */
finishing: boolean
/** 만들었고 조각을 보낼 차례를 기다린다 — 함께 보내는 수(LARGE_FILE_PARALLEL_UPLOADS) 밖 */
waitingTurn: boolean
scanStatus: ScanStatus | null
/** 저장본에서 이어 온 다 올린 파일이 남는 때 — "이 임시 저장본에 …까지 남습니다" */
keptUntil: string | null
missingReason: LargeUploadMissingReason | null
/** 빨간 줄 글 — 올리기 전 검사 · 만들기 거절 · 멈춘 까닭 (없으면 상태의 기본 글) */
errorText: string | null
/** 빨간 줄 끝에 [대용량 첨부 보기] (보관 한도) */
errorLinksSettings: boolean
/** 다시 고른 파일이 멈춘 파일과 다르다 */
repickMismatch: boolean
/** [다시 시도]로 처음부터 다시 올리는 중 (서버가 잊었거나 내용이 다른 조각이었다) */
restarted: boolean
/** 남은 시간(ms) — 모르면 null ("시간 계산 중") */
etaMs: number | null
}
/** 올리기 전 검사 · 만들기 거절의 줄 글 */
export interface LargeUploadProblem {
text: string
/** 줄 끝에 [대용량 첨부 보기] (보관 한도) */
linksSettings: boolean
}
export interface LargeUploadOptions {
/** 한도 — GET /file-quota 의 값(못 받았으면 대체값)과 받은 사용량 */
limits: () => Omit<LargeFileLimits, 'usedBytes'> & { baseUsedBytes: number }
clock?: Clock
/** 조각의 SHA-256(base64) — 테스트는 바꿔 넘긴다. 셈할 수 없으면(보안 컨텍스트가 아님) null */
digest?: (chunk: Blob) => Promise<string | null>
}
/** [다시 시도]를 누르면 처음부터 새로 만든다 — 서버가 올리기를 잊었다(404) · 받은 조각과 내용이 다르다(409) */
type RestartReason = 'gone' | 'changed'
/** 파일마다 화면에 그리지 않는 것 — 고른 파일 · 조각 셈 · 기다림 · 다시 하기 셈 */
interface UploadRuntime {
file: File | null
chunkSize: number
chunkCount: number
received: Set<number>
/** 서버가 아는 맨 앞 조각 해시 — 다시 고른 파일을 견줄 때 */
firstChunkSha256: string | null
lastModified: number | null
controller: AbortController | null
waitTimer: ClockTimer | null
wake: (() => void) | null
/** 이어서 실패하기 시작한 때 — 1분 넘게 다시 잇지 못하면 멈춘다 */
failingSince: number | null
retryCount: number
digestFailures: number
removed: boolean
/** 올리기 고리(drive)가 돌고 있다 */
running: boolean
/** 조각을 보낼 자리를 가졌다 — 함께 보내는 수를 센다 (만들기는 자리 없이 한다) */
slot: boolean
/** 멈춘 뒤 [다시 시도]면 처음부터 새로 만든다 */
restart: RestartReason | null
/** 이미 받았다는 조각을 한 번 더 보내 내용이 같은지 본다 (409 FILE_003 뒤 — 또 409 면 멈춘다) */
verifyChunk: number | null
/** 남은 시간을 셀 최근 진행 (시각 · 올린 바이트) */
samples: { at: number; bytes: number }[]
startedAt: number | null
scanTimer: ClockTimer | null
scanPolls: number
/** 이 화면에서 새로 만든 올리기 — 받은 사용량에 더한다 (저장본에서 이어 온 것은 이미 들어 있다) */
countsTowardSession: boolean
}
/** 저절로 다시 보낼 실패 — 응답 없음(네트워크 · 시간 초과) · 429 · 502 · 503 · 504 */
const RETRYABLE_STATUSES: readonly number[] = [429, 502, 503, 504]
const HTTP_NOT_FOUND = 404
function isRetryable(error: ApiError): boolean {
return error.status === undefined || RETRYABLE_STATUSES.includes(error.status) || error.code === 'REQ_001'
}
function isNotFound(error: ApiError): boolean {
return error.status === HTTP_NOT_FOUND || error.code === 'RES_001'
}
/** 기다림을 끊고 고리를 끝낼 때 던진다 — 멈춤 · 빼기 · 양식을 새로 채움 */
class UploadInterrupted extends Error {}
export function useLargeFileUploads(options: LargeUploadOptions) {
const api = useLargeFiles()
const clock = options.clock ?? systemClock
const digest = options.digest ?? sha256Base64
const items = ref<LargeUploadItem[]>([])
const runtimes = new Map<number, UploadRuntime>()
let nextKey = 1
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 요청이 새 양식을 건드리지 않게 */
let generation = 0
function itemOf(key: number): LargeUploadItem | undefined {
return items.value.find((item) => item.key === key)
}
function newRuntime(file: File | null): UploadRuntime {
return {
file,
chunkSize: LARGE_FILE_CHUNK_SIZE,
chunkCount: file ? chunkCountOf(file.size, LARGE_FILE_CHUNK_SIZE) : 0,
received: new Set(),
firstChunkSha256: null,
lastModified: file?.lastModified ?? null,
controller: null,
waitTimer: null,
wake: null,
failingSince: null,
retryCount: 0,
digestFailures: 0,
removed: false,
running: false,
slot: false,
restart: null,
verifyChunk: null,
samples: [],
startedAt: null,
scanTimer: null,
scanPolls: 0,
countsTowardSession: file !== null,
}
}
function newItem(name: string, size: number, phase: LargeUploadPhase): LargeUploadItem {
const key = nextKey
nextKey += 1
return {
key,
uploadId: null,
name,
size,
phase,
receivedBytes: 0,
sendingBytes: 0,
finishing: false,
waitingTurn: false,
scanStatus: null,
keptUntil: null,
missingReason: null,
errorText: null,
errorLinksSettings: false,
repickMismatch: false,
restarted: false,
etaMs: null,
}
}
// ── 한도 · 셈 ─────────────
/** 이 메일에 실을(보낼 · 저장할) 줄 — 빨간 줄(거절) · 없는 파일은 빼고 */
const carried = computed(() => items.value.filter((item) => item.phase !== 'rejected' && item.phase !== 'missing'))
/** 이 화면에서 새로 만든 올리기의 크기 — 받은 사용량에 아직 들지 않았다 */
const sessionBytes = computed(() =>
items.value.reduce((sum, item) => {
const run = runtimes.get(item.key)
return run?.countsTowardSession && item.phase !== 'rejected' && item.phase !== 'missing' ? sum + item.size : sum
}, 0),
)
function limits(): LargeFileLimits {
const { baseUsedBytes, ...rest } = options.limits()
return { ...rest, usedBytes: baseUsedBytes + sessionBytes.value }
}
/** 지금 보관 사용량의 어림 — 받은 사용량 + 이 화면에서 새로 만든 것 (링크 묶음 머리의 "보관 8.4 / 10 GB") */
const usedBytes = computed(() => options.limits().baseUsedBytes + sessionBytes.value)
/** 서버에 묻기 전 검사의 글 — 화면 상수 (형식은 README 대로 FILE_001 사전 문구) */
function precheckText(problem: LargeFilePrecheckProblem): LargeUploadProblem {
const current = options.limits()
switch (problem) {
case 'type':
return { text: getErrorMessage('FILE_001'), linksSettings: false }
case 'size':
return { text: LARGE_FILE_TEXT.tooLarge(formatSizeLimit(current.maxFileBytes)), linksSettings: false }
case 'count':
return { text: LARGE_FILE_TEXT.tooMany(formatCount(current.maxFilesPerMessage)), linksSettings: false }
case 'quota':
return { text: LARGE_FILE_TEXT.overQuota(formatSizeLimit(current.limitBytes)), linksSettings: true }
}
}
/**
* 링크로 올리기 전 검사 — 지금 실은 파일 수 · 보관량을 센다. 걸리면 줄 글, 아니면 null.
* 「링크로 보내기」가 일반 첨부를 빼기 전에 본다 (걸리면 일반 첨부는 그대로)
*/
function precheck(file: File): LargeUploadProblem | null {
const problem = precheckLargeFile(file, limits(), carried.value.length)
return problem ? precheckText(problem) : null
}
/** 만들기를 서버가 거절한 까닭 → 빨간 줄 글(그 코드의 사전 문구). null 이면 거절로 다루지 않는다 — 다시 하기 · 멈춤 */
function creationRejection(error: ApiError): LargeUploadProblem | null {
switch (error.code) {
case 'FILE_001':
case 'FILE_002':
case 'FILE_006':
case 'VAL_001':
return { text: getErrorMessage(error.code), linksSettings: false }
case 'FILE_005':
return { text: getErrorMessage(error.code), linksSettings: true }
default:
return null
}
}
// ── 진행 · 남은 시간 ─────────────
function resetEta(item: LargeUploadItem, run: UploadRuntime) {
run.samples = []
run.startedAt = null
item.etaMs = null
}
function noteProgress(item: LargeUploadItem, run: UploadRuntime, loaded: number) {
item.sendingBytes = loaded
const now = clock.now()
const bytes = item.receivedBytes + loaded
run.startedAt ??= now
run.samples.push({ at: now, bytes })
while (run.samples.length > 2 && now - (run.samples[0]?.at ?? now) > LARGE_FILE_ETA_WINDOW_MS) run.samples.shift()
const first = run.samples[0]
const span = first ? now - first.at : 0
if (!first || now - run.startedAt < LARGE_FILE_ETA_WARMUP_MS || span <= 0 || bytes <= first.bytes) {
item.etaMs = null
return
}
const rate = (bytes - first.bytes) / span
item.etaMs = Math.max(0, (item.size - bytes) / rate)
}
/** 서버가 알려 준 올리기 상태를 줄에 옮긴다 (받은 조각 · 이름 · 다 올렸으면 검사 상태) */
function applyServerState(item: LargeUploadItem, run: UploadRuntime, upload: LargeFile) {
run.chunkSize = upload.chunkSize > 0 ? upload.chunkSize : run.chunkSize
run.chunkCount = upload.chunkCount > 0 ? upload.chunkCount : chunkCountOf(upload.size, run.chunkSize)
run.received = new Set(upload.receivedChunks)
run.firstChunkSha256 = upload.firstChunkSha256 ?? run.firstChunkSha256
run.lastModified = upload.lastModified ?? run.lastModified
item.uploadId = upload.id
item.name = upload.name || item.name
item.size = upload.size
item.receivedBytes = receivedBytesOf(run.received, run.chunkSize, upload.size)
item.sendingBytes = 0
// 조각은 다 갔다 — 남은 시간 대신 "마무리하는 중"
if (item.receivedBytes >= item.size) item.etaMs = null
if (upload.status === 'ready') {
item.phase = 'ready'
item.waitingTurn = false
item.scanStatus = upload.scanStatus
item.etaMs = null
item.errorText = null
scheduleScanPoll(item.key)
}
}
// ── 기다림 ─────────────
/** 기다린다 — 멈춤 · 빼기 · 다시 시도가 깨운다 */
function sleep(run: UploadRuntime, ms: number): Promise<void> {
return new Promise((resolve) => {
const done = () => {
if (run.waitTimer !== null) clock.clearTimeout(run.waitTimer)
run.waitTimer = null
run.wake = null
resolve()
}
run.wake = done
run.waitTimer = clock.setTimeout(done, ms)
})
}
/** 지금 올리고 있어야 하는 줄인가 — 멈추거나 빼거나 양식이 바뀌었으면 아니다 */
function isActive(key: number, gen: number): boolean {
const item = itemOf(key)
const run = runtimes.get(key)
return (
gen === generation &&
item !== undefined &&
run !== undefined &&
!run.removed &&
run.file !== null &&
(item.phase === 'uploading' || item.phase === 'reconnecting')
)
}
// ── 올리기 고리 ─────────────
/**
* 고리를 시작한다 — 만들기 전인 줄은 모두 바로(저장본에 실리게), 만든 줄은 조각 자리가 남는 만큼(동시 LARGE_FILE_PARALLEL_UPLOADS 개).
* 자리를 받지 못한 줄은 "차례를 기다리는 중"이다
*/
function pump() {
let slots = 0
runtimes.forEach((run) => {
if (run.slot) slots += 1
})
for (const item of items.value) {
const run = runtimes.get(item.key)
if (!run || run.running || run.removed || run.file === null) continue
if (item.phase !== 'uploading' && item.phase !== 'reconnecting') continue
if (item.uploadId !== null) {
if (slots >= LARGE_FILE_PARALLEL_UPLOADS) {
item.waitingTurn = true
continue
}
slots += 1
run.slot = true
}
item.waitingTurn = false
void drive(item.key, generation)
}
}
function stall(item: LargeUploadItem, run: UploadRuntime, text: string | null = null) {
item.phase = 'stalled'
item.errorText = text
item.sendingBytes = 0
item.waitingTurn = false
run.failingSince = null
run.retryCount = 0
resetEta(item, run)
}
/** 만들기 — 거절이면 빨간 줄로 두고 고리를 끝낸다 */
async function create(item: LargeUploadItem, run: UploadRuntime, gen: number): Promise<void> {
const file = run.file
if (!file) throw new UploadInterrupted()
const created = await api.createUpload(
{ name: item.name, size: item.size, purpose: 'mail', lastModified: file.lastModified },
{ silent: true },
)
// 그사이 뺐거나 양식을 새로 채웠다 — 어느 저장본에도 실리지 않을 올리기다. 보관량을 잡지 않게 지운다
if (gen !== generation || run.removed) {
void api.deleteUpload(created.id, { silent: true }).catch(() => undefined)
throw new UploadInterrupted()
}
applyServerState(item, run, created)
}
/** 조각 하나 — 받은 조각을 줄에 옮긴다. 이미 받은 조각을 다시 보내 내용을 견줄 때는 진행을 세지 않는다 */
async function sendChunk(item: LargeUploadItem, run: UploadRuntime, uploadId: string, index: number, gen: number) {
const file = run.file
if (!file) throw new UploadInterrupted()
const { start, end } = chunkBounds(index, run.chunkSize, item.size)
const chunk = file.slice(start, end)
const sha256 = await digest(chunk)
if (!isActive(item.key, gen)) throw new UploadInterrupted()
const verifying = run.verifyChunk === index
const controller = new AbortController()
run.controller = controller
item.sendingBytes = 0
try {
const updated = await api.putChunk(uploadId, index, chunk, {
sha256,
signal: controller.signal,
onProgress: (loaded) => {
if (!verifying && isActive(item.key, gen)) noteProgress(item, run, loaded)
},
})
if (gen !== generation || run.removed) throw new UploadInterrupted()
// 다시 보낸 조각이 받은 것과 같다 — 앞의 409 는 같은 조각을 앞 요청이 먼저 마친 것이었다
if (verifying) run.verifyChunk = null
applyServerState(item, run, updated)
} finally {
if (run.controller === controller) run.controller = null
}
}
/**
* 끝내기 — 다 올렸다. 빠진 조각 · 끝내기가 진행 중이면 서버가 FILE_003 이다(handleFailure 가 상태를 보고 잇는다).
* 200 인데 다 올리지 않았다고 하면(계약 밖) 거듭 묻지 않고 멈춘다 — [다시 시도]
*/
async function complete(item: LargeUploadItem, run: UploadRuntime, uploadId: string, gen: number): Promise<void> {
item.finishing = true
try {
const done = await api.completeUpload(uploadId, { silent: true })
if (gen !== generation || run.removed) throw new UploadInterrupted()
applyServerState(item, run, done)
if (item.phase !== 'ready') {
stall(item, run)
throw new UploadInterrupted()
}
} finally {
item.finishing = false
}
}
/** 처음부터 새로 만든다 — [다시 시도](서버가 잊었다 · 내용이 다른 조각) · 다시 고른 파일의 내용이 다르다 */
function restartFromScratch(item: LargeUploadItem, run: UploadRuntime) {
item.uploadId = null
item.receivedBytes = 0
item.sendingBytes = 0
item.restarted = true
run.received = new Set()
run.firstChunkSha256 = null
run.chunkSize = LARGE_FILE_CHUNK_SIZE
run.chunkCount = chunkCountOf(item.size, LARGE_FILE_CHUNK_SIZE)
run.countsTowardSession = true
run.restart = null
run.verifyChunk = null
resetEta(item, run)
}
/**
* 실패를 가른다 — 'retry': 고리가 이어서 한다(같은 조각 · 상태를 물은 뒤) · 'stop': 고리를 끝낸다(멈춤 · 거절 · 다 올림).
* 저절로 다시 보낼 실패는 여기서 기다린다
*/
async function handleFailure(
item: LargeUploadItem,
run: UploadRuntime,
error: unknown,
gen: number,
): Promise<'retry' | 'stop'> {
if (error instanceof UploadInterrupted) return 'stop'
const apiError = toApiError(error)
if (apiError.canceled || !isActive(item.key, gen)) return 'stop'
// 세션이 끝났다 — 세션 만료 대화상자가 로그인으로 보낸다. 저절로 다시 하지 않는다
if (isSessionEnd(apiError.code)) {
stall(item, run)
return 'stop'
}
if (item.uploadId === null) {
const rejected = creationRejection(apiError)
if (rejected) {
item.phase = 'rejected'
item.errorText = rejected.text
item.errorLinksSettings = rejected.linksSettings
run.file = null
return 'stop'
}
}
if (apiError.code === 'FILE_004') {
run.digestFailures += 1
if (run.digestFailures >= LARGE_FILE_DIGEST_RETRY_LIMIT) {
stall(item, run)
return 'stop'
}
return 'retry'
}
if (apiError.code === 'FILE_003' && item.uploadId !== null) return reconcileState(item, run, item.uploadId, gen)
if (isNotFound(apiError) && item.uploadId !== null) {
if (run.file) {
// 서버가 올리기를 잊었다(마지막 조각 뒤 24시간 · 설정에서 지움) — 멈추고 알린다. [다시 시도]면 처음부터 새로 만든다
stall(item, run, LARGE_FILE_TEXT.restartNeeded)
run.restart = 'gone'
return 'stop'
}
item.phase = 'missing'
item.missingReason = 'unknown'
return 'stop'
}
if (apiError.code === 'FILE_006') {
stall(item, run, getErrorMessage('FILE_006'))
return 'stop'
}
if (!isRetryable(apiError)) {
stall(item, run)
return 'stop'
}
// 잠깐 끊겼다 — 받은 조각은 그대로, 받지 못한 조각부터 저절로 다시 (1분 넘게 못 하면 멈춘다)
return backoff(item, run, gen, true)
}
/**
* 409 FILE_003 — 서버의 지금 상태를 보고 잇는다 (계약 4절 "조각" · "끝내기").
* - 하려던 조각을 다시 보냈는데 또 409: 받은 조각과 내용이 다르다(올리는 사이 파일이 바뀜) — 이어 붙이면 다른 파일이 된다. 멈추고,
* [다시 시도]면 지금 파일로 처음부터 새로 만든다(앞 올리기는 지운다)
* - 하려던 조각을 서버가 이미 받았다: 같은 조각을 앞 요청이 먼저 마쳤거나 내용이 다르다 — 한 번 더 보내 200 인지 본다
* - 빠진 조각이 있다(끝내기가 거절됨): 바로 그 조각부터
* - 그대로다: 같은 조각 · 끝내기를 다른 요청이 하는 중 — 기다렸다가 다시 (16MiB 조각을 쉬지 않고 거듭 보내지 않게)
*/
async function reconcileState(
item: LargeUploadItem,
run: UploadRuntime,
uploadId: string,
gen: number,
): Promise<'retry' | 'stop'> {
if (run.verifyChunk !== null) {
run.verifyChunk = null
stall(item, run, getErrorMessage('FILE_003'))
run.restart = 'changed'
return 'stop'
}
// 하려던 일 — 빠진 첫 조각(없으면 끝내기). 고리는 늘 이것부터 한다
const [attempted] = missingChunksOf(run.received, run.chunkCount)
let state: LargeFile
try {
state = await api.getUpload(uploadId, { silent: true })
} catch (stateError) {
return handleFailure(item, run, stateError, gen)
}
if (!isActive(item.key, gen)) return 'stop'
applyServerState(item, run, state)
if (item.phase === 'ready') return 'stop'
if (attempted !== undefined && run.received.has(attempted)) {
run.verifyChunk = attempted
return 'retry'
}
const [next] = missingChunksOf(run.received, run.chunkCount)
return next !== attempted ? 'retry' : backoff(item, run, gen, false)
}
/**
* 저절로 다시 하기 전에 기다린다 — 2 · 5 · 15 · 30초, 이어서 1분 넘게 나아가지 못했으면 멈춘다([다시 시도]).
* reconnecting 이면 줄에 "다시 잇는 중"을 보인다 (끊김). 아니면 올리는 중 그대로 기다린다 (다른 요청이 같은 일을 하는 중)
*/
async function backoff(
item: LargeUploadItem,
run: UploadRuntime,
gen: number,
reconnecting: boolean,
): Promise<'retry' | 'stop'> {
const now = clock.now()
run.failingSince ??= now
if (now - run.failingSince >= LARGE_FILE_STALL_AFTER_MS) {
stall(item, run)
return 'stop'
}
if (reconnecting) item.phase = 'reconnecting'
item.sendingBytes = 0
resetEta(item, run)
const delays = LARGE_FILE_RETRY_DELAYS_MS
const delay = delays[Math.min(run.retryCount, delays.length - 1)] ?? 0
run.retryCount += 1
await sleep(run, delay)
return isActive(item.key, gen) ? 'retry' : 'stop'
}
/**
* 한 파일의 고리 — 만들기 전이면 만들고(자리 없이), 조각 자리를 가졌으면 다 올릴 때까지.
* 만든 뒤 자리가 없으면 끝내고 차례를 기다린다(pump 가 자리가 나면 다시 부른다). 멈추거나 빼거나 거절 · 멈춤이면 끝난다
*/
async function drive(key: number, gen: number): Promise<void> {
const run = runtimes.get(key)
if (!run) return
run.running = true
try {
while (isActive(key, gen)) {
const item = itemOf(key)
if (!item) return
try {
if (item.uploadId === null) {
await create(item, run, gen)
if (!run.slot) return
} else {
const next = run.verifyChunk ?? missingChunksOf(run.received, run.chunkCount)[0]
if (next === undefined) {
await complete(item, run, item.uploadId, gen)
return
}
await sendChunk(item, run, item.uploadId, next, gen)
}
// 이어졌다 — 다시 잇기 셈을 처음부터
run.failingSince = null
run.retryCount = 0
run.digestFailures = 0
if (item.phase === 'reconnecting') item.phase = 'uploading'
} catch (error) {
if ((await handleFailure(item, run, error, gen)) === 'stop') return
}
}
} finally {
run.running = false
run.slot = false
if (gen === generation) pump()
}
}
// ── 검사 상태 다시 묻기 ─────────────
function clearScanPoll(run: UploadRuntime) {
if (run.scanTimer !== null) clock.clearTimeout(run.scanTimer)
run.scanTimer = null
}
/** 다 올린 파일의 검사가 끝났는지 — 검사 중인 동안 간격을 늘리며 다시 묻는다 (알림 없이) */
function scheduleScanPoll(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || item.phase !== 'ready' || item.scanStatus !== 'pending' || item.uploadId === null) return
if (run.scanTimer !== null || run.scanPolls >= LARGE_FILE_SCAN_POLL_LIMIT) return
const delays = LARGE_FILE_SCAN_POLL_DELAYS_MS
const delay = delays[Math.min(run.scanPolls, delays.length - 1)] ?? 0
const gen = generation
const uploadId = item.uploadId
run.scanTimer = clock.setTimeout(() => {
run.scanTimer = null
run.scanPolls += 1
void api
.getUpload(uploadId, { silent: true })
.then((upload) => {
const current = itemOf(key)
if (gen !== generation || run.removed || !current || current.uploadId !== uploadId) return
if (current.phase === 'ready') current.scanStatus = upload.scanStatus
})
.catch((error: unknown) => {
const current = itemOf(key)
if (gen !== generation || run.removed || !current) return
if (isNotFound(toApiError(error))) {
current.phase = 'missing'
current.missingReason = 'unknown'
}
})
.finally(() => {
if (gen === generation && !run.removed) scheduleScanPoll(key)
})
}, delay)
}
// ── 바깥에서 부르는 일 ─────────────
/**
* 고른 파일을 링크로 올린다 — 고른 차례대로 검사한다(한 통의 개수 · 보관량은 앞 파일을 센다).
* @returns 새로 만든 줄 (거절된 줄 포함) — 화면이 "합계를 넘어 링크로" 안내를 잇는다
*/
function addFiles(files: Iterable<File>): LargeUploadItem[] {
const added: LargeUploadItem[] = []
for (const file of files) {
const problem = precheckLargeFile(file, limits(), carried.value.length)
const item = newItem(file.name, file.size, problem ? 'rejected' : 'uploading')
if (problem) {
const rejected = precheckText(problem)
item.errorText = rejected.text
item.errorLinksSettings = rejected.linksSettings
runtimes.set(item.key, { ...newRuntime(null), countsTowardSession: false })
} else {
runtimes.set(item.key, newRuntime(file))
}
items.value = [...items.value, item]
added.push(item)
}
pump()
// 줄은 반응형 배열 안의 것을 돌려준다 (화면이 key 로 찾는다)
return added.map((item) => itemOf(item.key) ?? item)
}
/** 저장본의 대용량 파일을 줄로 — 다 올린 것 · 올리다 멈춘 것(같은 파일을 다시 골라야 한다) · 없는 것 */
function addFromDraft(files: readonly DraftLargeFile[]): LargeUploadItem[] {
const added: LargeUploadItem[] = []
for (const file of files) {
if (file.status === 'missing') {
const item = newItem(file.name ?? LARGE_FILE_TEXT.unnamed, file.size ?? 0, 'missing')
item.uploadId = file.id
item.missingReason = file.reason
runtimes.set(item.key, { ...newRuntime(null), countsTowardSession: false })
added.push(item)
continue
}
const item = newItem(file.name, file.size, file.status === 'ready' ? 'ready' : 'waiting-file')
const run = { ...newRuntime(null), countsTowardSession: false }
run.chunkSize = file.chunkSize > 0 ? file.chunkSize : LARGE_FILE_CHUNK_SIZE
run.chunkCount = file.chunkCount > 0 ? file.chunkCount : chunkCountOf(file.size, run.chunkSize)
run.received = new Set(file.receivedChunks)
run.firstChunkSha256 = file.firstChunkSha256
run.lastModified = file.lastModified
item.uploadId = file.id
item.receivedBytes = receivedBytesOf(run.received, run.chunkSize, file.size)
if (file.status === 'ready') {
item.scanStatus = file.scanStatus
item.keptUntil = file.expiresAt
}
runtimes.set(item.key, run)
added.push(item)
}
items.value = [...items.value, ...added]
for (const item of added) scheduleScanPoll(item.key)
return added.map((item) => itemOf(item.key) ?? item)
}
/** [멈춤] — 그 파일만. 보내던 조각은 버린다(서버는 다 받은 조각만 적는다) */
function pause(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || (item.phase !== 'uploading' && item.phase !== 'reconnecting')) return
item.phase = 'paused'
item.sendingBytes = 0
item.waitingTurn = false
run.controller?.abort()
run.wake?.()
run.failingSince = null
run.retryCount = 0
resetEta(item, run)
// 차례를 기다리던 다른 파일이 자리를 받는다 (돌던 고리는 끝나며 자리를 돌려준다)
pump()
}
/**
* [이어 올리기] · [다시 시도] — 받은 조각부터 이어서.
* 서버가 잊었거나(404) 받은 조각과 내용이 달라(409) 멈춘 파일은 처음부터 새로 만든다 — 내용이 다른 앞 올리기는 지운다(보관량을 잡지 않게)
*/
function resume(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || run.file === null || (item.phase !== 'paused' && item.phase !== 'stalled')) return
if (run.restart !== null) {
const previous = item.uploadId
if (run.restart === 'changed' && previous) void api.deleteUpload(previous, { silent: true }).catch(() => undefined)
restartFromScratch(item, run)
}
item.phase = 'uploading'
item.errorText = null
run.failingSince = null
run.retryCount = 0
run.digestFailures = 0
resetEta(item, run)
pump()
}
/**
* [파일 고르기] — 저장본에서 이어 온 올리는 중 파일을 같은 파일로 다시 고른다 (계약 4절 "새로고침 · 창 닫힘 뒤 이어 올리기").
* 이름 · 크기 · 고친 시각이 다르면 줄 안에 알리고 기다린다. 같으면 맨 앞 조각 SHA-256 을 견줘 같으면 받은 조각부터 잇고,
* 다르거나(내용이 바뀜) 맨 앞 조각을 받기 전이면 새로 만든다 — 앞 올리기는 지운다
*/
async function repick(key: number, file: File): Promise<void> {
const item = itemOf(key)
const run = runtimes.get(key)
if (!item || !run || item.phase !== 'waiting-file') return
if (!looksLikeSameFile(file, { name: item.name, size: item.size, lastModified: run.lastModified })) {
item.repickMismatch = true
return
}
item.repickMismatch = false
const gen = generation
const expected = run.firstChunkSha256
const firstChunk = file.slice(0, Math.min(file.size, run.chunkSize))
const actual = expected === null ? null : await digest(firstChunk)
const current = itemOf(key)
if (gen !== generation || run.removed || !current || current.phase !== 'waiting-file') return
run.file = file
run.lastModified = file.lastModified
if (expected === null || actual === null || actual !== expected) {
// 이을 것이 없거나 내용이 다르다 — 앞 올리기를 지우고 이 파일로 새로 만든다
const previous = current.uploadId
if (previous) void api.deleteUpload(previous, { silent: true }).catch(() => undefined)
restartFromScratch(current, run)
current.restarted = false
}
current.phase = 'uploading'
resetEta(current, run)
pump()
}
/** ✕ (빼기) — 올리던 것을 멈추고 서버의 올리기 · 파일도 지운다(알림 없이 — 404 는 지운 것으로 본다) */
function remove(key: number) {
const item = itemOf(key)
const run = runtimes.get(key)
if (run) {
run.removed = true
run.controller?.abort()
run.wake?.()
clearScanPoll(run)
}
runtimes.delete(key)
items.value = items.value.filter((candidate) => candidate.key !== key)
if (item?.uploadId && item.phase !== 'missing') {
void api.deleteUpload(item.uploadId, { silent: true }).catch(() => undefined)
}
pump()
}
/** 실시간 알림의 위험 판정 — 그 파일이 이 화면에 있으면 "위험 — 보낼 수 없음" */
function markInfected(fileId: string) {
for (const item of items.value) {
if (item.uploadId !== fileId) continue
if (item.phase === 'ready') item.scanStatus = 'infected'
const run = runtimes.get(item.key)
if (run) clearScanPoll(run)
}
}
/**
* 보내기 · 저장이 MAIL_016 이었다 — 파일마다 상태를 다시 묻고(404 면 없는 파일) 쓸 수 없는 것을 보인다 (계약 4절 "보내기 전 확인").
* 묻지 못한 파일(장애)은 그대로 둔다
*/
async function recheck(): Promise<void> {
const gen = generation
const targets = items.value.filter(
(item) => item.uploadId !== null && item.phase !== 'missing' && item.phase !== 'rejected',
)
await Promise.all(
targets.map(async (target) => {
const uploadId = target.uploadId
if (uploadId === null) return
try {
const upload = await api.getUpload(uploadId, { silent: true })
const item = itemOf(target.key)
const run = runtimes.get(target.key)
if (gen !== generation || !item || !run || item.uploadId !== uploadId) return
if (upload.status === 'ready') {
applyServerState(item, run, upload)
} else if (item.phase === 'ready') {
// 다 올린 줄 알았는데 서버는 올리는 중이라 한다 — 고른 파일이 있으면 잇고, 없으면 다시 골라야 한다
applyServerState(item, run, upload)
item.phase = run.file ? 'uploading' : 'waiting-file'
}
} catch (error) {
const item = itemOf(target.key)
if (gen !== generation || !item || item.uploadId !== uploadId) return
if (isNotFound(toApiError(error))) {
item.phase = 'missing'
item.missingReason = 'unknown'
}
}
}),
)
pump()
}
function abortAll() {
runtimes.forEach((run) => {
run.removed = true
run.controller?.abort()
run.wake?.()
clearScanPoll(run)
})
runtimes.clear()
}
/** 양식을 새로 채웠다 — 올리던 것을 멈춘다 (서버의 올리기는 지우지 않는다 — 저장본이 이어 쓴다) */
function reset() {
generation += 1
abortAll()
items.value = []
}
onScopeDispose(() => {
generation += 1
abortAll()
})
// ── 화면 · 보내기 · 저장이 보는 셈 ─────────────
/** 올리는 중 · 다시 잇는 중인 파일 수(차례를 기다리는 파일 포함) — "파일 N개를 올리는 중입니다", 나가기 확인 */
const activeCount = computed(
() => items.value.filter((item) => item.phase === 'uploading' || item.phase === 'reconnecting').length,
)
const isUploading = computed(() => activeCount.value > 0)
/** 멈춤 · 오래 끊김 · 다시 골라야 이어지는 파일 */
const hasIncomplete = computed(() =>
items.value.some((item) => item.phase === 'paused' || item.phase === 'stalled' || item.phase === 'waiting-file'),
)
/** 지운 · 기간이 지난 · 찾을 수 없는 파일 — 남은 동안 저장하지 않는다 (서버가 MAIL_016 으로 거절한다) */
const hasMissing = computed(() => items.value.some((item) => item.phase === 'missing'))
const hasInfected = computed(() =>
items.value.some((item) => item.phase === 'ready' && item.scanStatus === 'infected'),
)
/** 발송 · 임시 저장의 largeFileIds — 서버 id 가 있는 실을 줄, 겹치지 않게 */
const uploadIds = computed(() => {
const ids: string[] = []
for (const item of carried.value) if (item.uploadId && !ids.includes(item.uploadId)) ids.push(item.uploadId)
return ids
})
/**
* 저장본이 가리키는 파일 — 실을 줄에 없는 파일(지움 · 기간 지남)까지. 쓰기 화면의 "바꿈" 판정이 쓴다:
* 없는 파일을 빼면 바뀐 것이라 다시 저장한다 (남은 동안은 저장하지 않는다)
*/
const trackedIds = computed(() => {
const ids: string[] = []
for (const item of items.value) {
if (item.phase !== 'rejected' && item.uploadId && !ids.includes(item.uploadId)) ids.push(item.uploadId)
}
return ids
})
return {
items,
carried,
usedBytes,
activeCount,
isUploading,
hasIncomplete,
hasMissing,
hasInfected,
uploadIds,
trackedIds,
precheck,
addFiles,
addFromDraft,
pause,
resume,
repick,
remove,
markInfected,
recheck,
reset,
}
}
+145
View File
@@ -0,0 +1,145 @@
import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import { startBrowserDownload } from './useMail'
import {
LARGE_FILE_CHUNK_CONTENT_TYPE,
LARGE_FILE_CHUNK_TIMEOUT_MS,
LARGE_FILE_DIGEST_HEADER,
LARGE_FILE_REQUEST_TIMEOUT_MS,
} from '@/shared/constants/large-file'
import type {
FileQuota,
FileUploadCreate,
LargeFile,
LargeFileListQuery,
ShareLink,
ShareLinkLookupResult,
} from '@/shared/types/large-file'
import type { PaginatedResult } from '@/shared/types/pagination'
import { contentDigestValue } from '@/shared/utils/large-file'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type LargeFileRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
export interface ChunkUploadOptions {
/** 조각 내용의 SHA-256 (base64) — 있으면 Content-Digest 로 보낸다 (서버가 받은 바이트와 견준다 — 다르면 400 FILE_004) */
sha256: string | null
signal?: AbortSignal
/** 이 조각에서 지금까지 올린 바이트 */
onProgress?: (loaded: number) => void
}
/** 경로에 넣는 id 는 항상 인코딩한다 */
const segment = (value: string) => encodeURIComponent(value)
// 대용량 첨부 API Composable — docs/mail/api-contract.md 4절 "대용량 첨부 — 11단계" (공개 내려받기 밖은 모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 중앙 axios(useApi)를 쓴다 — 401 이면 세션을 한 번 갱신하고 같은 요청을 다시 보낸다(조각도 같은 바이트로), silent 도 그대로 돈다.
// 로그인 없는 공개 화면의 요청은 usePublicShare 가 따로 보낸다 (세션 갱신 · 세션 만료 대화상자를 타지 않는다)
export function useLargeFiles() {
const api = useApi()
// ── 올리기 (계약 4절 "올리기") ─────────────
/** 올리기 만들기 — 형식(400 FILE_001) → 크기(413 FILE_002) → 직원 한도(409 FILE_005) → 서버 공간(507 FILE_006) 차례로 본다 */
function createUpload(body: FileUploadCreate, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.post<LargeFile, LargeFile>('/file-uploads', body, { timeout: LARGE_FILE_REQUEST_TIMEOUT_MS, ...options })
}
/** 상태 — 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 */
function getUpload(id: string, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.get<LargeFile, LargeFile>(`/file-uploads/${segment(id)}`, {
timeout: LARGE_FILE_REQUEST_TIMEOUT_MS,
...options,
})
}
/**
* 조각 하나 — 본문은 그 조각의 바이트 그대로(application/octet-stream). 알림은 끈다 — 실패는 그 파일 줄에 보인다 (계약 4절 "클라이언트").
* 이미 받은 조각을 같은 내용으로 다시 보내면 200, 다른 내용이면 409 FILE_003
*/
function putChunk(id: string, index: number, chunk: Blob, options: ChunkUploadOptions): Promise<LargeFile> {
const headers: Record<string, string> = { 'Content-Type': LARGE_FILE_CHUNK_CONTENT_TYPE }
if (options.sha256) headers[LARGE_FILE_DIGEST_HEADER] = contentDigestValue(options.sha256)
return api.put<LargeFile, LargeFile>(`/file-uploads/${segment(id)}/chunks/${index}`, chunk, {
timeout: LARGE_FILE_CHUNK_TIMEOUT_MS,
silent: true,
signal: options.signal,
headers,
onUploadProgress: (event) => options.onProgress?.(event.loaded),
})
}
/** 끝내기 — 조각이 모두 있으면 ready(검사 차례에 넣는다). 빠진 조각 · 진행 중이면 409 FILE_003, 이미 끝냈으면 그대로 200 */
function completeUpload(id: string, options?: LargeFileRequestOptions): Promise<LargeFile> {
return api.post<LargeFile, LargeFile>(`/file-uploads/${segment(id)}/completion`, null, {
timeout: LARGE_FILE_REQUEST_TIMEOUT_MS,
...options,
})
}
/** 그만두기 · 지우기 — 살아 있는 링크에 실린 파일은 409 FILE_003, 없으면 404 RES_001 */
function deleteUpload(id: string, options?: LargeFileRequestOptions): Promise<void> {
return api.delete<void, void>(`/file-uploads/${segment(id)}`, options)
}
/** 보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 포함), 만든 때 최근부터, total 포함 */
function listUploads(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise<PaginatedResult<LargeFile>> {
return api.get<PaginatedResult<LargeFile>, PaginatedResult<LargeFile>>('/file-uploads', { ...options, params: query })
}
/** 보관 사용량 · 한도 */
function getFileQuota(options?: LargeFileRequestOptions): Promise<FileQuota> {
return api.get<FileQuota, FileQuota>('/file-quota', options)
}
// ── 보낸 사람 관리 (계약 4절 "보낸 사람 관리") ─────────────
/** 내가 만든 링크 — 보낸 때 최근부터, total 포함 (끝나거나 끊긴 뒤 90일까지 나온다) */
function listShareLinks(query: LargeFileListQuery, options?: LargeFileRequestOptions): Promise<PaginatedResult<ShareLink>> {
return api.get<PaginatedResult<ShareLink>, PaginatedResult<ShareLink>>('/share-links', { ...options, params: query })
}
/** 링크 끊기 — 그 링크(메일)의 파일 모두. 되돌릴 수 없다. 이미 끊었거나 기간이 지났으면 그대로 200 */
function revokeShareLink(id: string, options?: LargeFileRequestOptions): Promise<ShareLink> {
return api.post<ShareLink, ShareLink>(`/share-links/${segment(id)}/revocation`, null, options)
}
/** 인용 속 링크의 상태 — 주소(토큰)를 쿼리에 싣지 않으려고 본문으로 보낸다 (1~20개 · 300자) */
function lookupShareLinks(urls: readonly string[], options?: LargeFileRequestOptions): Promise<ShareLinkLookupResult> {
return api.post<ShareLinkLookupResult, ShareLinkLookupResult>('/share-link-lookups', { urls }, options)
}
// ── 우리 웹메일에서 받기 (계약 4절 "우리 웹메일에서 읽기") ─────────────
/** 메일 속 대용량 첨부 주소 — 첨부처럼 <a href> 로 받는다 (표준 래퍼 없음) */
function messageLargeFileUrl(messageId: string, fileId: string): string {
return buildApiUrl(`/messages/${segment(messageId)}/large-files/${segment(fileId)}`)
}
/**
* 메일 속 대용량 첨부를 내려받는다 — 이동하기 전에 세션을 확인한다 (일반 첨부와 같다 — confirmSession).
* @returns 내려받기를 시작했으면 true. false 면 안내(세션 만료 대화상자 · 알림)는 이미 끝났다
*/
async function downloadMessageLargeFile(messageId: string, fileId: string): Promise<boolean> {
if (!(await confirmSession())) return false
startBrowserDownload(messageLargeFileUrl(messageId, fileId))
return true
}
return {
createUpload,
getUpload,
putChunk,
completeUpload,
deleteUpload,
listUploads,
getFileQuota,
listShareLinks,
revokeShareLink,
lookupShareLinks,
messageLargeFileUrl,
downloadMessageLargeFile,
}
}
+2 -1
View File
@@ -98,8 +98,9 @@ const segment = (value: string) => encodeURIComponent(value)
/**
* 화면을 떠나지 않고 브라우저 다운로드를 시작한다 — 임시 <a download> 를 눌러 원래 링크와 똑같이 받는다.
* (location.assign 으로 이동하면 서버가 오류 본문(JSON)을 줄 때 화면이 그 본문으로 바뀐다)
* 대용량 첨부 받기(useLargeFiles · 공개 내려받기 화면)도 같은 방법으로 받는다
*/
function startBrowserDownload(url: string): void {
export function startBrowserDownload(url: string): void {
const anchor = document.createElement('a')
anchor.href = url
// 값을 비워 두면 파일 이름은 서버의 Content-Disposition 을 따른다
+15 -1
View File
@@ -13,7 +13,7 @@ import {
MAIL_EVENTS_VISIBILITY_WAIT_MS,
} from '@/shared/constants/mail-notify'
import { supportsMailRealtime } from '@/shared/utils/browser-support'
import { parseMailEvent, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import { parseMailEvent, type LargeFileSignal, type MailServerEvent, type NewMailSignal } from '@/shared/utils/mail-events'
import {
mailEventsLockName,
packRealtimeMessage,
@@ -51,6 +51,8 @@ import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-rea
// - 75초 동안 아무것도 오지 않으면(머리글을 기다리는 동안 포함) 끊긴 것으로 보고 연결을 끊은 뒤 위처럼 다시 잇는다
// - new-mail 은 연결한 탭만 받는다 — onNewMail 이 바탕화면 알림을 정한다(사용자가 보고 있는 탭이 있는지 다른 탭에 묻는다)
// - 사용자가 어느 웹메일 탭이든 보게 되면(보이고 초점이 있음) 연결한 탭에서 onUserReturn — 띄운 바탕화면 알림을 닫는다
// - large-file(올린 대용량 파일의 위험 판정 — 11단계)은 연결한 탭이 받아 다른 탭에 나누고, 모든 탭이 onLargeFile 을 부른다
// (탭마다 알림 · 그 탭의 쓰기 화면이 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다)
//
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
@@ -68,6 +70,8 @@ export interface MailRealtimeOptions {
onNewMail?: (signal: NewMailSignal, context: NewMailContext) => unknown
/** 사용자가 웹메일 탭(어느 탭이든)을 보게 됐다 — 연결한 탭에서 부른다 (띄운 바탕화면 알림을 닫는다) */
onUserReturn?: () => void
/** 올린 대용량 파일의 판정(위험) — 모든 탭에서 부른다 (11단계 — 알림 · 쓰기 화면) */
onLargeFile?: (signal: LargeFileSignal) => void
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
clock?: Clock
/** 다시 잇는 간격의 흔들림 (0~1) — 테스트는 고정값을 넘긴다 */
@@ -254,6 +258,10 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
case 'attended':
if (leader) options.onUserReturn?.()
break
case 'large-file':
// 연결한 탭은 자기 연결에서 이미 받았다 (채널은 보낸 탭에 돌아오지 않는다)
if (!leader) options.onLargeFile?.({ fileId: message.fileId, name: message.name, scanStatus: message.scanStatus })
break
}
}
@@ -312,6 +320,12 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
.then(() => options.onNewMail?.({ count: event.count, messages: event.messages }, newMailContext))
.catch(() => undefined)
break
case 'large-file': {
const signal: LargeFileSignal = { fileId: event.fileId, name: event.name, scanStatus: event.scanStatus }
post({ type: 'large-file', ...signal })
options.onLargeFile?.(signal)
break
}
}
}
@@ -0,0 +1,91 @@
import { buildApiUrl, type StandardResponse } from './useApi'
import { startBrowserDownload } from './useMail'
import { SHARE_TOKEN_PATTERN } from '@/shared/constants/large-file'
import type { PublicLink } from '@/shared/types/large-file'
// 공개 내려받기 화면의 요청 — 계약 4절 "공개 내려받기 (로그인 없음)"
// 로그인 · 쿠키를 쓰지 않는다: 중앙 axios(세션 갱신 · 세션 만료 대화상자 · 알림)를 거치지 않고 fetch 를 쿠키 없이(credentials: omit) 부른다.
// 받는 쪽은 바깥 거래처의 브라우저다 — 실패는 화면이 상태로 그린다(알림 없음). 파일은 <a href> 이동으로 받는다(큰 파일을 fetch 로 받으면 메모리에 다 오른다).
export type PublicShareFetch = (input: string, init: RequestInit) => Promise<Response>
/** 링크 정보 결과 — 받음 · 없는 링크(404) · 요청이 너무 많음(429) · 그 밖의 실패(네트워크 · 5xx · 모양이 틀림) */
export type PublicLinkResult =
| { kind: 'ok'; link: PublicLink }
| { kind: 'not-found' }
| { kind: 'too-many' }
| { kind: 'error' }
export interface PublicShareOptions {
/** 테스트는 가짜 서버를 넘긴다 */
fetch?: PublicShareFetch
}
const HTTP_NOT_FOUND = 404
const HTTP_TOO_MANY_REQUESTS = 429
const LINK_STATUSES: readonly string[] = ['active', 'expired', 'revoked']
const defaultFetch: PublicShareFetch = (input, init) => fetch(input, init)
/** 응답이 화면이 그릴 수 있는 모양인가 — 앞단이 다른 것을 돌려줬을 때(HTML 등) 그리다 깨지지 않게 */
function isPublicLink(value: unknown): value is PublicLink {
if (typeof value !== 'object' || value === null) return false
const link = value as Record<string, unknown>
const sender = link.sender as Record<string, unknown> | null | undefined
return (
typeof link.status === 'string' &&
LINK_STATUSES.includes(link.status) &&
typeof sender === 'object' &&
sender !== null &&
typeof sender.email === 'string' &&
typeof link.expiresAt === 'string' &&
Array.isArray(link.files) &&
typeof link.reportAddress === 'string'
)
}
/** 경로의 토큰 — 늘 인코딩한다 (모양 검사를 지난 값이라 바뀌지 않는다) */
const segment = (value: string) => encodeURIComponent(value)
export function usePublicShare(options: PublicShareOptions = {}) {
const fetchLink = options.fetch ?? defaultFetch
/** 링크 정보 — 토큰 모양이 아니면 묻지 않고 없는 링크다 (서버도 그렇게 답한다) */
async function getPublicLink(token: string): Promise<PublicLinkResult> {
if (!SHARE_TOKEN_PATTERN.test(token)) return { kind: 'not-found' }
let response: Response
try {
response = await fetchLink(buildApiUrl(`/public-links/${segment(token)}`), {
method: 'GET',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: { Accept: 'application/json' },
})
} catch {
return { kind: 'error' }
}
if (response.status === HTTP_NOT_FOUND) return { kind: 'not-found' }
if (response.status === HTTP_TOO_MANY_REQUESTS) return { kind: 'too-many' }
if (!response.ok) return { kind: 'error' }
try {
const body = (await response.json()) as StandardResponse<unknown> | null
const link = body?.success ? body.data : null
return isPublicLink(link) ? { kind: 'ok', link } : { kind: 'error' }
} catch {
return { kind: 'error' }
}
}
/** 파일 주소 — 받기 단추의 href (링크 주소 복사 같은 브라우저 기본 기능을 지킨다) */
function publicFileUrl(token: string, fileId: string): string {
return buildApiUrl(`/public-links/${segment(token)}/files/${segment(fileId)}`)
}
/** 받기 — 늘 저장이다(서버가 octet-stream + attachment). 끊기면 브라우저가 범위로 이어 받는다 */
function downloadPublicFile(token: string, fileId: string): void {
startBrowserDownload(publicFileUrl(token, fileId))
}
return { getPublicLink, publicFileUrl, downloadPublicFile }
}
@@ -444,7 +444,9 @@ describe('ComposePage — 임시 저장 · 서명 (2단계)', () => {
expect(requests.some((request) => request.method === 'POST' && request.url === '/messages')).toBe(false)
expect(wrapper.get('.compose__send').text()).toBe('보내기')
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
// 꺼진 단추는 aria-disabled (11단계 — 초점은 받는다) · 까닭은 단추 옆 글
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
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([])
@@ -159,7 +159,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
)
const alert = view.get('.compose__editor [role="alert"]')
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
expect(view.get('.compose__send').attributes('aria-disabled')).toBe('true')
// 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
expect(alert.attributes('initial-html')).toBeUndefined()
expect(loadErrors).toHaveLength(1)
@@ -172,7 +172,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
expect(loader.calls).toBe(2)
expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
expect(view.get('.compose__send').attributes('aria-disabled')).toBeUndefined()
// 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
await vi.waitFor(() => {
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
@@ -0,0 +1,470 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { LARGE_FILE_TEXT, SEND_BLOCK_TEXT } from '@/shared/constants/large-file'
import type { DraftLargeFile, FileQuota, LargeFile, MessageLargeFile } from '@/shared/types/large-file'
import type { Draft, MessageDetail } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
// 쓰기 화면의 대용량 첨부 (11단계 — 디자인 원본 ComposeLargeFiles · 계약 4절 "발송 · 임시 저장과 함께" · "인용 속 링크의 상태")
// 조각 올리기 자체는 useLargeFileUploads.spec.ts 가 본다 — 여기서는 화면이 잇는 것: 저장본의 파일 줄 · 보내기를 막는 까닭 ·
// 보내기의 MAIL_016 뒤 다시 묻기 · 전달의 인용 속 링크 상태 · 「링크로 보내기」 · 인용 경계(덩이 자리)를 싣는 때와 싣지 않는 때
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec.ts 와 같다 */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
const MB = 1024 * 1024
const GB = 1024 * MB
const CHUNK = 16 * MB
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
const NOW = Date.parse('2026-10-30T05:15:00Z')
const SHARE = 'https://files.arasystem.it.kr/s/'
const URL1 = `${SHARE}Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x`
const URL2 = `${SHARE}Ab3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5bC7dE9f`
const QUOTA: FileQuota = {
usedBytes: 2 * GB,
limitBytes: 10 * GB,
maxFileBytes: 2047 * MB,
maxFilesPerMessage: 10,
chunkSize: CHUNK,
retentionDays: 30,
}
const range = (count: number) => Array.from({ length: count }, (_, index) => index)
/** 다 올린 파일 — 1.2 GB, 깨끗함 */
const READY: LargeFile = {
id: 'lf-1',
name: '설계도_v3.dwg',
size: 1_288_490_189,
status: 'ready',
chunkSize: CHUNK,
chunkCount: 77,
receivedChunks: range(77),
firstChunkSha256: 'sha-first',
scanStatus: 'clean',
createdAt: '2026-10-26T01:00:00Z',
expiresAt: '2026-11-25T14:59:59.999Z',
lastModified: 1_700_000_000_000,
}
/** 올리다 멈춘 파일 — 612 MB 가운데 조각 10개 */
const STOPPED: LargeFile = {
...READY,
id: 'lf-2',
name: '현장영상_1029.mp4',
size: 612 * MB,
status: 'uploading',
chunkCount: 39,
receivedChunks: range(10),
scanStatus: null,
expiresAt: '2026-10-31T05:00:00Z',
}
const DELETED: DraftLargeFile = { id: 'lf-3', status: 'missing', reason: 'deleted', name: '시공도면_v4.dwg', size: 620 * MB }
/**
* 저장본 · 보내기 취소로 되돌린 초안의 답장 본문 — 서버 정화가 data-* 를 지워 인용 표시(data-mail-quote · data-quote-header)가 없다.
* 남은 것은 쓰기 화면이 입힌 인라인 style 과 "원본 메일" 머리 문단 + 머리 표뿐이다 (리뷰 M1)
*/
const SANITIZED_REPLY_HTML =
'<div style="font-family: \'Malgun Gothic\', sans-serif; font-size: 14px">' +
'<p style="margin: 0">확인했습니다.</p>' +
'<p style="margin: 0"><br></p>' +
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">' +
'<p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>' +
'<table style="margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6"><tbody>' +
'<tr><td style="padding: 0 12px 0 0; color: #667085">보낸 사람</td><td style="padding: 0">이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</td></tr>' +
'</tbody></table>' +
'<p style="margin: 0">김아라님, 안녕하세요.</p>' +
'</blockquote></div>'
function draftWith(largeFiles: DraftLargeFile[]): Draft {
return {
id: 'd9',
to: [{ name: '박준호', email: 'junho.park@arasystem.it.kr' }],
cc: [],
bcc: [],
subject: '11월 현장 점검 자료',
body: '자료 보냅니다.',
bodyHtml: '<div><p>자료 보냅니다.</p></div>',
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-30T05:00:00Z',
largeFiles,
}
}
function sharedFile(changes: Partial<MessageLargeFile>): MessageLargeFile {
return {
linkId: 'link1',
fileId: 'f1',
name: '설계도_v3.dwg',
size: READY.size,
status: 'available',
expiresAt: '2026-11-29T14:59:59.999Z',
revokedAt: null,
url: URL1,
downloads: null,
...changes,
}
}
/** 원래 메일 — 서버가 첨부처럼 알아본 덩이(URL1, 파일 둘)와 인용 속 옛 링크(URL2) */
const ORIGINAL: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: '11월 현장 점검 자료',
preview: '',
receivedAt: '2026-10-30T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: true,
size: 100,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: '<m1@arasystem.it.kr>',
references: [],
htmlBody: `<p>자료입니다.</p><div data-ara-large-files=""><a href="${URL1}">${URL1}</a></div><blockquote><a href="${URL2}">지난 자료</a></blockquote>`,
textBody: '자료입니다.',
hasBlockedImages: false,
attachments: [{ id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 3 * MB }],
largeFiles: [sharedFile({}), sharedFile({ fileId: 'f2', name: '현장영상_1029.mp4', size: 612 * MB })],
}
describe('ComposePage — 대용량 첨부', () => {
let pinia: Pinia
const mounted: VueWrapper[] = []
beforeAll(installProseMirrorDom)
// 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간이 첫 테스트의 시간 한도에 잡히지 않게
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
document.body.innerHTML = ''
})
/** 테스트마다 먼저 답하는 요청 — null 이면 server 가 답한다 */
type ExtraHandler = (request: MockRequest) => MockResult | null | Promise<MockResult | null>
/** 화면이 늘 부르는 것 — 보관량 · 저장본 · 원본 메일. extra 가 먼저 답한다 */
function server(options: { draft?: Draft; extra?: ExtraHandler } = {}): MockHandler {
return async (request) => {
const extra = await options.extra?.(request)
if (extra) return extra
const { method, url } = request
if (method === 'GET' && url === '/file-quota') return ok(QUOTA)
if (method === 'GET' && url === '/drafts/d9' && options.draft) return ok(options.draft)
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
if (method === 'DELETE') return { status: 204 }
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
}
}
async function mountCompose(path: string, handler: MockHandler) {
const requests = installMockApi(handler)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: document.body,
global: {
plugins: [pinia, router],
provide: { ...routeViewProvide(router), [CLOCK as symbol]: createManualClock(NOW) },
},
})
mounted.push(wrapper)
await flushPromises()
await vi.waitFor(
() => {
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
return { wrapper, requests }
}
const phases = (wrapper: VueWrapper) =>
wrapper.findAll('[data-large-upload]').map((row) => row.attributes('data-phase'))
const lineOf = (wrapper: VueWrapper, phase: string) =>
wrapper.get(`[data-phase="${phase}"] .large-upload__line-text`).text()
const calls = (requests: MockRequest[]) => requests.map((request) => `${request.method} ${request.url}`)
it('저장본의 대용량 파일 — 다 올린 것 · 올리다 멈춘 것 · 지운 것을 줄로, 빼야 할 것이 남은 동안 [보내기]는 까닭과 함께 막힌다', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?draft=d9',
server({ draft: draftWith([READY, STOPPED, DELETED]) }),
)
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(wrapper.find('[data-phase="waiting-file"] [data-large-repick]').exists()).toBe(true)
expect(lineOf(wrapper, 'missing')).toBe('설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.')
const send = () => wrapper.get('.compose__send')
expect(send().attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.unsendable)
// 지운 파일을 빼면 — 서버에 지울 것이 없다. 남은 까닭은 다 올리지 못한 파일
await wrapper.get('[data-phase="missing"] [data-large-remove]').trigger('click')
await flushPromises()
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.incomplete)
expect(calls(requests)).not.toContain('DELETE /file-uploads/lf-3')
// 멈춘 파일을 빼면 서버의 올리기도 지운다 — 막을 까닭이 없어진다
await wrapper.get('[data-phase="waiting-file"] [data-large-remove]').trigger('click')
await flushPromises()
expect(calls(requests)).toContain('DELETE /file-uploads/lf-2')
expect(phases(wrapper)).toEqual(['ready'])
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
expect(send().attributes('aria-disabled')).toBeUndefined()
})
it('보내기가 MAIL_016 이면 파일마다 다시 묻고, 찾을 수 없는 파일은 빨간 줄 · [보내기]를 막는다 (알림 없음)', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?draft=d9',
server({
draft: draftWith([READY]),
extra: ({ method, url }) => {
if (method === 'POST' && url === '/messages') return fail(400, 'MAIL_016')
if (method === 'GET' && url === '/file-uploads/lf-1') return fail(404, 'RES_001')
return null
},
}),
)
await wrapper.get('form').trigger('submit')
await flushPromises()
await flushPromises()
const sent = requests.find((request) => request.method === 'POST' && request.url === '/messages')
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(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([])
})
it('전달 — 인용 속 원래 대용량 첨부 링크의 상태를 묻고 첨부 칸 아래 글자로만 알린다 · 원본 첨부에는 「링크로 보내기」가 없다', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?forward=m1',
server({
extra: ({ method, url }) =>
method === 'POST' && url === '/share-link-lookups'
? ok({
links: [
{ url: URL1, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' },
{ url: URL2, status: 'revoked', expiresAt: '2026-10-29T14:59:59.999Z' },
],
})
: null,
}),
)
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일까지',
'인용 속 대용량 첨부 링크는 공유가 멈춰 받을 수 없습니다.',
])
// 원래 메일의 대용량 첨부는 다시 싣지 않는다 — 링크 묶음이 없다
expect(wrapper.find('[data-large-upload]').exists()).toBe(false)
expect(wrapper.find('[data-send-as-link]').exists()).toBe(false)
expect(useToastStore().toasts).toEqual([])
})
it('「링크로 보내기」 — 이 화면에서 고른 파일을 메일에서 빼고 링크로 올린다 · 올리는 동안 [보내기]는 까닭과 함께 막힌다', async () => {
let releaseChunk: () => void = () => undefined
const chunkGate = new Promise<void>((resolve) => {
releaseChunk = resolve
})
const large = (status: 'uploading' | 'ready', received: number[]): LargeFile => ({
...READY,
id: 'lf-9',
name: '회의록.pdf',
size: 2 * MB,
status,
chunkCount: 1,
receivedChunks: received,
firstChunkSha256: null,
scanStatus: status === 'ready' ? 'pending' : null,
})
const { wrapper, requests } = await mountCompose(
'/mail/compose',
server({
extra: async ({ method, url }) => {
if (method === 'POST' && url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
}
if (method === 'POST' && url === '/file-uploads') return ok(large('uploading', []), 201)
if (method === 'PUT' && url === '/file-uploads/lf-9/chunks/0') {
await chunkGate
return ok(large('uploading', [0]))
}
if (method === 'POST' && url === '/file-uploads/lf-9/completion') return ok(large('ready', [0]))
return null
},
}),
)
const picked = new File(['x'], '회의록.pdf', { lastModified: 0 })
Object.defineProperty(picked, 'size', { value: 2 * MB })
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [picked], configurable: true })
await picker.trigger('change')
await flushPromises()
expect(wrapper.findAll('.upload-item')).toHaveLength(1)
await wrapper.get('[data-send-as-link]').trigger('click')
await flushPromises()
expect(wrapper.find('.upload-item').exists()).toBe(false)
expect(phases(wrapper)).toEqual(['uploading'])
expect(calls(requests)).toContain('POST /file-uploads')
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('#compose-send-why').text()).toBe(SEND_BLOCK_TEXT.uploading('1'))
releaseChunk()
await flushPromises()
await flushPromises()
expect(phases(wrapper)).toEqual(['ready'])
expect(lineOf(wrapper, 'ready')).toBe('2 MB · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다')
expect(wrapper.find('#compose-send-why').exists()).toBe(false)
})
it('표시 없는 저장본 HTML 로 연 답장도 원문 인용을 알아본다 — 대용량 파일을 실으면 경계 표시와 bodyQuoteStart 를 보낸다 (리뷰 M1)', async () => {
const draft: Draft = { ...draftWith([READY]), inReplyTo: 'm1', subject: 'Re: 11월 현장 점검 자료', bodyHtml: SANITIZED_REPLY_HTML }
const { wrapper, requests } = await mountCompose(
'/mail/compose?draft=d9',
server({
draft,
extra: ({ method, url }) => (method === 'POST' && url === '/messages' ? ok({ id: 'sent-1' }, 201) : null),
}),
)
await wrapper.get('form').trigger('submit')
await flushPromises()
const payload = requests.find((request) => request.method === 'POST' && request.url === '/messages')
?.data as Record<string, unknown> | undefined
expect(payload?.largeFileIds).toEqual(['lf-1'])
const bodyHtml = String(payload?.bodyHtml)
// 표시는 하나, 원문 인용 바로 앞 — 서버가 그 자리에 덩이를 넣는다
expect(bodyHtml.split('data-ara-quote-start').length - 1).toBe(1)
expect(bodyHtml).toContain('<div data-ara-quote-start=""></div><blockquote style="margin: 24px 0 0; ')
// 텍스트의 자리는 인용 첫 줄의 처음 ("> 원본 메일")
const body = String(payload?.body)
const quoteStart = payload?.bodyQuoteStart
expect(typeof quoteStart).toBe('number')
expect(body.slice(Number(quoteStart)).startsWith('> 원본 메일')).toBe(true)
expect(body.slice(0, Number(quoteStart))).toContain('확인했습니다.')
})
it('대용량 파일이 없는 답장 — 보내기 · 임시 저장 본문에 경계 표시 · largeFileIds · bodyQuoteStart 가 없다', async () => {
const { wrapper, requests } = await mountCompose(
'/mail/compose?replyTo=m1',
server({
extra: ({ method, url }) => {
if (method === 'POST' && url === '/share-link-lookups') return ok({ links: [] })
if (method === 'POST' && url === '/drafts') return ok({ id: 'd1', attachments: [], updatedAt: '2026-10-30T05:16:00Z' }, 201)
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
return null
},
}),
)
await wrapper.get('#compose-save-draft').trigger('click')
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
for (const url of ['/drafts', '/messages']) {
const payload = requests.find((request) => request.method === 'POST' && request.url === url)?.data as
| Record<string, unknown>
| undefined
expect(payload, url).toBeDefined()
expect(payload, url).not.toHaveProperty('largeFileIds')
expect(payload, url).not.toHaveProperty('bodyQuoteStart')
const bodyHtml = String(payload?.bodyHtml)
expect(bodyHtml, url).not.toContain('data-ara-quote-start')
expect(bodyHtml, url).not.toContain('data-')
// 원문 인용은 그대로 — 표시만 없다
expect(bodyHtml, url).toContain('<blockquote style="margin: 24px 0 0; ')
}
})
it('「링크로 보내기」는 링크로 올리기 전 검사를 먼저 본다 — 보관 한도에 걸리면 메일에 담은 채 두고 까닭을 알린다 (리뷰 L3)', async () => {
const nearlyFull: FileQuota = { ...QUOTA, usedBytes: QUOTA.limitBytes - MB }
const { wrapper, requests } = await mountCompose(
'/mail/compose',
server({
extra: ({ method, url }) => {
if (method === 'GET' && url === '/file-quota') return ok(nearlyFull)
if (method === 'POST' && url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
}
return null
},
}),
)
const picked = new File(['x'], '회의록.pdf', { lastModified: 0 })
Object.defineProperty(picked, 'size', { value: 2 * MB })
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [picked], configurable: true })
await picker.trigger('change')
await flushPromises()
await wrapper.get('[data-send-as-link]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.upload-item')).toHaveLength(1)
expect(wrapper.find('[data-large-upload]').exists()).toBe(false)
expect(calls(requests)).not.toContain('POST /file-uploads')
const [toast] = useToastStore().toasts
expect(toast?.message).toBe(LARGE_FILE_TEXT.overQuota('10GB'))
expect(toast?.action).toMatchObject({ label: LARGE_FILE_TEXT.quotaLink, to: { name: 'settings-mail-large-files' } })
})
})
+33 -10
View File
@@ -469,13 +469,13 @@ describe('ComposePage', () => {
expect(item.classes()).toContain('upload-item--failed')
expect(item.get('.upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_004)
expect(useToastStore().toasts).toEqual([])
expect(wrapper.get('.compose__send').attributes('disabled')).toBeDefined()
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBe('true')
const removeButton = wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]')
expect(document.activeElement).toBe(removeButton.element)
// 찾지 못한 첨부를 빼면 다시 보낼 수 있다
await removeButton.trigger('click')
expect(wrapper.get('.compose__send').attributes('disabled')).toBeUndefined()
expect(wrapper.get('.compose__send').attributes('aria-disabled')).toBeUndefined()
expect(requests.filter((request) => request.url === '/messages' && request.method === 'POST')).toHaveLength(1)
wrapper.unmount()
})
@@ -538,7 +538,7 @@ describe('ComposePage', () => {
const { wrapper } = await mountCompose('/mail/compose', replyApi)
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 문서, 이미지, 압축 파일',
'파일당 최대 10MB · 합계 30MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
)
})
@@ -547,11 +547,11 @@ describe('ComposePage', () => {
expect(useMailStore().quota).toBeNull()
expect(wrapper.get('.compose__attach-hint').text()).toBe(
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 문서, 이미지, 압축 파일',
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
)
})
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 붙일 때 막는다 — 전달 원본도 세고, 넘는 파일은 올리지 않고 빼고 보낸다', async () => {
it('첨부 합계는 서버 한도(attachmentTotalMaxBytes)로 센다 — 전달 원본도 세고, 넘게 만드는 파일은 링크로 보낸다 (11단계)', async () => {
const MB = 1024 * 1024
useMailStore().quota = {
usedBytes: 0,
@@ -560,21 +560,39 @@ describe('ComposePage', () => {
attachmentTotalMaxBytes: 15 * MB,
}
const PHOTOS = { id: 'att1', name: '현장사진.zip', type: 'application/zip', size: 12 * MB }
/** 대용량 파일 — 한 조각(16MiB)이라 조각 하나 뒤 끝낸다 */
const largeFile = (status: 'uploading' | 'ready', received: number[]) => ({
id: 'lf1',
name: '도면.pdf',
size: 5 * MB,
status,
chunkSize: 16 * MB,
chunkCount: 1,
receivedChunks: received,
firstChunkSha256: null,
scanStatus: status === 'ready' ? 'unscannable' : null,
createdAt: '2026-10-30T05:00:00Z',
expiresAt: '2026-10-31T05:00:00Z',
lastModified: 0,
})
const { wrapper, requests } = await mountCompose('/mail/compose?forward=m1', ({ method, url }) => {
if (method === 'GET' && url === '/messages/m1') return ok({ ...ORIGINAL, attachments: [PHOTOS] })
if (method === 'POST' && url === '/mail-attachments') {
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 2 * MB }, 201)
}
if (method === 'POST' && url === '/file-uploads') return ok(largeFile('uploading', []), 201)
if (method === 'PUT' && url === '/file-uploads/lf1/chunks/0') return ok(largeFile('uploading', [0]))
if (method === 'POST' && url === '/file-uploads/lf1/completion') return ok(largeFile('ready', [0]))
if (method === 'POST' && url === '/messages') return ok({ id: 'sent-1' }, 201)
return ok({})
})
const sized = (name: string, size: number) => {
const chosen = new File(['x'], name)
const chosen = new File(['x'], name, { lastModified: 0 })
Object.defineProperty(chosen, 'size', { value: size })
return chosen
}
// 원본 12MB + 회의록 2MB = 14MB 는 통과, 도면 5MB 를 더하면 19MB 라 막힌다
// 원본 12MB + 회의록 2MB = 14MB 는 메일에 담고, 도면 5MB 를 더하면 19MB 라 링크로 보낸다
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', {
value: [sized('회의록.pdf', 2 * MB), sized('도면.pdf', 5 * MB)],
@@ -582,14 +600,15 @@ describe('ComposePage', () => {
})
await picker.trigger('change')
await flushPromises()
await flushPromises()
expect(wrapper.findAll('.upload-item').map((item) => item.classes())).toEqual([
expect.arrayContaining(['upload-item--done']),
expect.arrayContaining(['upload-item--done']),
expect.arrayContaining(['upload-item--failed']),
])
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(
ERROR_CODE_MESSAGES.FILE_002,
expect(wrapper.get('[data-large-upload]').text()).toContain('도면.pdf')
expect(wrapper.get('[data-large-switch]').text()).toBe(
'‘도면.pdf’(5 MB)는 메일에 담으면 합계가 15MB 를 넘어 링크로 보냅니다.',
)
expect(requests.filter((request) => request.url === '/mail-attachments')).toHaveLength(1)
expect(useToastStore().toasts).toEqual([])
@@ -605,6 +624,10 @@ describe('ComposePage', () => {
{ id: 'att1', name: '현장사진.zip' },
{ id: 'up1', name: '회의록.pdf' },
])
expect(payload?.largeFileIds).toEqual(['lf1'])
// 덩이 자리 — 원문 인용 바로 앞의 표시와 텍스트의 인용 시작 자리
expect(payload?.bodyHtml).toContain('<div data-ara-quote-start=""></div><blockquote')
expect(typeof payload?.bodyQuoteStart).toBe('number')
})
it('보내기 버튼은 submit 이고, 제목에서 Enter 를 눌러도 보내지 않는다', async () => {
+119 -48
View File
@@ -40,6 +40,15 @@
// [예약]은 보내기와 같은 흐름(임시 저장 기다리기 · 길이 검사 · 같은 발송 키)에 sendAt 을 더해 보낸다 → "10월 30일 (금) 오전 9:00 에 보냅니다." + [예약됨 보기].
// 서버가 그 시각을 받지 않으면(VAL_001 — 그사이 5분 안이 됐거나 시계가 어긋남) 알림 대신 예약 창을 다시 열고 고르기 아래에 까닭
// ("지금부터 5분 뒤 이후의 시각을 고르세요." · "7일 안의 시각만 고를 수 있습니다." — 이 화면의 검사로 고른다, schedule-send.ts 의 rejectedScheduleProblem)
// 대용량 첨부 (11단계 — 계약 4절 "대용량 첨부" · 디자인 README "11단계 › 쓰기 화면 — 첨부 칸") — 잇는 일은 useComposeLargeFiles:
// - 고른 파일이 20MB 를 넘거나, 메일에 담은 파일의 합계를 25MB 넘게 만들거나, 메일에 담지 못하는 형식(mp4 · dwg 등)이면 저절로 링크로 보낸다.
// 이 화면에서 고른 일반 첨부는 「링크로 보내기」로 옮길 수 있다(링크로 올리기 전 검사를 통과할 때만).
// - 링크 파일은 조각으로 올린다(useLargeFileUploads). 올리는 동안 · 다 올리지 못한 파일 · 빼야 하는 파일이 있으면 [보내기]를 aria-disabled 로 막고
// 단추 옆에 까닭(sendBlock). 임시 저장은 그대로 된다 — 올리는 중 · 차례를 기다리는 파일도 저장본에 실린다(새로고침 뒤 같은 파일을 고르면 잇는다).
// 지운 · 기간이 지난 파일이 남은 동안은 저장하지 않는다(서버가 MAIL_016). 보내기 · 저장이 MAIL_016 이면 파일마다 상태를 다시 묻는다.
// - 보낼 때 largeFileIds 와 덩이 자리(서식 본문의 인용 경계 표시 · bodyQuoteStart)를 싣는다 — 대용량 파일이 없으면 표시를 지우고 보낸다(bodyFields).
// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 그만큼 텍스트 한도를 남긴다(sendReserve).
// - 나가기 확인은 올리는 중이면 "올리는 중인 파일이 있습니다". 답장 · 전달의 인용 속 원래 링크는 상태만 물어 칸 아래 글자로 알린다(share-links.ts).
// 고정 id — 화면 검증 · 테스트가 쓴다: 임시 저장 #compose-save-draft · 상태 #compose-draft-status(ComposeDraftStatus) ·
// 휴지통 #compose-discard · 닫기 .compose__close
import {
@@ -56,7 +65,7 @@ import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import ComposeAlert from '@/components/mail/ComposeAlert.vue'
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
import ComposeAttachmentGroups from '@/components/mail/ComposeAttachmentGroups.vue'
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
import ComposeFooter from '@/components/mail/ComposeFooter.vue'
import ComposeHeader from '@/components/mail/ComposeHeader.vue'
@@ -73,6 +82,7 @@ import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useAp
import { useAttachmentUploads, type UploadItem } from '@/composables/useAttachmentUploads'
import { useClock } from '@/composables/useClock'
import { useComposeDraftLink } from '@/composables/useComposeDraftLink'
import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles'
import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { useDraftTrash } from '@/composables/useDraftTrash'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
@@ -81,12 +91,11 @@ import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/us
import { useScheduledMessageActions } from '@/composables/useScheduledMessageActions'
import { useToast } from '@/composables/useToast'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import { COMPOSE_ATTACHMENT_ACCEPT } from '@/shared/constants/large-file'
import {
COMPOSE_LOADING_TITLE,
COMPOSE_TITLES,
DRAFT_LEAVE_SAVE_WAIT_MS,
MAIL_ATTACHMENT_ACCEPT,
MAIL_MAX_ATTACHMENTS,
MAIL_MAX_RECIPIENTS,
MAIL_PARTIAL_SEND_TEXT,
MAIL_SENT_ACTION_LABEL,
@@ -119,7 +128,6 @@ import {
tokenFromAddress,
type RecipientToken,
} from '@/shared/utils/mail-address'
import { attachmentHint } from '@/shared/utils/mail-attachment'
import { buildForwardDraft, isForwardSubject } from '@/shared/utils/mail-forward'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { buildReplyDraft } from '@/shared/utils/mail-reply'
@@ -171,22 +179,46 @@ const draftTrash = useDraftTrash()
const scheduledActions = useScheduledMessageActions()
const toast = useToast()
const clock = useClock()
const {
items: uploadItems,
isUploading,
hasMissing,
addFiles,
addExisting,
applySavedAttachments,
markMissing,
remove: removeUpload,
reset: resetUploads,
} = useAttachmentUploads({
const normalUploads = useAttachmentUploads({
// 한도는 서버 값(GET /mail-quota 의 파일당 attachmentMaxBytes · 합계 attachmentTotalMaxBytes)
// — 못 받았으면 스토어가 대체값을 준다
maxBytes: () => mailStore.attachmentMaxBytes,
totalMaxBytes: () => mailStore.attachmentTotalMaxBytes,
})
const {
items: uploadItems,
isUploading,
hasMissing,
addExisting,
applySavedAttachments,
markMissing,
remove: removeUpload,
reset: resetUploads,
} = normalUploads
/** 대용량 첨부(링크 — 11단계) — 고른 파일 가르기 · 칸 아래 글 · 보내기를 막는 까닭 · 본문 필드 (useComposeLargeFiles) */
const {
uploads: largeUploads,
quotedNotices,
hint: attachmentHintText,
normalTotalText,
linkGroupHint,
quotaWarning: largeQuotaWarning,
switchNotice,
sendBlock,
bodyFields,
sendReserve,
attachFiles,
isLinkable,
sendAsLink,
lookupQuotedLinks,
reset: resetLargeFiles,
} = useComposeLargeFiles({ clock, normal: normalUploads })
const {
items: largeItems,
isUploading: largeUploading,
hasMissing: hasMissingLarge,
trackedIds: trackedLargeFileIds,
} = largeUploads
const to = ref<RecipientToken[]>([])
const cc = ref<RecipientToken[]>([])
@@ -254,8 +286,11 @@ let formGeneration = 0
* 보내기를 누르고 바로 떠나도 진행 중이던 저장을 기다려 새 draftId · 첨부 id 로 보낸다 (그사이 다른 메일로 바뀌었으면 보내지 않는다)
*/
let resetCount = 0
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007']
/**
* 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다).
* MAIL_016(대용량 파일을 쓸 수 없음 — 11단계)은 파일마다 상태를 다시 물어 빨간 줄로 보인다
*/
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_010', 'MAIL_004', 'MAIL_007', 'MAIL_016']
/** 예약 발송에서 이 화면이 직접 안내하는 코드 — 위에 더해 VAL_001(서버가 그 시각을 받지 않음 — 예약 창을 다시 연다) */
const SCHEDULE_INLINE_CODES: readonly ErrorCode[] = [...SEND_INLINE_CODES, 'VAL_001']
/**
@@ -273,7 +308,7 @@ const editorRef = ref<MailEditorHandle | null>(null)
const subjectInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
function queryId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
@@ -304,9 +339,6 @@ const source = computed<ComposeSource>(() => sourceOf(route.query))
const title = computed(() =>
source.value === 'draft' && preparing.value ? COMPOSE_LOADING_TITLE : COMPOSE_TITLES[composeKind.value],
)
const attachmentHintText = computed(() =>
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
)
/** 쓰기를 마치거나 닫으면 보던 화면으로 — 처음부터 쓰기로 들어왔으면 받은편지함 */
const backTo = computed(() => mailStore.lastBrowseLocation ?? { name: 'mail' })
const sender = computed(() => (authStore.user ? formatAddress(authStore.user) : ''))
@@ -331,17 +363,12 @@ const recipientErrorMessage = computed(() =>
const subjectErrorMessage = getErrorMessage('VAL_001')
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
const bodyErrorMessage = getErrorMessage('MAIL_007')
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
/** 보낼 준비 — 편집기를 불러왔고 채우는 · 보내는 · 버리는 중이 아니다 (첨부 때문에 막힌 것은 sendBlock — useComposeLargeFiles) */
const canSend = computed(
() =>
editorReady.value &&
!preparing.value &&
!bodyPending.value &&
!sending.value &&
!discarding.value &&
!isUploading.value &&
!hasMissing.value,
() => editorReady.value && !preparing.value && !bodyPending.value && !sending.value && !discarding.value,
)
/** 지금 보낼 수 있다 — 준비됐고 막는 까닭이 없다 */
const sendable = computed(() => canSend.value && sendBlock.value === null)
/**
* 임시 저장할 수 있는 때 — 편집기가 준비됐고, 보내거나 버리는 중 · 보낸 뒤 · 버린 뒤가 아니고, 로그인 중.
* 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 저장하지 않는다 — 어느 첨부인지 몰라 모두 "찾지 못함"으로 바꿨으므로
@@ -357,6 +384,7 @@ const canSaveDraft = computed(
!discarded.value &&
!sent.value &&
!hasMissing.value &&
!hasMissingLarge.value &&
authStore.isLoggedIn,
)
@@ -365,7 +393,7 @@ function savedAttachmentItems(): UploadItem[] {
return uploadItems.value.filter((item) => item.status === 'done' && item.attachment !== null)
}
/** 지금 양식(받는 사람 · 제목 · 저장할 첨부) — 저장한 양식(savedForm)과 견준다 */
/** 지금 양식(받는 사람 · 제목 · 저장할 첨부 · 대용량 파일) — 저장한 양식(savedForm)과 견준다 */
function snapshotForm(): ComposeFormSnapshot {
return snapshotComposeForm({
to: to.value,
@@ -373,6 +401,7 @@ function snapshotForm(): ComposeFormSnapshot {
bcc: bcc.value,
subject: subject.value,
attachments: savedAttachmentItems(),
largeFiles: trackedLargeFileIds.value,
})
}
@@ -392,7 +421,12 @@ const hasUnsavedChanges = computed(
() =>
!sent.value &&
!discarded.value &&
(hasDraftChanges.value || typingRecipient.value || isUploading.value || hasMissing.value),
(hasDraftChanges.value ||
typingRecipient.value ||
isUploading.value ||
hasMissing.value ||
largeUploading.value ||
hasMissingLarge.value),
)
function addressesOf(tokens: readonly RecipientToken[]) {
@@ -400,15 +434,15 @@ function addressesOf(tokens: readonly RecipientToken[]) {
return tokens.filter((token) => token.valid).map((token) => token.address)
}
/** 임시 저장 본문 — 발송 본문과 같은 필드 (받는 사람 0명도 된다 · 제목은 쓴 그대로) */
/** 임시 저장 본문 — 발송 본문과 같은 필드 (받는 사람 0명도 된다 · 제목은 쓴 그대로 · 올리는 중인 대용량 파일도 싣는다) */
function draftPayloadOf(content: MailEditorContent, attachments: readonly UploadItem[]): DraftPayload {
return {
to: addressesOf(to.value),
cc: addressesOf(cc.value),
bcc: addressesOf(bcc.value),
subject: subject.value,
body: content.text,
...(content.html ? { bodyHtml: content.html } : {}),
// 대용량 파일이 있으면 그 id 와 덩이 자리를 싣는다 — 없으면 인용 경계 표시를 지운다 (계약 4절)
...bodyFields(content),
attachments: attachments.flatMap((item) =>
item.attachment ? [{ id: item.attachment.id, name: item.attachment.name }] : [],
),
@@ -464,6 +498,8 @@ const drafts = useDraftSaving({
onFailed: (error, trigger) => {
// 저장본에 실을 첨부를 서버가 찾지 못했다 — 보내기 때와 같이 표시한다 (빼거나 다시 올리게)
if (error.code === 'MAIL_004') markMissing()
// 실을 대용량 파일을 쓸 수 없다(지움 · 기간 지남) — 파일마다 다시 물어 빨간 줄로 (빼면 다시 저장한다)
if (error.code === 'MAIL_016') void largeUploads.recheck()
// 본문이 너무 길다 — 보내기와 같은 띠를 본문 아래에 (「임시 저장」을 눌렀으면 본문으로 초점도. 자동 저장은 초점을 뺏지 않는다)
if (error.code === 'MAIL_007') {
if (trigger === 'manual') showBodyTooLong()
@@ -582,6 +618,8 @@ function resetForm(next: ComposeSource, sourceId: string | null) {
rejectedByServer.value = null
lengthError.value = null
resetUploads()
// 대용량 첨부 — 올리던 것을 멈춘다(서버의 올리기는 저장본이 이어 쓴다). 한도 · 사용량은 새로 받는다
resetLargeFiles()
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
formGeneration += 1
resetCount += 1
@@ -647,6 +685,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
fitDraft.value = true
renewEditor()
// 인용 속 원래 대용량 첨부 링크 — 상태만 물어 칸 아래 글자로 (원래 메일이 알려 준 파일 이름을 쓴다)
void lookupQuotedLinks(original.htmlBody, original.textBody, original.largeFiles ?? [])
} catch {
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
} finally {
@@ -684,6 +724,8 @@ async function prepareFromDraft(draftId: string) {
composeKind.value = draftKindOf(draft)
const added = addExisting(draft.attachments)
const filled = added.filter((item) => item.status === 'done').map((item) => item.key)
// 저장본의 대용량 파일 — 다 올린 것 · 올리다 멈춘 것(같은 파일을 다시 골라야 이어진다) · 지운 · 기간이 지난 것
largeUploads.addFromDraft(draft.largeFiles ?? [])
// 불러온 그대로가 저장한 상태다 — 저장본 id · 저장 시각으로 시작한다 (다음 저장은 이 id 로 PUT).
// 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(형식 · 개수 · 합계 사전 검사 실패 — 목록에 실패로 보인다)가 있으면
// 원본을 지우지 않는다 — 첫 저장을 새 저장본으로 하고 원본은 그 첨부째 임시보관함에 남긴다 (보내기도 원본을 지우지 않는다)
@@ -694,6 +736,8 @@ async function prepareFromDraft(draftId: string) {
keepOriginal: added.some((item) => item.status === 'failed'),
})
renewEditor()
// 답장 · 전달을 이어 쓰면 인용 속 원래 대용량 첨부 링크의 상태를 묻는다 (파일 이름은 모른다)
if (composeKind.value !== 'new') void lookupQuotedLinks(draft.bodyHtml, draft.body, [])
} catch {
if (seq !== prepareSeq) return
// 없는 저장본(그사이 보냈거나 지웠다) · 메일 서버 장애 — 알림은 인터셉터가 띄웠다. 임시보관함 목록으로 바꿔 끼운다
@@ -730,7 +774,7 @@ function onEditorReady() {
*/
function attachFromBody(files: File[]) {
if (files.length === 0) return
const accepted = addFiles(files)
const accepted = attachFiles(files)
if (accepted.some(isImageFile)) toast.success(IMAGE_AS_ATTACHMENT_MESSAGE)
}
@@ -809,6 +853,8 @@ function precheckSend(): boolean {
return false
}
if (!canSend.value || !editorRef.value) return false
// 첨부 때문에 막혔다 — 까닭은 [보내기] 옆 글이 알린다 (단추를 누르면 읽어 준다)
if (sendBlock.value) return false
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
if (subject.value.trim().length > MAIL_SUBJECT_MAX_LENGTH) {
@@ -853,7 +899,8 @@ async function send(sendAt?: string) {
const subjectText = subject.value.trim()
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
const content = editorRef.value.getContentForSend()
// 대용량 파일이 있으면 서버가 덩이를 넣은 뒤 다시 잰다 — 그만큼 텍스트 한도를 남긴다
const content = editorRef.value.getContentForSend(sendReserve())
if (content === null) {
showBodyTooLong()
return
@@ -887,9 +934,9 @@ async function send(sendAt?: string) {
if (generation === formGeneration) sending.value = false
return
}
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다) 그사이 새 첨부를 올리기 시작했다.
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다
if (hasMissing.value || isUploading.value) {
// 기다린 저장이 첨부를 찾지 못했거나(MAIL_004 — 첨부를 모두 "찾지 못함"으로 바꾼다 · 대용량 파일은 MAIL_016) 그사이 새 첨부를 올리기 시작했다.
// 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 — 보내지 않고 MAIL_004 와 같이 안내한다 (대용량 파일은 [보내기] 옆 까닭 글)
if (sendBlock.value !== null || isUploading.value) {
if (generation !== formGeneration) {
// 양식이 이미 없다 — 보내지 않았다는 것을 토스트로 알린다 (칸 안 안내를 볼 수 없다)
if (hasMissing.value) notifyApiError({ code: 'MAIL_004', message: getErrorMessage('MAIL_004') })
@@ -905,8 +952,8 @@ async function send(sendAt?: string) {
const payload: SendMessagePayload = {
...recipients,
subject: subjectText,
body: content.text,
...(content.html ? { bodyHtml: content.html } : {}),
// 대용량 파일이 있으면 그 id 와 덩이 자리를 싣는다 — 없으면 인용 경계 표시를 지운다 (계약 4절)
...bodyFields(content),
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id, 저장본의 첨부는 저장본 id 를 그대로 보낸다 (계약 2절)
attachments: savedAttachmentItems().flatMap((item) =>
item.attachment ? [{ id: item.attachment.id, name: item.attachment.name }] : [],
@@ -972,6 +1019,11 @@ async function send(sendAt?: string) {
} else if (code === 'VAL_001' && sendAt) {
// 서버가 예약 시각을 받지 않았다(그사이 5분 안이 됨 · 시계가 어긋남) — 알림 대신 예약 창을 다시 열어 고르게 한다
reopenSchedule(Date.parse(sendAt))
} else if (code === 'MAIL_016') {
// 대용량 파일을 쓸 수 없다(없음 · 지움 · 기간 지남 · 올리는 중 · 위험) — 파일마다 다시 물어 빨간 줄로 보인다.
// 다시 물어도 막을 까닭이 보이지 않으면(그사이 고쳐졌다 등) 보내지 않았다는 것을 알린다
await largeUploads.recheck()
if (generation === formGeneration && sendBlock.value === null) notifyApiError(apiError)
}
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
}
@@ -1055,7 +1107,7 @@ function pickFiles() {
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) addFiles(Array.from(input.files))
if (input.files) attachFiles(Array.from(input.files))
// 같은 파일을 다시 고를 수 있게 비운다 (실패한 파일 다시 올리기)
input.value = ''
}
@@ -1113,9 +1165,14 @@ watch(allRecipients, () => {
})
// 보낼 수 없게 되면(보내는 중 · 양식을 새로 채움 · 첨부를 올리기 시작함) 예약 창을 닫는다. 세션이 바뀌어도 닫는다 — 세션 만료 대화상자와 겹치지 않게
watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
watch([sendable, () => authStore.generation], ([can], [, previousSession]) => {
if (!can || authStore.generation !== previousSession) closeSchedule()
})
// 지운 · 기간이 지난 대용량 파일을 모두 뺐다 — 멈췄던 자동 저장을 다시 잡는다
watch(hasMissingLarge, (missing) => {
if (!missing) drafts.touch()
})
</script>
<template>
@@ -1258,20 +1315,33 @@ watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="MAIL_ATTACHMENT_ACCEPT"
:accept="COMPOSE_ATTACHMENT_ACCEPT"
@change="onFilesChosen"
>
</div>
<ComposeAttachmentList
v-if="uploadItems.length > 0"
<ComposeAttachmentGroups
v-if="uploadItems.length > 0 || largeItems.length > 0 || quotedNotices.length > 0"
ref="attachmentListRef"
:items="uploadItems"
:normal-items="uploadItems"
:large-items="largeItems"
:normal-total-text="normalTotalText"
:link-hint="linkGroupHint"
:quota-warning="largeQuotaWarning"
:switch-notice="switchNotice"
:quoted-notices="quotedNotices"
:linkable="isLinkable"
@remove="removeUpload"
@send-as-link="sendAsLink"
@large-pause="largeUploads.pause"
@large-resume="largeUploads.resume"
@large-remove="largeUploads.remove"
@large-repick="(key: number, file: File) => void largeUploads.repick(key, file)"
/>
</div>
<ComposeFooter
:can-send="canSend"
:blocked-reason="sendBlock?.text ?? null"
:sending="sending"
:can-save-draft="canSaveDraft"
:draft-status="draftStatus"
@@ -1293,6 +1363,7 @@ watch([canSend, () => authStore.generation], ([can], [, previousSession]) => {
<!-- 저장하지 못한 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<ComposeLeaveDialog
:open="leaveConfirming"
:uploading="largeUploading"
@stay="stayOnCompose"
@leave="leaveCompose"
/>
+2
View File
@@ -53,6 +53,8 @@ useMailStatePolling({
mailStore.syncCounts()
void mailStore.syncVisibleList()
void mailStore.loadScheduledCount()
// 열린 메일의 대용량 첨부가 보안 검사 중이면 상태를 다시 받는다 (11단계 — 끝나면 받을 수 있다)
void mailStore.refreshOpenedLargeFiles()
},
onHiddenChange: (inboxUnread) => mailStore.setInboxUnread(inboxUnread),
})
@@ -0,0 +1,354 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { FileQuota, LargeFile, ShareLink } from '@/shared/types/large-file'
import { useAuthStore } from '@/stores/auth.store'
import { useLargeFilesStore } from '@/stores/large-files.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MailLargeFilesPage from './MailLargeFilesPage.vue'
// 설정 › 메일 › 대용량 첨부 (11단계 — 디자인 원본 SettingsLargeFiles · 계약 4절 "보낸 사람 관리")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const MB = 1024 * 1024
const GB = 1024 * MB
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
const NOW = Date.parse('2026-10-30T05:15:00Z')
const QUOTA: FileQuota = {
usedBytes: 6.2 * GB,
limitBytes: 10 * GB,
maxFileBytes: 2047 * MB,
maxFilesPerMessage: 10,
chunkSize: 16 * MB,
retentionDays: 30,
}
function unsentFile(changes: Partial<LargeFile>): LargeFile {
return {
id: 'u1',
name: '시공도면_v4.dwg',
size: 620 * MB,
status: 'ready',
chunkSize: 16 * MB,
chunkCount: 39,
receivedChunks: [],
firstChunkSha256: null,
scanStatus: 'clean',
createdAt: '2026-10-30T01:00:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
lastModified: null,
...changes,
}
}
const LINK: ShareLink = {
id: 'link1',
sentEmailId: 'sent-m1',
subject: '11월 현장 점검 자료 보내드립니다',
sentAt: '2026-10-30T05:15:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
status: 'active',
revokedAt: null,
files: [
{ id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'available', downloadCount: 3, lastDownloadedAt: '2026-10-30T05:41:00Z' },
{ id: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, status: 'scanning', downloadCount: 0, lastDownloadedAt: null },
],
}
const page = <T>(items: T[], hasNext = false) => ({
items,
pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: items.length + (hasNext ? 20 : 0) },
})
describe('MailLargeFilesPage', () => {
let pinia: Pinia
let wrapper: VueWrapper | null = null
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
useToastStore().clear()
restoreApiAdapter()
vi.useRealTimers()
document.body.innerHTML = ''
})
interface Fixture {
unsent?: LargeFile[]
links?: ShareLink[]
linksHasNext?: boolean
quota?: FileQuota
failLinks?: boolean
/** 지우기가 409 FILE_003 — 그사이 그 파일을 실은 메일을 보냈다 (다시 받는 목록에서는 보낸 링크로 간다) */
sentMeanwhile?: { unsent: LargeFile[]; links: ShareLink[] }
}
async function mountPage(fixture: Fixture = {}) {
let unsent = fixture.unsent ?? []
let links = fixture.links ?? []
let quota = fixture.quota ?? QUOTA
const requests = installMockApi((request: MockRequest) => {
const { method, url } = request
if (method === 'GET' && url === '/file-uploads') return ok(page(unsent))
if (method === 'GET' && url === '/share-links') {
if (fixture.failLinks) return fail(503, 'MAIL_001')
return ok(request.params.cursor ? page([{ ...LINK, id: 'link-old', subject: '지난 메일' }]) : page(links, fixture.linksHasNext))
}
if (method === 'GET' && url === '/file-quota') return ok(quota)
if (method === 'GET' && url === '/mailboxes') {
return ok(page([{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }]))
}
if (method === 'DELETE' && url.startsWith('/file-uploads/')) {
if (fixture.sentMeanwhile) {
unsent = fixture.sentMeanwhile.unsent
links = fixture.sentMeanwhile.links
return fail(409, 'FILE_003')
}
unsent = unsent.filter((file) => `/file-uploads/${file.id}` !== url)
quota = { ...quota, usedBytes: quota.usedBytes - 620 * MB }
return { status: 204 }
}
if (method === 'POST' && url === '/share-links/link1/revocation') {
return ok({
...LINK,
status: 'revoked',
revokedAt: '2026-10-30T05:20:00Z',
files: LINK.files.map((file) => ({ ...file, status: 'revoked' })),
})
}
return ok({})
})
const router = createTestRouter()
await router.push('/settings/mail/large-files')
await router.isReady()
wrapper = mount(MailLargeFilesPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: createManualClock(NOW) } },
})
await flushPromises()
await flushPromises()
return { wrapper, requests }
}
it('사용량 — "6.2 / 10 GB" + 막대 + 보낸 링크 · 보내지 않은 파일로 나눈 양 (위험 판정 파일은 빼고 센다)', async () => {
const { wrapper: view } = await mountPage({
unsent: [
unsentFile({ id: 'u1', size: 1.2 * GB, status: 'uploading', scanStatus: null }),
unsentFile({ id: 'u2', size: 700 * MB, scanStatus: 'clean' }),
unsentFile({ id: 'u3', size: 24 * MB, scanStatus: 'infected' }),
],
links: [LINK],
})
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(임시 저장 · 올리다 멈춤). 기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
)
})
it('보내지 않은 파일 — 상태 · 크기(올린 만큼) · 남는 기한 / 보낸 링크 위에', async () => {
const { wrapper: view } = await mountPage({
unsent: [
unsentFile({
id: 'u1',
name: '현장영상_1030.mp4',
size: 1.2 * GB,
status: 'uploading',
scanStatus: null,
chunkCount: 77,
receivedChunks: Array.from({ length: 26 }, (_, index) => index),
expiresAt: '2026-10-31T05:41:00Z',
}),
unsentFile({ id: 'u2', name: '발표자료_초안.pptx', size: 64 * MB, scanStatus: 'unscannable' }),
],
links: [LINK],
})
expect(view.get('#large-unsent-title').text()).toBe('보내지 않은 파일 2개')
const rows = view.findAll('[data-unsent]')
expect(rows[0]?.get('.unsent__status').text()).toBe('올리다 멈춤')
expect(rows[0]?.get('.unsent__size').text()).toContain('416 MB 올림')
expect(rows[0]?.get('.unsent__until').text()).toContain('까지 남음')
expect(rows[0]?.get('.unsent__until').text()).toContain('마지막으로 올린 뒤 24시간')
expect(rows[1]?.get('.unsent__status').text()).toBe('다 올림 · 검사하지 못함')
expect(rows[1]?.get('.unsent__until').text()).toBe('11월 29일까지 남음')
// 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위
const html = view.html()
expect(html.indexOf('보내지 않은 파일 2개')).toBeLessThan(html.indexOf('보낸 링크 1개'))
})
it('[지우기] — 확인 창(처음 초점 「취소」) 뒤 지우고 알린다 · 줄이 사라지고 사용량을 다시 받는다', async () => {
const { wrapper: view, requests } = await mountPage({ unsent: [unsentFile({})], links: [LINK] })
await view.get('[data-unsent-delete]').trigger('click')
await flushPromises()
expect(view.get('#large-delete-message').text()).toBe(
'‘시공도면_v4.dwg’(620 MB) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.',
)
expect(document.activeElement?.hasAttribute('data-delete-cancel')).toBe(true)
await view.get('[data-delete-confirm]').trigger('click')
await flushPromises()
await flushPromises()
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/file-uploads/u1')).toBe(true)
expect(view.find('[data-unsent]').exists()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['‘시공도면_v4.dwg’ 파일을 지웠습니다.'])
expect(view.get('[data-large-usage]').text()).toBe('5.6 / 10 GB')
})
it('보낸 링크 — 메일마다 한 덩이: 제목은 보낸 메일로 · 보낸 날 · 끝나는 날 · 남은 날 · 파일마다 받은 수 · 상태', async () => {
const { wrapper: view } = await mountPage({ links: [LINK] })
const block = view.get('[data-share-link="link1"]')
const subject = block.get('a.link__subject')
expect(subject.text()).toBe('11월 현장 점검 자료 보내드립니다')
expect(subject.attributes('href')).toBe('/mail/sent/sent-m1')
expect(block.get('.link__sent').text()).toBe('10월 30일 (금) 보냄 · 파일 2개 · 1.8 GB')
expect(block.get('.link__until').text()).toContain('11월 29일까지')
expect(block.get('.link__until').text()).toContain('30일 남음')
const files = block.findAll('[data-shared-file]')
expect(files[0]?.get('.link__file-downloads').text()).toContain('3회 받음')
expect(files[0]?.get('.link__file-downloads').text()).toContain('마지막 오늘')
expect(files[0]?.get('.link__file-status').text()).toBe('사용 중')
expect(files[1]?.get('.link__file-downloads').text()).toBe('아직 받지 않음')
expect(files[1]?.get('.link__file-status').text()).toBe('검사 중')
// [링크 복사]는 두지 않는다 — 서버는 링크를 해시로만 안다
expect(view.text()).not.toContain('링크 복사')
})
it('[링크 끊기] — 메일 제목이 든 확인 창 뒤 끊고, 덩이는 "공유 멈춤 / 10월 30일에 끊음"(단추 없음) · 초점은 덩이 머리로', async () => {
const { wrapper: view, requests } = await mountPage({ links: [LINK] })
await view.get('[data-link-revoke]').trigger('click')
await flushPromises()
expect(view.get('#settings-large-revoke-message').text()).toContain(
'‘11월 현장 점검 자료 보내드립니다’의 파일 2개를 받는 사람 모두 더는 내려받을 수 없습니다.',
)
await view.get('[data-revoke-confirm]').trigger('click')
await flushPromises()
await flushPromises()
expect(requests.some((request) => request.method === 'POST' && request.url === '/share-links/link1/revocation')).toBe(true)
const block = view.get('[data-share-link="link1"]')
expect(block.classes()).toContain('link--ended')
expect(block.get('.link__until-main').text()).toBe('공유 멈춤')
expect(block.get('.link__until-note').text()).toBe('10월 30일에 끊음')
expect(block.find('[data-link-revoke]').exists()).toBe(false)
expect(document.activeElement?.classList.contains('link__head')).toBe(true)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.',
])
})
it('[더 보기] — 20개씩 이어 받는다', async () => {
const { wrapper: view } = await mountPage({ links: [LINK], linksHasNext: true })
expect(view.get('#large-links-title').text()).toBe('보낸 링크 21개')
await view.get('[data-links-more]').trigger('click')
await flushPromises()
expect(view.findAll('[data-share-link]').map((block) => block.attributes('data-share-link'))).toEqual([
'link1',
'link-old',
])
expect(view.find('[data-links-more]').exists()).toBe(false)
})
it('빈 상태 — 사용량은 그대로, "링크로 보낸 파일이 없습니다"', async () => {
const { wrapper: view } = await mountPage({ quota: { ...QUOTA, usedBytes: 0 } })
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.find('#large-unsent-title').exists()).toBe(false)
})
it('목록을 받지 못하면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
const { wrapper: view } = await mountPage({ failLinks: true })
expect(view.text()).toContain('메일 서버와 연결할 수 없습니다.')
expect(useToastStore().toasts).toEqual([])
expect(useLargeFilesStore().status).toBe('error')
})
it('실시간 위험 판정은 받아 둔 목록에 바로 옮겨진다', async () => {
const { wrapper: view } = await mountPage({ links: [LINK] })
useLargeFilesStore().noteInfected('f2', '현장영상_1029.mp4')
await flushPromises()
const infected = view.get('[data-shared-file="f2"]')
expect(infected.classes()).toContain('link__file--danger')
expect(infected.get('.link__file-status').text()).toBe('위험내려받기를 막음')
})
it('보낸 메일을 지웠으면(제목 null) 제목은 글자만 — 보낸편지함으로 가는 링크가 없다 (리뷰 L6)', async () => {
const { wrapper: view } = await mountPage({ links: [{ ...LINK, subject: null }] })
const block = view.get('[data-share-link="link1"]')
expect(block.find('a.link__subject').exists()).toBe(false)
expect(block.get('.link__subject--plain').text()).toBe('(제목 없음)')
})
it('지우기가 409 FILE_003 이면(그사이 그 파일을 실은 메일을 보냄) 목록을 다시 받고 확인 창을 닫는다 (리뷰 L9)', async () => {
const file = unsentFile({})
const { wrapper: view, requests } = await mountPage({
unsent: [file],
links: [],
sentMeanwhile: { unsent: [], links: [LINK] },
})
const listCalls = () => requests.filter((request) => request.method === 'GET' && request.url === '/file-uploads').length
await view.get('[data-unsent-delete]').trigger('click')
await flushPromises()
expect(listCalls()).toBe(1)
await view.get('[data-delete-confirm]').trigger('click')
await flushPromises()
await flushPromises()
expect(listCalls()).toBe(2)
expect(view.find('#large-delete-message').exists()).toBe(false)
expect(view.find('[data-unsent]').exists()).toBe(false)
expect(view.find('[data-share-link="link1"]').exists()).toBe(true)
// 실패 알림은 인터셉터가 사전 문구로 (성공 알림은 없다)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.FILE_003])
})
it('끊은 링크 — 끊은 날은 한국 날짜로 "10월 22일에 끊음", 끊은 때가 없는 옛 응답은 "공유 멈춤"만', async () => {
const ended = (id: string, revokedAt: string | null): ShareLink => ({
...LINK,
id,
status: 'revoked',
revokedAt,
files: LINK.files.map((file) => ({ ...file, status: 'revoked' })),
})
const { wrapper: view } = await mountPage({
// UTC 10월 21일 15:30 = 한국 10월 22일 오전 0:30
links: [ended('revoked-kst', '2026-10-21T15:30:00Z'), ended('revoked-old', null)],
})
const withDay = view.get('[data-share-link="revoked-kst"]')
expect(withDay.get('.link__until-main').text()).toBe('공유 멈춤')
expect(withDay.get('.link__until-note').text()).toBe('10월 22일에 끊음')
const old = view.get('[data-share-link="revoked-old"]')
expect(old.get('.link__until').text()).toBe('공유 멈춤')
expect(old.find('.link__until-note').exists()).toBe(false)
})
})
@@ -0,0 +1,152 @@
<script setup lang="ts">
// 설정 › 메일 › 대용량 첨부 (디자인 원본 SettingsLargeFiles · README "11단계 › 설정 › 메일 › 대용량 첨부" · 계약 4절 "보낸 사람 관리")
// — /settings/mail/large-files · 탭 제목 "대용량 첨부"
// 머리 "대용량 첨부" + 설명 → 사용량 상자("보관 사용량 6.2 / 10 GB" + 막대 + 보낸 링크 · 보내지 않은 파일로 나눈 양) →
// 보내지 않은 파일(있을 때만 — 공간을 바로 비울 수 있는 곳이라 보낸 링크보다 위) → 보낸 링크(메일마다 한 덩이 · 20개씩 [더 보기]) 또는 빈 상태.
// - 들어올 때마다 새로 받는다(다른 탭 · PC 에서 보냈거나 지웠을 수 있다 — large-files.store). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 메일 제목은 보낸편지함의 그 메일로 잇는다 — 메일함 목록을 받아 둔다(보낸편지함 id)
// - 실시간 알림의 위험 판정은 받아 둔 목록에 바로 옮겨진다 (스토어의 noteInfected)
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import LargeFileLinkList from '@/components/settings/LargeFileLinkList.vue'
import LargeFileUnsentList from '@/components/settings/LargeFileUnsentList.vue'
import LargeFileUsage from '@/components/settings/LargeFileUsage.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { useClock } from '@/composables/useClock'
import { LARGE_FILE_SETTINGS_TEXT } from '@/shared/constants/large-file'
import { formatCount, formatMegabytes } from '@/shared/utils/format'
import { useLargeFilesStore } from '@/stores/large-files.store'
import { useMailStore } from '@/stores/mail.store'
const largeFilesStore = useLargeFilesStore()
const mailStore = useMailStore()
const clock = useClock()
/** 화면을 연 때 — 남은 날 · "오늘 오후 2:41" 의 기준 */
const now = clock.now()
const ready = computed(() => largeFilesStore.status === 'ready')
const retentionDays = computed(() => largeFilesStore.limits.retentionDays)
const description = computed(() => LARGE_FILE_SETTINGS_TEXT.description(formatCount(retentionDays.value)))
const sentMailboxId = computed(() => mailStore.mailboxByRole('sent')?.id ?? null)
const hasMoreLinks = computed(() => largeFilesStore.linksPageInfo?.hasNext === true)
/**
* 사용량을 "보낸 링크 · 보내지 않은 파일"로 나눈다 — 보내지 않은 파일을 다 받았을 때만 (직원마다 50개라 보통 다 받는다).
* 위험 판정 파일은 바이트를 곧 지워 사용량에서 빠진다
*/
const split = computed(() => {
if (!largeFilesStore.unsentComplete) return null
const unsentBytes = largeFilesStore.unsent.reduce(
(sum, file) => (file.scanStatus === 'infected' ? sum : sum + file.size),
0,
)
return { unsentBytes, sentBytes: Math.max(0, largeFilesStore.limits.usedBytes - unsentBytes) }
})
const emptyHint = computed(() =>
LARGE_FILE_SETTINGS_TEXT.emptyHint(formatMegabytes(mailStore.attachmentMaxBytes), formatCount(retentionDays.value)),
)
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void largeFilesStore.loadSettings()
void mailStore.ensureMailboxes()
</script>
<template>
<SettingsPageFrame
title-id="large-files-page-title"
:title="LARGE_FILE_SETTINGS_TEXT.title"
:description="description"
>
<MailLoadError
v-if="largeFilesStore.status === 'error' && largeFilesStore.error"
:error="largeFilesStore.error"
leave-label=""
@retry="largeFilesStore.loadSettings()"
/>
<template v-else-if="ready">
<LargeFileUsage
:used-bytes="largeFilesStore.limits.usedBytes"
:limit-bytes="largeFilesStore.limits.limitBytes"
:split="split"
/>
<LargeFileUnsentList
v-if="largeFilesStore.unsent.length > 0"
:files="largeFilesStore.unsent"
:now="now"
/>
<LargeFileLinkList
v-if="largeFilesStore.links.length > 0"
:links="largeFilesStore.links"
:total="largeFilesStore.linksTotal"
:has-more="hasMoreLinks"
:loading-more="largeFilesStore.loadingMore"
:now="now"
:sent-mailbox-id="sentMailboxId"
@more="largeFilesStore.loadMoreLinks()"
/>
<div
v-else
class="large-files-page__empty"
data-large-empty
>
<AppIcon
name="link"
:size="22"
class="large-files-page__empty-icon"
/>
<p class="large-files-page__empty-title">
{{ LARGE_FILE_SETTINGS_TEXT.emptyTitle }}
</p>
<p class="large-files-page__empty-hint">
{{ emptyHint }}
</p>
</div>
</template>
<div
v-else
class="large-files-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* 빈 상태 — 가운데 정렬 상자 (디자인 원본 SettingsLargeFiles "빈 상태") */
.large-files-page__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-6);
padding: var(--space-28) var(--space-24);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-panel);
text-align: center;
}
.large-files-page__empty-icon {
color: var(--color-icon-muted);
}
.large-files-page__empty-title {
margin: 0;
font-weight: 600;
}
.large-files-page__empty-hint {
max-width: 28rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.large-files-page__placeholder {
min-height: 16rem;
}
</style>
@@ -9,8 +9,8 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림
// (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계" · "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -35,7 +35,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -50,6 +50,7 @@ describe('SettingsLayout', () => {
['필터', '/settings/mail/filters'],
['보내기 · 읽기', '/settings/mail/send-read'],
['차단한 주소', '/settings/mail/blocks'],
['대용량 첨부', '/settings/mail/large-files'],
['알림', '/settings/mail/notify'],
],
])
@@ -97,6 +98,22 @@ describe('SettingsLayout', () => {
expect(router.currentRoute.value.meta).toMatchObject({ title: '차단한 주소', requiresAuth: true })
})
it('설정 › 메일 › 대용량 첨부 (11단계) — /settings/mail/large-files, 로그인이 필요하고 탭 제목은 메뉴 이름 (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/mail/large-files')
expect(router.currentRoute.value.name).toBe('settings-mail-large-files')
expect(router.currentRoute.value.meta).toMatchObject({ title: '대용량 첨부', requiresAuth: true })
})
it('대용량 첨부 메뉴는 링크 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/large-files')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('대용량 첨부')
expect(current.findAll('path')).toHaveLength(2)
})
it('차단한 주소 메뉴는 원 + 빗금 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/blocks')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
+319
View File
@@ -0,0 +1,319 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { CLOCK } from '@/composables/useClock'
import type { PublicLink, PublicLinkFile } from '@/shared/types/large-file'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import SharePage from './SharePage.vue'
// 공개 내려받기 화면 /s/:token (11단계 — 디자인 원본 SharePage · 계약 4절 "공개 내려받기")
// 로그인 없이 쿠키 없는 fetch 로 링크 정보를 받고, 상태마다 다른 화면을 그린다. 받기는 누르기 직전에 상태를 한 번 더 묻는다.
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
const GB = 1024 * 1024 * 1024
const MB = 1024 * 1024
/** 한국 시간 2026-10-30 (금) 오후 2:15 */
const NOW = Date.parse('2026-10-30T05:15:00Z')
const DESIGN: PublicLinkFile = { id: 'f1', name: '설계도_v3.dwg', size: 1.2 * GB, status: 'available' }
const VIDEO: PublicLinkFile = { id: 'f2', name: '현장영상_1029.mp4', size: 612 * MB, status: 'available' }
function publicLink(changes: Partial<PublicLink> = {}): PublicLink {
return {
status: 'active',
sender: { name: '김아라', email: 'ara.kim@arasystem.it.kr' },
sentAt: '2026-10-30T05:15:00Z',
expiresAt: '2026-11-29T14:59:59.999Z',
files: [DESIGN, VIDEO],
reportAddress: 'security@arasystem.it.kr',
...changes,
}
}
type Reply = { link: PublicLink } | { status: number } | 'network' | 'html'
interface FetchCall {
url: string
init: RequestInit
}
describe('SharePage', () => {
let wrapper: VueWrapper | null = null
let clock: ManualClock
let replies: Reply[]
let calls: FetchCall[]
let downloads: string[]
/** 링크 정보 요청에 차례로 답한다 — 남은 답이 없으면 마지막 답을 되풀이한다 */
function fakeFetch(url: string, init: RequestInit): Promise<Response> {
calls.push({ url, init })
const reply = replies.length > 1 ? replies.shift() : replies[0]
if (!reply || reply === 'network') return Promise.reject(new TypeError('Failed to fetch'))
if (reply === 'html') {
return Promise.resolve(new Response('<!doctype html><title>x</title>', { status: 200, headers: { 'Content-Type': 'text/html' } }))
}
if ('status' in reply) {
return Promise.resolve(new Response(JSON.stringify({ success: false }), { status: reply.status }))
}
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: reply.link }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}
beforeEach(() => {
setActivePinia(createPinia())
clock = createManualClock(NOW)
replies = []
calls = []
downloads = []
vi.stubGlobal('fetch', vi.fn(fakeFetch))
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloads.push(this.getAttribute('href') ?? '')
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.unstubAllGlobals()
vi.restoreAllMocks()
document.body.innerHTML = ''
document.head.querySelectorAll('meta[name="robots"], meta[name="referrer"]').forEach((meta) => meta.remove())
})
async function mountAt(token: string, ...answers: Reply[]) {
replies = answers
const router = createTestRouter()
await router.push(`/s/${token}`)
await router.isReady()
wrapper = mount(SharePage, {
attachTo: document.body,
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
})
await flushPromises()
await flushPromises()
return wrapper
}
it('정상 — 보낸 사람 · 파일 수 · 합계 · 끝나는 날(한국 시간) · 파일마다 [내려받기] · 로그인 안내 · 영어 한 줄', async () => {
const view = await mountAt(TOKEN, { link: publicLink() })
expect(view.get('h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
// 이름과 주소 사이 띄어쓰기는 괄호 칸 안에 둔다 — 좁은 화면은 괄호와 함께 감추고 주소를 다음 줄로 (디자인 원본 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일 남음',
)
expect(view.findAll('.share-file__meta').map((meta) => meta.text())).toEqual([
'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`)
expect(download.attributes('aria-label')).toBe('설계도_v3.dwg 내려받기')
expect(view.get('.share__safety').text()).toBe(
'이 화면은 로그인이나 비밀번호를 묻지 않습니다. 묻는 화면이 나오면 닫고 보낸 사람에게 확인해 주세요.',
)
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.',
)
// 메일 제목 · 본문 · 받는 사람 · 받은 수는 보이지 않는다
expect(view.text()).not.toContain('회 받음')
expect(document.title).toBe('김아라 님이 보낸 파일 — ARA SYSTEM')
})
it('링크 정보는 쿠키 없이 · 캐시 없이 · 주소를 싣지 않고 묻는다 — 검색 엔진 · referrer 막기 머리도 둔다', async () => {
await mountAt(TOKEN, { link: publicLink() })
expect(calls).toHaveLength(1)
expect(calls[0]?.url).toBe(`/api/public-links/${TOKEN}`)
expect(calls[0]?.init).toMatchObject({ method: 'GET', credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer' })
expect(document.head.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe('noindex, nofollow')
expect(document.head.querySelector('meta[name="referrer"]')?.getAttribute('content')).toBe('no-referrer')
wrapper?.unmount()
wrapper = null
// 화면을 떠나면 머리에 둔 것을 거둔다
expect(document.head.querySelector('meta[name="robots"]')).toBeNull()
})
it('[내려받기] — 누르기 직전에 상태를 한 번 더 묻고, 받을 수 있으면 그 파일 주소로 이동한다', async () => {
const view = await mountAt(TOKEN, { link: publicLink() })
await view.get('[data-share-file="f2"] a.share-file__download').trigger('click')
await flushPromises()
expect(calls).toHaveLength(2)
expect(downloads).toEqual([`/api/public-links/${TOKEN}/files/f2`])
})
it('[내려받기] — 그사이 공유가 멈췄으면 받지 않고 화면을 바꾸고 알린다', async () => {
const view = await mountAt(TOKEN, { link: publicLink() }, { link: publicLink({ status: 'revoked', files: [] }) })
await view.get('[data-share-file="f1"] a.share-file__download').trigger('click')
await flushPromises()
await flushPromises()
expect(downloads).toEqual([])
expect(view.get('[data-share-view="revoked"] h1').text()).toBe('보낸 사람이 공유를 멈췄습니다')
expect(view.get('[role="status"]').text()).toBe('이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.')
})
it('일부 검사 중 — 그 줄만 "보안 검사 중"이고 단추는 aria-disabled(누르면 까닭) · 15초 뒤 다시 묻는다', async () => {
const scanning = publicLink({ files: [DESIGN, { ...VIDEO, status: 'scanning' }] })
const view = await mountAt(TOKEN, { link: scanning }, { link: publicLink() })
expect(view.get('h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
const blocked = view.get('[data-share-file="f2"] .share-file__download')
expect(blocked.element.tagName).toBe('BUTTON')
expect(blocked.attributes('aria-disabled')).toBe('true')
await blocked.trigger('click')
await flushPromises()
expect(view.get('[role="status"]').text()).toBe('612 MB · 보안 검사 중')
expect(downloads).toEqual([])
clock.advance(15_000)
await flushPromises()
await flushPromises()
expect(calls).toHaveLength(2)
expect(view.get('[data-share-file="f2"] .share-file__download').element.tagName).toBe('A')
// 다 끝났으면 더 묻지 않는다
expect(clock.pendingCount()).toBe(0)
})
it('모두 검사 중 — 제목이 바뀌고 [다시 확인]', async () => {
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.')
await view.get('[data-share-recheck]').trigger('click')
await flushPromises()
await flushPromises()
expect(view.get('[data-share-view="active"] h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
})
it('위험 · 검사하지 못함 · 횟수 초과 — 줄마다 글, 위험과 횟수 초과는 단추가 없다', async () => {
const view = await mountAt(TOKEN, {
link: publicLink({
files: [
{ ...DESIGN, status: 'infected' },
{ ...VIDEO, status: 'unscannable' },
{ id: 'f3', name: '회의록.zip', size: 3 * MB, status: 'limit-reached' },
],
}),
})
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.find('.share-file__download').exists()).toBe(false)
const unscannable = view.get('[data-share-file="f2"]')
expect(unscannable.classes()).toContain('share-file--warning')
expect(unscannable.find('a.share-file__download').exists()).toBe(true)
const limit = view.get('[data-share-file="f3"]')
expect(limit.get('.share-file__meta').text()).toBe(
'내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요.',
)
expect(limit.find('.share-file__download').exists()).toBe(false)
})
it('모두 위험 — "내려받을 수 없는 파일입니다"', async () => {
const view = await mountAt(TOKEN, { link: publicLink({ files: [{ ...DESIGN, status: 'infected' }] }) })
expect(view.get('[data-share-view="infected"] h1').text()).toBe('내려받을 수 없는 파일입니다')
expect(view.find('[data-share-recheck]').exists()).toBe(false)
})
it('만료 — 끝난 날 · 보낸 사람(이름, 주소)과 신고 주소 · 파일 이름은 보이지 않는다', async () => {
const view = await mountAt(TOKEN, {
link: publicLink({ status: 'expired', expiresAt: '2026-10-29T14:59:59.999Z', files: [] }),
})
expect(view.get('[data-share-view="expired"] h1').text()).toBe('내려받을 수 있는 기간이 지났습니다')
expect(view.get('[data-share-view="expired"] .share__line').text()).toBe(
'2026년 10월 29일까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(김아라, ara.kim@arasystem.it.kr)에게 다시 보내 달라고 요청해 주세요.',
)
expect(view.get('.share__english').text()).toBe('This link expired on Oct 29, 2026 (KST).')
expect(view.find('.share__report').exists()).toBe(true)
expect(view.text()).not.toContain('설계도_v3.dwg')
})
it('없는 링크(404) · 모양이 틀린 토큰 — 보낸 사람도 숨기고, 토큰이 틀리면 묻지도 않는다', async () => {
const notFound = await mountAt(TOKEN, { status: 404 })
expect(notFound.get('[data-share-view="not-found"] h1').text()).toBe('찾을 수 없는 링크입니다')
expect(notFound.find('.share__report').exists()).toBe(false)
expect(notFound.text()).not.toContain('김아라')
notFound.unmount()
wrapper = null
calls = []
const malformed = await mountAt('too-short', { link: publicLink() })
expect(malformed.get('[data-share-view="not-found"] h1').text()).toBe('찾을 수 없는 링크입니다')
expect(calls).toEqual([])
})
it('요청이 너무 많음(429) · 네트워크 실패 · JSON 이 아닌 응답 — [다시 시도]로 처음부터 다시 묻는다', async () => {
const tooMany = await mountAt(TOKEN, { status: 429 }, 'network', 'html', { link: publicLink() })
expect(tooMany.get('[data-share-view="too-many"] h1').text()).toBe('요청이 너무 많습니다')
await tooMany.get('[data-share-retry]').trigger('click')
await flushPromises()
expect(tooMany.get('[data-share-view="error"] h1').text()).toBe('화면을 불러오지 못했습니다')
await tooMany.get('[data-share-retry]').trigger('click')
await flushPromises()
await flushPromises()
// 앞단이 HTML 을 돌려줘도 그리다 깨지지 않는다
expect(tooMany.get('[data-share-view="error"] h1').text()).toBe('화면을 불러오지 못했습니다')
await tooMany.get('[data-share-retry]').trigger('click')
await flushPromises()
await flushPromises()
expect(tooMany.get('[data-share-view="active"] h1').text()).toBe('김아라 님이 보낸 파일 2개 (1.8 GB)')
})
it('보낸 사람 이름이 없으면 주소로 — 제목 · 탭 제목', async () => {
const view = await mountAt(TOKEN, {
link: publicLink({ sender: { name: null, email: 'ara.kim@arasystem.it.kr' }, files: [DESIGN] }),
})
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')
})
it('불러오는 중 — 자리 그림은 숨기고 "불러오는 중"을 화면 낭독기에 읽어 준다 (리뷰 L9)', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(() => undefined)))
const router = createTestRouter()
await router.push(`/s/${TOKEN}`)
await router.isReady()
wrapper = mount(SharePage, {
attachTo: document.body,
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
})
await flushPromises()
const loading = wrapper.get('[data-share-loading]')
expect(loading.attributes('role')).toBe('status')
expect(loading.text()).toBe('불러오는 중')
expect(loading.findAll('[aria-hidden="true"]')).toHaveLength(3)
expect(wrapper.get('main').attributes('aria-busy')).toBe('true')
})
it('보낸 사람 이름이 빈 글이어도 주소로 — 이름 칸이 비지 않는다', async () => {
const view = await mountAt(TOKEN, {
link: publicLink({ sender: { name: '', email: 'ara.kim@arasystem.it.kr' }, files: [DESIGN] }),
})
expect(view.get('.share__intro strong').text()).toBe('ara.kim@arasystem.it.kr')
expect(view.find('.share__email').exists()).toBe(false)
})
})
+640
View File
@@ -0,0 +1,640 @@
<script setup lang="ts">
// 공개 내려받기 화면 — /s/:token (디자인 원본 SharePage · README "11단계 › 내려받기 화면" · 계약 4절 "공개 내려받기")
// 메일 속 링크는 파일이 아니라 이 화면으로 온다. 로그인 없이 링크가 있는 누구나 연다 — 계정 비밀번호는 묻지 않는다.
// 독립 화면: 앱 레일 · 상단 바 · 로그인 메뉴가 없다. 머리 바(#0B3640, 60 — 좁으면 52)에 흰 ARA SYSTEM 로고(88 — 좁으면 76) + "파일 내려받기",
// 바탕 위 가운데 열 640. 탭 제목 "김아라 님이 보낸 파일 — ARA SYSTEM". 메일 제목 · 본문 · 받는 사람, 받은 수 · 남은 수는 보이지 않는다.
// - 링크 정보(GET /api/public-links/{token})는 쿠키 없이 묻는다(usePublicShare — 세션 갱신 · 세션 만료 대화상자를 타지 않는다).
// - 상태: 정상 · 검사 중(모두면 제목이 바뀌고 [다시 확인], 일부면 그 줄만) · 위험 · 검사하지 못함 · 횟수 초과 · 만료 · 회수(파일 이름도 숨김) ·
// 없는 링크(보낸 사람도 숨김) · 요청이 너무 많음([다시 시도]) · 그 밖의 실패. 모든 상태에서 머리 · 영어 한 줄은 그대로, 신고 주소는 링크를 알 때.
// - [내려받기]는 <a href> 이동(늘 저장 — 큰 파일을 fetch 로 받으면 메모리에 다 오른다) — 누르기 직전에 상태를 한 번 더 묻고,
// 받을 수 없게 됐으면 화면을 바꾸고 알린다(이동 뒤 JSON 오류가 뜨지 않게). 끊기면 브라우저의 다시 시도로 이어 받는다.
// - 검사 중인 파일이 있으면 잠시 뒤 다시 묻는다(15 · 30 · 60초 — 링크 정보는 IP 마다 1분 60회 안).
// - 검색 엔진에 걸리지 않게(robots noindex) · 이 화면에서 나가는 요청에 주소가 실리지 않게(referrer no-referrer) 머리에 둔다 (앞단도 머리글로 막는다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import logoFull from '@/assets/brand/logo-arasystem-white.png'
import AppIcon from '@/components/AppIcon.vue'
import ShareFileRow from '@/components/share/ShareFileRow.vue'
import { useClock, type ClockTimer } from '@/composables/useClock'
import { usePublicShare, type PublicLinkResult } from '@/composables/usePublicShare'
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 {
formatKstEnglishDate,
formatKstFullDate,
formatKstLongDate,
kstDaysLeft,
} from '@/shared/utils/kst-date'
import { isDownloadable } from '@/shared/utils/large-file-status'
type ShareState = { kind: 'loading' } | PublicLinkResult
const route = useRoute()
const clock = useClock()
const share = usePublicShare()
const state = shallowRef<ShareState>({ kind: 'loading' })
/** 받기 전에 상태를 다시 묻는 중인 파일 */
const busyFileId = ref<string | null>(null)
/** 화면 낭독기에 읽어 줄 글 (role=status) */
const announcement = ref<string>('')
const host = typeof window === 'undefined' ? '' : window.location.host
let seq = 0
let pollTimer: ClockTimer | null = null
let polls = 0
let unmounted = false
const headTags: HTMLMetaElement[] = []
const token = computed(() => (typeof route.params.token === 'string' ? route.params.token : ''))
const link = computed<PublicLink | null>(() => (state.value.kind === 'ok' ? state.value.link : null))
const files = computed<PublicLinkFile[]>(() => (link.value?.status === 'active' ? link.value.files : []))
const senderName = computed(() => link.value?.sender.name || link.value?.sender.email || '')
const senderInline = computed(() =>
link.value ? SHARE_PAGE_TEXT.senderInline(link.value.sender.name, link.value.sender.email) : '',
)
const totalSize = computed(() => formatFileSize(files.value.reduce((sum, file) => sum + file.size, 0)))
const allScanning = computed(() => files.value.length > 0 && files.value.every((file) => file.status === 'scanning'))
const allInfected = computed(() => files.value.length > 0 && files.value.every((file) => file.status === 'infected'))
const anyDownloadable = computed(() => files.value.some((file) => isDownloadable(file.status)))
/** 지금 그릴 모양 — 링크 상태와 파일 상태로 고른다 */
const view = computed(() => {
const current = state.value
if (current.kind !== 'ok') return current.kind
if (current.link.status !== 'active') return current.link.status
if (allScanning.value) return 'scanning'
if (allInfected.value) return 'infected'
return 'active'
})
const untilText = computed(() => (link.value ? SHARE_PAGE_TEXT.until(formatKstFullDate(link.value.expiresAt)) : ''))
const untilTail = computed(() => {
if (!link.value) return ''
const days = kstDaysLeft(link.value.expiresAt, clock.now()) ?? 0
return days > 0 ? SHARE_PAGE_TEXT.untilTail(formatCount(days)) : SHARE_PAGE_TEXT.untilTailLastDay
})
/** 영어 한 줄 — 상태마다 */
const english = computed(() => {
switch (view.value) {
case 'active':
return link.value
? SHARE_PAGE_TEXT.englishActive(senderName.value, link.value.sender.email, formatKstEnglishDate(link.value.expiresAt))
: ''
case 'scanning':
return SHARE_PAGE_TEXT.scanningEnglish
case 'infected':
return SHARE_PAGE_TEXT.infectedEnglish
case 'expired':
return link.value ? SHARE_PAGE_TEXT.expiredEnglish(formatKstEnglishDate(link.value.expiresAt)) : ''
case 'revoked':
return SHARE_PAGE_TEXT.revokedEnglish
case 'not-found':
return SHARE_PAGE_TEXT.notFoundEnglish
case 'too-many':
return SHARE_PAGE_TEXT.tooManyEnglish
case 'error':
return SHARE_PAGE_TEXT.errorEnglish
default:
return ''
}
})
function clearPoll() {
if (pollTimer !== null) clock.clearTimeout(pollTimer)
pollTimer = null
}
/** 검사 중인 파일이 있으면 잠시 뒤 다시 묻는다 — 간격을 늘리며, 정해진 횟수까지 */
function schedulePoll() {
clearPoll()
if (unmounted || !files.value.some((file) => file.status === 'scanning') || polls >= SHARE_SCAN_POLL_LIMIT) return
const delays = SHARE_SCAN_POLL_DELAYS_MS
const delay = delays[Math.min(polls, delays.length - 1)] ?? 0
pollTimer = clock.setTimeout(() => {
pollTimer = null
polls += 1
void load(true)
}, delay)
}
/** 링크 정보를 묻는다 — quiet 면 지금 화면을 둔 채 바꾼다(다시 묻기 · 받기 직전). 늦게 온 응답은 버린다 */
async function load(quiet = false): Promise<PublicLinkResult> {
seq += 1
const current = seq
if (!quiet) state.value = { kind: 'loading' }
const result = await share.getPublicLink(token.value)
if (current === seq && !unmounted) {
state.value = result
schedulePoll()
}
return result
}
/** [다시 확인] · [다시 시도] — 처음부터 다시 묻는다 */
function reload() {
polls = 0
void load()
}
/** 같은 글이어도 다시 읽게 비웠다가 넣는다 */
async function announce(text: string) {
announcement.value = ''
await nextTick()
announcement.value = text
}
/** [내려받기] — 누르기 직전에 상태를 한 번 더 묻고, 받을 수 있을 때만 받는다 */
async function download(file: PublicLinkFile) {
if (busyFileId.value) return
busyFileId.value = file.id
try {
const result = await load(true)
const current =
result.kind === 'ok' && result.link.status === 'active'
? result.link.files.find((candidate) => candidate.id === file.id)
: undefined
if (current && isDownloadable(current.status)) share.downloadPublicFile(token.value, file.id)
else void announce(SHARE_PAGE_TEXT.unavailableNow)
} finally {
busyFileId.value = null
}
}
function addHeadMeta(name: string, content: string) {
const meta = document.createElement('meta')
meta.name = name
meta.content = content
document.head.append(meta)
headTags.push(meta)
}
onMounted(() => {
addHeadMeta('robots', 'noindex, nofollow')
addHeadMeta('referrer', 'no-referrer')
})
onBeforeUnmount(() => {
unmounted = true
clearPoll()
headTags.splice(0).forEach((meta) => meta.remove())
})
// 탭 제목 — 보낸 사람을 알면 "김아라 님이 보낸 파일 — ARA SYSTEM"
watch(
senderName,
(name) => {
document.title = name ? SHARE_PAGE_TEXT.tabTitle(name, APP_NAME) : SHARE_PAGE_TEXT.tabTitleDefault(APP_NAME)
},
{ flush: 'post' },
)
// 주소의 토큰이 바뀌면 다시 묻는다 (처음 열 때 포함)
watch(
token,
() => {
polls = 0
void load()
},
{ immediate: true },
)
</script>
<template>
<div class="share">
<header class="share__bar">
<img
:src="logoFull"
:alt="APP_NAME"
width="960"
height="459"
class="share__logo"
>
<span
class="share__bar-divider"
aria-hidden="true"
/>
<span class="share__bar-title">{{ SHARE_PAGE_TEXT.barTitle }}</span>
</header>
<main
class="share__main"
:aria-busy="view === 'loading' ? 'true' : undefined"
>
<div class="share__column">
<!-- 불러오는 중 — 자리만 (화면 낭독기에는 "불러오는 중"을 읽어 준다) -->
<div
v-if="view === 'loading'"
class="share__skeleton"
role="status"
data-share-loading
>
<span class="visually-hidden">{{ SHARE_PAGE_TEXT.loading }}</span>
<span
class="share__bar-skeleton share__bar-skeleton--title"
aria-hidden="true"
/>
<span
class="share__bar-skeleton"
aria-hidden="true"
/>
<span
class="share__bar-skeleton share__bar-skeleton--short"
aria-hidden="true"
/>
</div>
<!-- 정상 -->
<template v-else-if="view === 'active' && link">
<div
class="share__intro"
data-share-view="active"
>
<h1 class="share__title">
{{ SHARE_PAGE_TEXT.title(senderName, formatCount(files.length), totalSize) }}
</h1>
<p class="share__line">
{{ SHARE_PAGE_TEXT.senderLabel }}
<strong>{{ link.sender.name || link.sender.email }}</strong>
<span
v-if="link.sender.name"
class="share__email"
><span class="share__bracket"> &lt;</span>{{ link.sender.email }}<span class="share__bracket">&gt;</span></span>
<span class="share__org"> · {{ APP_NAME }}</span>
</p>
<p class="share__line">
<strong>{{ untilText }}</strong>{{ untilTail }}
</p>
</div>
<ul class="share__files">
<ShareFileRow
v-for="file in files"
:key="file.id"
:file="file"
:href="share.publicFileUrl(token, file.id)"
:busy="busyFileId === file.id"
@download="download"
@blocked="announce"
/>
</ul>
<p
v-if="anyDownloadable"
class="share__hint"
>
{{ SHARE_PAGE_TEXT.resumeHint }}
</p>
<p class="share__safety">
<AppIcon
name="shield"
:size="16"
class="share__safety-icon"
/>
<span>{{ SHARE_PAGE_TEXT.noLogin }}</span>
</p>
</template>
<!-- 모두 검사 중 · 모두 위험 — 제목이 바뀐다 -->
<template v-else-if="(view === 'scanning' || view === 'infected') && link">
<div
class="share__intro"
:data-share-view="view"
>
<h1 class="share__title">
{{ view === 'scanning' ? SHARE_PAGE_TEXT.scanningTitle : SHARE_PAGE_TEXT.infectedTitle }}
</h1>
<p class="share__line">
{{ view === 'scanning' ? SHARE_PAGE_TEXT.scanningMessage : SHARE_PAGE_TEXT.infectedMessage }}
</p>
</div>
<ul class="share__files">
<ShareFileRow
v-for="file in files"
:key="file.id"
:file="file"
:href="share.publicFileUrl(token, file.id)"
:busy="busyFileId === file.id"
@download="download"
@blocked="announce"
/>
</ul>
<div v-if="view === 'scanning'">
<button
type="button"
class="share__action"
data-share-recheck
@click="reload"
>
{{ SHARE_PAGE_TEXT.recheck }}
</button>
</div>
</template>
<!-- 끝난 링크 · 없는 링크 · 요청이 너무 많음 · 그 밖의 실패 -->
<div
v-else
class="share__state"
:data-share-view="view"
>
<AppIcon
v-if="view === 'expired' || view === 'revoked' || view === 'not-found'"
:name="view === 'expired' ? 'clock' : view === 'revoked' ? 'block' : 'link'"
:size="24"
class="share__state-icon"
/>
<h1 class="share__title">
<template v-if="view === 'expired'">
{{ SHARE_PAGE_TEXT.expiredTitle }}
</template>
<template v-else-if="view === 'revoked'">
{{ SHARE_PAGE_TEXT.revokedTitle }}
</template>
<template v-else-if="view === 'not-found'">
{{ SHARE_PAGE_TEXT.notFoundTitle }}
</template>
<template v-else-if="view === 'too-many'">
{{ SHARE_PAGE_TEXT.tooManyTitle }}
</template>
<template v-else>
{{ SHARE_PAGE_TEXT.errorTitle }}
</template>
</h1>
<p class="share__line">
<template v-if="view === 'expired' && link">
{{ SHARE_PAGE_TEXT.expiredMessage(formatKstLongDate(link.expiresAt), senderInline) }}
</template>
<template v-else-if="view === 'revoked'">
{{ SHARE_PAGE_TEXT.revokedMessage(senderInline) }}
</template>
<template v-else-if="view === 'not-found'">
{{ SHARE_PAGE_TEXT.notFoundMessage }}
</template>
<template v-else-if="view === 'too-many'">
{{ SHARE_PAGE_TEXT.tooManyMessage }}
</template>
<template v-else>
{{ SHARE_PAGE_TEXT.errorMessage }}
</template>
</p>
<div v-if="view === 'too-many' || view === 'error'">
<button
type="button"
class="share__action"
data-share-retry
@click="reload"
>
{{ SHARE_PAGE_TEXT.retry }}
</button>
</div>
</div>
<p
v-if="link && view !== 'loading'"
class="share__report"
>
{{ SHARE_PAGE_TEXT.reportBefore }}<a :href="`mailto:${link.reportAddress}`">{{ link.reportAddress }}</a>{{ SHARE_PAGE_TEXT.reportAfter }}
</p>
<p
v-if="english"
class="share__english"
lang="en"
>
{{ english }}
</p>
<p
class="visually-hidden"
role="status"
>
{{ announcement }}
</p>
</div>
</main>
<footer class="share__footer">
{{ APP_NAME }}<template v-if="host">
· {{ host }}
</template>
</footer>
</div>
</template>
<style scoped>
.share {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-surface-panel);
color: var(--color-text);
word-break: keep-all;
overflow-wrap: anywhere;
}
/* 머리 바 — 60, 흰 로고 88 + 가는 선 + "파일 내려받기" */
.share__bar {
display: flex;
align-items: center;
gap: var(--space-14);
min-height: 3.75rem;
padding: 0 var(--space-28);
background: var(--color-brand-surface);
}
.share__logo {
display: block;
width: 5.5rem;
height: auto;
}
.share__bar-divider {
width: var(--border-width);
height: var(--space-18);
background: var(--color-on-brand-track);
}
.share__bar-title {
color: var(--color-on-brand-text);
font-weight: 600;
}
.share__main {
flex: 1 1 auto;
display: flex;
justify-content: center;
padding: var(--space-28) var(--space-24) var(--space-24);
}
/* 가운데 열 640 */
.share__column {
width: 100%;
max-width: 40rem;
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.share__intro {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.share__title {
margin: 0;
font-size: var(--font-size-heading);
line-height: var(--line-height-heading);
font-weight: 650;
letter-spacing: var(--letter-spacing-tighter);
}
.share__line {
margin: 0;
color: var(--color-text-strong);
}
.share__line strong {
font-weight: 600;
}
.share__files {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.share__hint {
margin: calc(var(--space-6) * -1) 0 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 청록 상자 — 로그인이나 비밀번호를 묻지 않는다는 안내 */
.share__safety {
display: flex;
align-items: flex-start;
gap: var(--space-10);
margin: 0;
padding: var(--space-12) var(--space-14);
border-radius: var(--radius-md);
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 500;
}
.share__safety-icon {
margin-top: var(--space-2);
}
/* 끝난 링크 · 없는 링크 등 — 아이콘 · 제목 · 글 */
.share__state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-10);
padding: var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.share__state-icon {
color: var(--color-icon-muted);
}
/* [다시 확인] · [다시 시도] — 흰 테두리 단추 44 */
.share__action {
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-brand-strong);
font-weight: 600;
}
.share__action:hover {
background: var(--color-surface-muted);
}
.share__report {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-secondary);
}
.share__english {
margin: 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.share__footer {
padding: var(--space-8) var(--space-24) var(--space-20);
text-align: center;
font-size: var(--font-size-small);
color: var(--color-icon-muted);
}
.share__skeleton {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.share__bar-skeleton {
width: 70%;
height: var(--space-12);
border-radius: var(--radius-xs);
background: var(--color-skeleton);
}
.share__bar-skeleton--title {
width: 55%;
height: var(--space-20);
}
.share__bar-skeleton--short {
width: 40%;
}
/* 좁은 폭(휴대폰 360) — 머리 52 · 로고 76, 보낸 사람 주소는 이름 아래 줄 */
@media (max-width: 30rem) {
.share__bar {
gap: var(--space-10);
min-height: 3.25rem;
padding: 0 var(--space-16);
}
.share__logo {
width: 4.75rem;
}
.share__bar-divider {
height: var(--space-14);
}
.share__main {
padding: var(--space-20) var(--space-16) var(--space-18);
}
.share__title {
font-size: var(--font-size-title-lg);
}
.share__email {
display: block;
}
.share__bracket,
.share__org {
display: none;
}
}
</style>
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { fail, installMockApi, restoreApiAdapter } from '@/test-utils/mock-api'
import router from './index'
// 라우터 가드 — 공개 화면(11단계 내려받기 /s/:token)은 세션을 묻지 않는다 (계약 4절 "화면 /s/{token}")
// 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 바깥 브라우저에서 /auth/me · /auth/refresh 가 오가지 않게
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
describe('router 가드', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('공개 화면(/s/:token)은 세션 복원 없이 바로 연다', async () => {
const requests = installMockApi(() => fail(401, 'AUTH_001'))
await router.push(`/s/${TOKEN}`)
expect(router.currentRoute.value.name).toBe('public-share')
expect(requests).toEqual([])
})
it('공개 화면 주소는 대소문자를 가린다 — /S/<토큰> 은 공개 화면이 아니다(앞단 nginx 의 /s/ 와 같게)', () => {
expect(router.resolve(`/s/${TOKEN}`).name).toBe('public-share')
expect(router.resolve(`/S/${TOKEN}`).name).toBe('not-found')
})
it('로그인해야 하는 화면은 세션을 복원해 보고, 없으면 로그인으로 보낸다', async () => {
const requests = installMockApi(() => fail(401, 'AUTH_001'))
await router.push('/settings/mail/large-files')
expect(requests.map((request) => request.url)).toEqual(['/auth/me', '/auth/refresh'])
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/settings/mail/large-files')
})
})
+7
View File
@@ -12,6 +12,11 @@ declare module 'vue-router' {
requiresAuth?: boolean
/** 로그인한 사용자는 들어올 필요가 없는 화면 (로그인) */
guestOnly?: boolean
/**
* 로그인 없는 공개 화면 (11단계 내려받기 화면 /s/:token) — 세션 복원(/auth/me · /auth/refresh)을 건너뛴다.
* 받는 쪽은 우리 직원이 아니다 — 쿠키 없는 브라우저에서 갱신 요청이 오가지 않게 (계약 4절 "화면 /s/{token}")
*/
public?: boolean
/** 문서 제목 — "제목 · ARA SYSTEM" */
title?: string
}
@@ -24,6 +29,8 @@ const router = createRouter({
// 네비게이션 가드 — 앱 시작 시 /auth/me 로 세션을 복원한 뒤 첫 네비게이션을 판정한다
router.beforeEach(async (to) => {
// 공개 화면은 세션을 묻지 않는다 — 로그인 없이 그린다 (그 뒤 다른 화면으로 가면 그때 복원한다)
if (to.meta.public) return true
// 첫 호출만 실제로 조회하고, 이후에는 같은 결과를 바로 돌려준다
await useAuth().restoreSession()
+20 -1
View File
@@ -1,6 +1,7 @@
import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
import { PUBLIC_SHARE_ROUTE } from '@/shared/constants/large-file'
import { MAIL_PRINT_ROUTE } from '@/shared/constants/mail-print'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import {
@@ -95,7 +96,7 @@ export const routes: RouteRecordRaw[] = [
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 알림) + 하위 화면.
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 보내기 · 읽기 · 차단한 주소 · 대용량 첨부 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
path: '/settings',
component: () => import('@/pages/settings/SettingsLayout.vue'),
@@ -139,6 +140,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/pages/settings/MailBlocksPage.vue'),
meta: { title: '차단한 주소' },
},
{
// 대용량 첨부 (11단계 — 디자인 README "11단계 › 설정 › 메일 › 대용량 첨부") — 보관 사용량 · 보내지 않은 파일 · 보낸 링크
path: 'mail/large-files',
name: SETTINGS_ROUTES.largeFiles,
component: () => import('@/pages/settings/MailLargeFilesPage.vue'),
meta: { title: '대용량 첨부' },
},
{
path: 'mail/notify',
name: SETTINGS_ROUTES.notify,
@@ -147,6 +155,17 @@ export const routes: RouteRecordRaw[] = [
},
],
},
{
// 공개 내려받기 화면 (11단계 — 계약 4절 "공개 내려받기" · 디자인 README "11단계 › 내려받기 화면") — 로그인 없는 독립 화면.
// 받는 사람(바깥 거래처)이 메일 속 링크로 연다 — 세션 복원(/auth/me · /auth/refresh)과 로그인 화면 보내기를 건너뛴다(public).
// 운영의 공개 호스트(files.)에서는 이 화면과 그 API(/api/public-links/…)만 열린다 — 앞단이 그 밖을 막는다
// 대소문자를 가린다(sensitive) — 앞단 nginx 의 /s/ 는 소문자만이라, 포털이 닫아도 /S/<토큰> 이 SPA 로 와 이 화면이 그려지지 않게(2026-10-30 리뷰)
path: '/s/:token',
sensitive: true,
name: PUBLIC_SHARE_ROUTE,
component: () => import('@/pages/share/SharePage.vue'),
meta: { public: true, title: '파일 내려받기' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
+12
View File
@@ -295,6 +295,18 @@ export const ICONS = {
clock: [circle(12, 12, 10), polyline('12 6 12 12 16 14')],
/** 「보낸 사람 차단」 · 차단한 보낸 사람의 메일 안내 줄 · 설정의 차단한 주소 (원 + 빗금) */
block: [circle(12, 12, 10), line(4.93, 4.93, 19.07, 19.07)],
// ── 11단계: 대용량 첨부 (디자인 원본 ComposeLargeFiles · MailReadLargeFiles · SharePage) ──
/** 쓰기 화면의 링크 파일 [멈춤] */
pause: [rect(6, 4, 4, 16), rect(14, 4, 4, 16)],
/** 쓰기 화면의 링크 파일 [이어 올리기] */
play: [polygon('6 4 20 12 6 20 6 4')],
/** 위험 판정 파일 (경고 삼각형) */
'alert-triangle': [
path('M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'),
line(12, 9, 12, 13),
line(12, 17, 12.01, 17),
],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
+333
View File
@@ -0,0 +1,333 @@
// 대용량 첨부 상수 — 한도 · 형식 · 조각 · 화면 글 (계약 4절 "대용량 첨부 — 11단계" · 디자인 README "11단계")
// 한도의 실제 값은 서버가 준다(GET /file-quota). 아래 대체값은 그 값을 못 받았을 때만 쓴다 — 서버 기본값과 같다.
import { BYTES_PER_MB, MAIL_ATTACHMENT_EXTENSIONS } from '@/shared/constants/mail'
const BYTES_PER_GB = 1024 * BYTES_PER_MB
/** 조각 크기 16MiB — 계약 상수 (서버 응답의 chunkSize 와 같다. 마지막 조각만 짧다) */
export const LARGE_FILE_CHUNK_SIZE = 16 * BYTES_PER_MB
/** GET /file-quota 를 못 받았을 때의 한도 — 서버 기본값 (MAIL_LARGE_FILE_* · 계약 4절 "한도") */
export const LARGE_FILE_FALLBACK_LIMITS = {
/** 한 파일 2,047MB — ClamAV 가 검사할 수 있는 2GiB 아래 */
maxFileBytes: 2047 * BYTES_PER_MB,
/** 메일 한 통에 새로 싣는 대용량 파일 수 */
maxFilesPerMessage: 10,
/** 직원당 보관량 */
limitBytes: 10 * BYTES_PER_GB,
/** 보낸 날부터 받을 수 있는 날 수 */
retentionDays: 30,
} as const
/**
* 메일에는 담지 못하고 링크로만 보내는 형식 — 일반 첨부 허용 목록(MAIL_ATTACHMENT_EXTENSIONS)에 더한다.
* ⚠️ 짝: 백엔드의 대용량 파일 형식 정책(계약 4절 "한도"의 형식 — 실행 형식 · 디스크 이미지는 받지 않는다). 한쪽을 고치면 둘 다
*/
export const LARGE_FILE_EXTRA_EXTENSIONS: readonly string[] = ['mp4', 'mov', 'dwg', 'dxf', 'psd', 'ai', '7z']
/** 쓰기 화면의 파일 고르기 창 — 일반 첨부 + 링크로만 보내는 형식 (두 목록을 합친 형식만 보인다) */
export const COMPOSE_ATTACHMENT_ACCEPT = [...MAIL_ATTACHMENT_EXTENSIONS, ...LARGE_FILE_EXTRA_EXTENSIONS]
.map((ext) => `.${ext}`)
.join(',')
// ── 올리기 (계약 4절 "올리기" · 디자인 README "11단계 › 쓰기 화면") ─────────────
/** 조각 하나의 요청 시간 한도 — 16MiB 를 느린 망에서도 (앞단 nginx 의 읽기 90초와 별개로 브라우저가 기다리는 시간) */
export const LARGE_FILE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
/** 끝내기 · 상태 · 만들기 요청 시간 한도 */
export const LARGE_FILE_REQUEST_TIMEOUT_MS = 30 * 1000
/** 잠깐 끊겼을 때 저절로 다시 보내는 간격 — 2 · 5 · 15 · 30초, 그 뒤는 30초마다 (실시간 알림의 다시 잇기와 같은 결) */
export const LARGE_FILE_RETRY_DELAYS_MS: readonly number[] = [2_000, 5_000, 15_000, 30_000]
/** 저절로 다시 잇기를 이만큼 하지 못했으면 멈추고 [다시 시도] (올린 만큼은 남는다) */
export const LARGE_FILE_STALL_AFTER_MS = 60_000
/** 서버가 조각 내용을 거절(FILE_004 — 보내는 중 손상)하면 그 조각만 다시 — 이만큼 거듭되면 멈춘다 */
export const LARGE_FILE_DIGEST_RETRY_LIMIT = 3
/**
* 한 쓰기 화면에서 조각을 함께 보내는 파일 수 — 서버의 직원당 동시 조각 4개 안 (파일마다 한 번에 한 조각).
* 만들기(POST /file-uploads)는 이 수와 상관없이 고르자마자 한다 — 서버는 동시 수를 조각 요청으로만 센다(계약 4절 "요청 수 · 동시 수")
*/
export const LARGE_FILE_PARALLEL_UPLOADS = 3
/** 처음 몇 초는 남은 시간 대신 "시간 계산 중" — 속도를 재기에 짧다 */
export const LARGE_FILE_ETA_WARMUP_MS = 3_000
/** 남은 시간은 최근 이만큼의 속도로 센다 */
export const LARGE_FILE_ETA_WINDOW_MS = 20_000
/** 다 올린 파일의 보안 검사가 끝났는지 다시 묻는 간격 — 쓰기 화면이 떠 있는 동안만 (위험 판정은 실시간 알림이 먼저 알린다) */
export const LARGE_FILE_SCAN_POLL_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000]
/** 검사 상태를 다시 묻는 최대 횟수 (2GB 는 분 단위가 걸린다 — 그 뒤는 다시 열 때 안다) */
export const LARGE_FILE_SCAN_POLL_LIMIT = 30
/** 보관 사용량이 이 비율을 넘으면 링크 묶음 머리 오른쪽에 "보관 8.4 / 10 GB" (갈색 굵게) */
export const LARGE_FILE_QUOTA_WARNING_RATIO = 0.8
/** 조각 요청의 내용 지문 머리글 (RFC 9530) — 웹은 늘 보낸다 */
export const LARGE_FILE_DIGEST_HEADER = 'Content-Digest'
export const LARGE_FILE_CHUNK_CONTENT_TYPE = 'application/octet-stream'
/** 서버 덩이가 텍스트 본문에 더하는 길이의 어림 — 파일마다 이름 + 이만큼 (보내기 직전에 인용을 그만큼 더 줄인다) */
export const LARGE_FILE_BLOCK_TEXT_PER_FILE = 24
/** 덩이 머리 · 주소 · 안내 · 영어 한 줄 · 가르는 줄의 어림 (주소 길이는 운영 설정이라 넉넉히) */
export const LARGE_FILE_BLOCK_TEXT_BASE = 600
// ── 공개 내려받기 (계약 4절 "공개 내려받기") ─────────────
/** 공개 화면 라우트 이름 — /s/:token (로그인 없음 · 세션 복원을 건너뛴다) */
export const PUBLIC_SHARE_ROUTE = 'public-share'
/** 공개 화면 주소의 경로 — /s/ + 토큰 43자(base64url) */
export const SHARE_PATH_PATTERN = /^\/s\/[A-Za-z0-9_-]{43}$/
/** 토큰 모양 — 아니면 서버에 묻지 않는다 */
export const SHARE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
/** 인용 속 링크의 상태를 한 번에 묻는 수 · 주소 길이 (POST /share-link-lookups) */
export const SHARE_LOOKUP_MAX_URLS = 20
export const SHARE_LOOKUP_MAX_URL_LENGTH = 300
/** 검사 중인 파일이 있으면 화면이 다시 묻는 간격 — 링크 정보는 IP 마다 1분 60회 안 */
export const SHARE_SCAN_POLL_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000]
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}개까지)`,
normalGroup: (count: string) => `메일에 담는 파일 ${count}개`,
normalTotal: (used: string, limit: string) => `합계 ${used} / ${limit}`,
linkGroup: (count: string) => `링크로 보내는 파일 ${count}개`,
linkGroupHint: (days: string, until: string) =>
`받는 사람은 보낸 날부터 ${days}일 동안 내려받습니다 — 오늘 보내면 ${until}까지`,
/** 보관 사용량이 80%를 넘을 때 링크 묶음 머리 오른쪽 */
quotaWarning: (usage: string) => `보관 ${usage}`,
/** 합계 때문에 저절로 링크로 바꿨을 때 — 칸 아래 ⓘ 한 줄 (role=status) */
switchedByTotal: (name: string, size: string, total: string) =>
`‘${name}’(${size})는 메일에 담으면 합계가 ${total} 를 넘어 링크로 보냅니다.`,
sendAsLink: '링크로 보내기',
pause: '멈춤',
resume: '이어 올리기',
retry: '다시 시도',
repick: '파일 고르기',
remove: '빼기',
uploading: (progress: string) => `올리는 중 · ${progress}`,
restarting: (progress: string) => `처음부터 다시 올리는 중 · ${progress}`,
etaCalculating: '시간 계산 중',
etaSeconds: (seconds: string) => `약 ${seconds}초 남음`,
etaMinutes: (minutes: string) => `약 ${minutes}분 남음`,
etaHours: (hours: string) => `약 ${hours}시간 남음`,
etaHoursMinutes: (hours: string, minutes: string) => `약 ${hours}시간 ${minutes}분 남음`,
finishing: '마무리하는 중',
/** 만들었고 조각을 보낼 차례를 기다린다 (함께 보내는 3개 밖) — 남은 시간 자리 */
queued: '차례를 기다리는 중',
reconnecting: (progress: string) => `연결이 끊겨 다시 잇는 중… · ${progress} 올림`,
paused: (progress: string) => `멈춤 · ${progress} 올림`,
stalled: (uploaded: string) => `올리지 못하고 멈췄습니다 — 올린 ${uploaded} 는 남아 있습니다.`,
stalledEmpty: '올리지 못하고 멈췄습니다.',
waitingFile: (progress: string) =>
`올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다 · ${progress} 올림`,
repickMismatch: '고른 파일이 멈춘 파일과 다릅니다(이름 · 크기). 같은 파일을 골라 주세요.',
scanning: (size: string) => `${size} · 보안 검사 중 — 끝나지 않아도 보낼 수 있습니다`,
unscannable: (size: string) => `${size} · 검사하지 못함 — 암호가 걸린 압축 파일 등`,
keptInDraft: (size: string, until: string) => `${size} · 이 임시 저장본에 ${until}까지 남습니다`,
infected: '위험한 내용이 발견되어 보낼 수 없습니다. 이 파일을 빼 주세요.',
missingExpired: '기간이 지나 지운 파일입니다 — 빼고 다시 올려 주세요.',
missingDeleted: '설정 › 대용량 첨부에서 지운 파일입니다 — 빼고 다시 올려 주세요.',
missingUnknown: '서버에서 찾을 수 없는 파일입니다 — 빼고 다시 올려 주세요.',
tooLarge: (max: string) => `파일 하나는 ${max} 까지 보낼 수 있습니다.`,
tooMany: (count: string) => `링크로 보내는 파일은 메일 한 통에 ${count}개까지입니다.`,
overQuota: (limit: string) =>
`대용량 보관 한도(${limit})를 넘어 올릴 수 없습니다. 보내지 않은 파일을 지우거나 링크를 끊으면 바로 공간이 생깁니다.`,
quotaLink: '대용량 첨부 보기',
unnamed: '(이름 없는 파일)',
/** 화면 낭독기 — 10% 마다 */
progressAnnounce: (name: string, percent: string) => `${name} ${percent}% 올림`,
/** 다시 고르기 창의 이름 (보이지 않는 입력) */
repickLabel: (name: string) => `${name} 다시 고르기`,
/** [멈춤] 단추 이름 — "설계도_v3.dwg 올리기 멈춤" */
pauseLabel: (name: string) => `${name} 올리기 멈춤`,
/** 서버가 올리던 파일을 잊었다(404 — 마지막 조각 뒤 24시간 · 설정에서 지움) — 멈춘 줄 글. [다시 시도]는 처음부터 새로 올린다 */
restartNeeded: '서버에서 올리던 파일을 찾을 수 없습니다 — 다시 시도하면 처음부터 올립니다.',
} as const
/** [보내기]를 막는 까닭 — 단추 옆 글 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다") */
export const SEND_BLOCK_TEXT = {
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}까지`,
revoked: (label: string) => `인용 속 ${label} 링크는 공유가 멈춰 받을 수 없습니다.`,
expired: (label: string) => `인용 속 ${label} 링크는 기간이 지나 받을 수 없습니다.`,
/** 파일 이름을 모를 때 */
unnamedLabel: '대용량 첨부',
label: (name: string) => `‘${name}’`,
labelWithOthers: (name: string, others: string) => `‘${name}’ 외 ${others}개`,
} as const
/** 쓰기 화면 나가기 확인 — 올리는 중인 대용량 파일이 있을 때 (디자인 원본 ComposeLargeFiles) */
export const COMPOSE_LEAVE_UPLOADING_DIALOG = {
title: '올리는 중인 파일이 있습니다',
message: '이 화면을 나가면 올리기가 멈춥니다. 임시보관함에서 이 메일을 다시 열고 같은 파일을 고르면 이어서 올립니다.',
stay: '계속 쓰기',
leave: '나가기',
} as const
// ── 화면 글 — 읽기 화면 (디자인 원본 MailReadLargeFiles) ─────────────
export const MESSAGE_LARGE_FILE_TEXT = {
until: (date: string) => `${date}까지`,
downloads: (count: string) => `${count}회 받음`,
notDownloaded: '아직 받지 않음',
scanning: '보안 검사 중 — 끝나면 받을 수 있습니다',
infected: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등',
expired: (date: string) => `기간 지남 — ${date}까지였습니다`,
revoked: '공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
/** 보낸 사람 자신 — 끊은 날을 알면 그 날짜(디자인 원본 MailReadLargeFiles 셋째 줄), 모르면 날짜 없이 */
revokedOwn: (date: string | null) => (date ? `공유 멈춤 — ${date}에 끊었습니다` : '공유 멈춤 — 링크를 끊었습니다'),
limitReached: '횟수 초과 — 더 내려받을 수 없습니다',
missing: '찾을 수 없음 — 더 내려받을 수 없습니다',
download: (name: string) => `${name} 다운로드`,
/** 보낸 사람 자신 — 첨부 칸 아래 한 줄 */
linkNote: (count: string) => `링크로 보낸 파일 ${count}개 — 받는 사람 모두 같은 링크로 받습니다.`,
linkNoteCut: (count: number) =>
count === 1 ? '끊으면 받을 수 없습니다.' : count === 2 ? '끊으면 둘 다 받을 수 없습니다.' : '끊으면 모두 받을 수 없습니다.',
copyLink: '링크 복사',
copied: '링크를 복사했습니다.',
copyFailed: '링크를 복사하지 못했습니다.',
revoke: '링크 끊기',
/** 본문의 덩이를 접는 줄 (본문 틀 안 — 스크립트 없이 details · summary) */
foldLabel: '본문의 대용량 첨부 안내',
foldHint: '(접어 둠) — 파일은 아래 첨부 칸에 있습니다',
foldOpen: '펼치기',
foldClose: '접기',
} as const
/** 링크 끊기 확인 (alertdialog 400 — 읽기 화면 · 설정 공용) */
export const LARGE_FILE_REVOKE_TEXT = {
title: '대용량 첨부 링크를 끊을까요?',
/** 읽기 화면 — 파일 이름을 늘어놓는다 */
messageWithFiles: (count: string, names: string) =>
`받는 사람 모두 이 메일의 파일 ${count}개(${names})를 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.`,
/** 설정 — 메일 제목으로 */
messageWithSubject: (subject: string, count: string) =>
`‘${subject}’의 파일 ${count}개를 받는 사람 모두 더는 내려받을 수 없습니다. 이미 받은 파일은 되돌릴 수 없고, 끊은 링크는 다시 열 수 없습니다.`,
nameSeparator: ' · ',
confirm: '링크 끊기',
cancel: '취소',
done: '링크를 끊었습니다. 받는 사람은 더 이상 내려받을 수 없습니다.',
} as const
/** 실시간 알림 large-file — 위험 판정 (계약 4절 "악성 검사") */
export const LARGE_FILE_ALERT_TEXT = {
infected: (name: string) => `올린 대용량 파일 ‘${name}’에서 위험한 내용이 발견되어 막았습니다.`,
action: '대용량 첨부 보기',
} as const
// ── 화면 글 — 설정 › 메일 › 대용량 첨부 (디자인 원본 SettingsLargeFiles) ─────────────
export const LARGE_FILE_SETTINGS_TEXT = {
title: '대용량 첨부',
description: (days: string) =>
`메일에 링크로 보낸 큰 파일입니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받고, 링크를 끊으면 바로 받을 수 없습니다.`,
usageLabel: '보관 사용량',
usageSplit: (sent: string, unsent: string) => `보낸 링크 ${sent} · 보내지 않은 파일 ${unsent}(임시 저장 · 올리다 멈춤).`,
usageNote: '기간이 지나거나, 링크를 끊거나, 보내지 않은 파일을 지우면 바로 줄어듭니다.',
unsentTitle: (count: string) => `보내지 않은 파일 ${count}개`,
unsentHint: (size: string) => `${size} · 임시 저장한 메일에 실렸거나 올리다 멈춘 파일 — 지우면 바로 공간이 생깁니다`,
unsentList: '보내지 않은 파일',
unsentUploading: '올리다 멈춤',
unsentReady: '다 올림',
unsentScanning: '보안 검사 중',
unsentUnscannable: '검사하지 못함',
unsentInfected: '위험 — 보낼 수 없음',
partUploaded: (bytes: string) => `${bytes} 올림`,
remainUntil: (when: string) => `${when}까지 남음`,
afterLastChunk: '마지막으로 올린 뒤 24시간',
delete: '지우기',
deleteLabel: (name: string) => `${name} 지우기`,
deleteTitle: '보내지 않은 파일을 지울까요?',
deleteMessage: (name: string, size: string) =>
`‘${name}’(${size}) 파일을 지웁니다. 이 파일을 실은 임시 저장본이 있으면 열 때 첨부 칸에 ‘지운 파일’로 보입니다. 지운 파일은 되돌릴 수 없습니다.`,
deleteConfirm: '지우기',
deleteCancel: '취소',
deleted: (name: string) => `‘${name}’ 파일을 지웠습니다.`,
linksTitle: (count: string) => `보낸 링크 ${count}개`,
linksHint: '메일마다 한 덩이 · 최근에 보낸 차례',
linksList: '링크로 보낸 메일',
sentLine: (day: string, count: string, total: string) => `${day} 보냄 · 파일 ${count}개 · ${total}`,
until: (date: string) => `${date}까지`,
daysLeft: (days: string) => `${days}일 남음`,
lastDay: '오늘까지',
revokeLabel: (subject: string) => `${subject} 링크 끊기`,
linkActive: '사용 중',
linkRevoked: '공유 멈춤',
/** 끝난 링크 머리 오른쪽 둘째 줄 — "10월 22일에 끊음" (한국 날짜) */
revokedOn: (date: string) => `${date}에 끊음`,
linkExpired: '기간 지남',
expiredUntil: (date: string) => `${date}까지였습니다`,
fileScanning: '검사 중',
fileInfected: '위험',
fileInfectedNote: '내려받기를 막음',
fileUnscannable: '검사하지 못함',
fileLimitReached: '횟수 초과',
fileMissing: '찾을 수 없음',
downloads: (count: string) => `${count}회 받음`,
notDownloaded: '아직 받지 않음',
lastDownloaded: (when: string) => `마지막 ${when}`,
more: '더 보기',
moreHint: '최근에 보낸 차례 · 20개씩. 끝난 링크는 90일 뒤 목록에서 빠집니다.',
emptyTitle: '링크로 보낸 파일이 없습니다',
emptyHint: (max: string, days: string) =>
`메일 쓰기에서 ${max} 가 넘는 파일을 고르면 링크로 보냅니다. 받는 사람은 보낸 날부터 ${days}일 동안 내려받을 수 있습니다.`,
noSubject: '(제목 없음)',
} as const
// ── 화면 글 — 공개 내려받기 화면 (디자인 원본 SharePage) ─────────────
export const SHARE_PAGE_TEXT = {
barTitle: '파일 내려받기',
/** 탭 제목 — "김아라 님이 보낸 파일 — ARA SYSTEM" */
tabTitle: (sender: string, app: string) => `${sender} 님이 보낸 파일 — ${app}`,
tabTitleDefault: (app: string) => `파일 내려받기 — ${app}`,
title: (sender: string, count: string, total: string) => `${sender} 님이 보낸 파일 ${count}개 (${total})`,
senderLabel: '보낸 사람',
until: (date: string) => `${date}까지`,
untilTail: (days: string) => ` 내려받을 수 있습니다 (한국 시간) · ${days}일 남음`,
untilTailLastDay: ' 내려받을 수 있습니다 (한국 시간) · 오늘까지',
clean: '보안 검사 · 이상 없음',
scanning: '보안 검사 중',
unscannable: '검사하지 못함 — 암호가 걸린 압축 파일 등 안을 볼 수 없는 파일입니다. 보낸 사람을 아는 경우에만 여세요.',
infected: '위험 — 막았습니다',
limitReached: '내려받을 수 있는 횟수를 넘었습니다. 보낸 사람에게 다시 보내 달라고 요청해 주세요.',
missing: '찾을 수 없는 파일입니다 — 더 내려받을 수 없습니다.',
download: '내려받기',
downloadLabel: (name: string) => `${name} 내려받기`,
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.`,
scanningTitle: '보안 검사 중입니다',
scanningMessage: '파일을 검사하고 있습니다. 몇 분 뒤 이 화면을 다시 열어 주세요.',
scanningEnglish: 'Security scan in progress — please check again in a few minutes.',
recheck: '다시 확인',
infectedTitle: '내려받을 수 없는 파일입니다',
infectedMessage: '보안 검사에서 위험한 내용이 발견되어 막았습니다. 보낸 사람에게 알렸습니다.',
infectedEnglish: 'This file was blocked by a security scan.',
expiredTitle: '내려받을 수 있는 기간이 지났습니다',
expiredMessage: (date: string, sender: string) =>
`${date}까지 받을 수 있었던 파일입니다. 파일은 지워져 다시 받을 수 없습니다. 필요하면 보낸 사람(${sender})에게 다시 보내 달라고 요청해 주세요.`,
expiredEnglish: (date: string) => `This link expired on ${date} (KST).`,
revokedTitle: '보낸 사람이 공유를 멈췄습니다',
revokedMessage: (sender: string) => `필요하면 보낸 사람(${sender})에게 문의해 주세요.`,
revokedEnglish: 'The sender stopped sharing these files.',
notFoundTitle: '찾을 수 없는 링크입니다',
notFoundMessage: '메일의 주소가 끝까지 복사되었는지 확인해 주세요.',
notFoundEnglish: 'Link not found — check that the whole address was copied.',
tooManyTitle: '요청이 너무 많습니다',
tooManyMessage: '잠시 뒤 다시 시도해 주세요.',
tooManyEnglish: 'Too many requests — please try again shortly.',
errorTitle: '화면을 불러오지 못했습니다',
errorMessage: '잠시 뒤 다시 시도해 주세요.',
errorEnglish: 'Something went wrong — please try again shortly.',
retry: '다시 시도',
/** 누르기 직전에 다시 물었더니 받을 수 없게 됐다 (role=status) */
unavailableNow: '이 파일은 지금 내려받을 수 없습니다. 아래 안내를 확인해 주세요.',
loading: '불러오는 중',
/** 보낸 사람 이름 · 주소를 문장 안에 — "김아라, ara.kim@…" */
senderInline: (name: string | null, email: string) => (name ? `${name}, ${email}` : email),
} as const
@@ -1,5 +1,5 @@
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터" ·
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소")
// "9단계 › 설정 · 보내기 · 읽기 · 차단한 주소" · "11단계 › 설정 › 메일 › 대용량 첨부" — 차단한 주소 아래)
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
@@ -15,6 +15,8 @@ export const SETTINGS_ROUTES = {
sendRead: 'settings-mail-send-read',
/** 차단한 주소 (9단계) */
blocks: 'settings-mail-blocks',
/** 대용량 첨부 — 보관 사용량 · 보내지 않은 파일 · 보낸 링크 (11단계) */
largeFiles: 'settings-mail-large-files',
notify: 'settings-mail-notify',
} as const
@@ -46,6 +48,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
{ routeName: SETTINGS_ROUTES.filters, label: '필터', icon: 'filter' },
{ routeName: SETTINGS_ROUTES.sendRead, label: '보내기 · 읽기', icon: 'send' },
{ routeName: SETTINGS_ROUTES.blocks, label: '차단한 주소', icon: 'block' },
{ routeName: SETTINGS_ROUTES.largeFiles, label: '대용량 첨부', icon: 'link' },
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
],
},
+179
View File
@@ -0,0 +1,179 @@
// 대용량 첨부 공유 타입 — docs/mail/api-contract.md 4절 "대용량 첨부 — 11단계"와 같은 구조
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
import type { CursorPaginationQuery } from '@/shared/types/pagination'
/** 검사 중 · 깨끗함 · 위험 · 검사하지 못함 (암호 압축 · 검사 한도 밖 · 검사기를 쓰지 않는 개발) */
export type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable'
/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */
export interface LargeFile {
/** 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) — 발송 · 임시 저장의 largeFileIds 에 쓴다 */
id: string
/** 서버가 정리한 이름 */
name: string
/** 바이트 */
size: number
status: 'uploading' | 'ready'
/** 16,777,216 — 마지막 조각만 짧다 */
chunkSize: number
/** ceil(size / chunkSize) */
chunkCount: number
/** 받은 조각 번호(0 부터, 오름차순) — ready 면 모두 */
receivedChunks: number[]
/** 받은 맨 앞 조각(0번)의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다 */
firstChunkSha256: string | null
/** ready 일 때만 (uploading 은 null) */
scanStatus: ScanStatus | null
createdAt: string
/** 이대로 두면 지워지는 때 (계약 4절 "기간 · 보관 · 정리") */
expiresAt: string
/** 만들 때 받은 File.lastModified (ms) */
lastModified: number | null
}
/** 임시 저장본이 가리키는데 이제 없는 파일 — 지운 파일(설정 · 쓰기 화면에서 지움) · 기간이 지남 */
export interface MissingLargeFile {
id: string
status: 'missing'
reason: 'deleted' | 'expired'
/** 기록이 남아 있으면(지운 뒤 30일) 이름 · 크기 */
name: string | null
size: number | null
}
/** GET /drafts/{id} 의 largeFiles 항목 */
export type DraftLargeFile = LargeFile | MissingLargeFile
/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */
export type SharedFileStatus =
| 'available'
| 'unscannable'
| 'scanning'
| 'infected'
| 'limit-reached'
| 'revoked'
| 'expired'
| 'missing'
/** 메일 속 대용량 첨부 하나 — MessageDetail.largeFiles (링크 주인이 그 메일의 보낸 사람일 때만 온다) */
export interface MessageLargeFile {
linkId: string
fileId: string
/** 서버 기록의 이름 — 메일 글이 아니다 */
name: string
size: number
status: SharedFileStatus
/** 링크가 끝나는 때 */
expiresAt: string
/** 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null. 읽기 화면은 날짜를 쓰지 않는다 (README "공유 멈춤 — 보낸 사람이 링크를 끊었습니다") */
revokedAt: string | null
/** 공개 화면 주소 `<SHARE_PUBLIC_URL>/s/<token>` — 본문 덩이의 그 주소. 같은 링크의 파일은 같은 주소 */
url: string
/** 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null */
downloads: {
/** 받은 수 (받기 시작한 수) */
count: number
lastDownloadedAt: string | null
} | null
}
/** GET /file-quota */
export interface FileQuota {
/** 지금 잡고 있는 양 — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 */
usedBytes: number
/** 직원당 보관량 한도 */
limitBytes: number
/** 한 파일 */
maxFileBytes: number
/** 메일 한 통에 새로 싣는 대용량 파일 수 */
maxFilesPerMessage: number
/** 16,777,216 — 올리기 응답의 chunkSize 와 같다 */
chunkSize: number
/** 보낸 날부터 받을 수 있는 날 수 (30) */
retentionDays: number
}
/** 링크 안 파일 하나 — 보낸 사람 목록(ShareLink.files) */
export interface SharedFile {
id: string
name: string
size: number
status: SharedFileStatus
downloadCount: number
lastDownloadedAt: string | null
}
/** GET /share-links 의 항목 — 내가 만든 링크 (보낸 메일 한 통) */
export interface ShareLink {
id: string
/** 보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null */
sentEmailId: string | null
/** 보낸 메일 제목 — 엔진에서 읽는다 (지웠거나 읽지 못하면 null) */
subject: string | null
/** 보낸 때 (예약은 보낼 시각) */
sentAt: string
expiresAt: string
/** revoked = 보낸 사람(또는 운영)이 끊었다 */
status: 'active' | 'expired' | 'revoked'
/** 끊은 때 — revoked 일 때만 값이 있다 (설정의 "10월 22일에 끊음") */
revokedAt: string | null
/** 링크의 파일 (보낸 차례) */
files: SharedFile[]
}
/** POST /share-link-lookups 본문 — 인용 속 링크의 상태 (누구의 링크든 상태 · 끝나는 날만) */
export interface ShareLinkLookupRequest {
/** 1~20개, 겹치지 않게 — 본문(인용)에서 찾은 공개 화면 주소, 한 개 300자까지 */
urls: string[]
}
export interface ShareLinkState {
/** 보낸 그대로 */
url: string
/** unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크 */
status: 'active' | 'expired' | 'revoked' | 'unknown'
/** unknown 이면 null */
expiresAt: string | null
}
/** POST /share-link-lookups 응답 — 요청 차례 그대로 */
export interface ShareLinkLookupResult {
links: ShareLinkState[]
}
/** 공개 화면의 파일 하나 — 끝난 링크는 파일이 오지 않는다 */
export interface PublicLinkFile {
id: string
name: string
size: number
status: Exclude<SharedFileStatus, 'revoked' | 'expired'>
}
/** GET /public-links/{token} — 로그인 없음 */
export interface PublicLink {
status: 'active' | 'expired' | 'revoked'
/** 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다 */
sender: { name: string | null; email: string }
sentAt: string
expiresAt: string
/** active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다) */
files: PublicLinkFile[]
/** 신고 · 문의 주소 */
reportAddress: string
}
/** POST /file-uploads 본문 */
export interface FileUploadCreate {
/** 1~255자 (JS 문자열 길이) */
name: string
/** 바이트 — 1 이상 · 한 파일 한도 이하의 정수 */
size: number
/** 쓰임 — 11단계는 'mail' 만 */
purpose: 'mail'
/** 브라우저 File.lastModified (ms, 0 이상 정수) */
lastModified?: number | null
}
/** GET /file-uploads · /share-links 쿼리 */
export type LargeFileListQuery = CursorPaginationQuery
+21
View File
@@ -2,6 +2,7 @@
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
import type { ContactEmailLabel } from '@/shared/types/contacts'
import type { DraftLargeFile, MessageLargeFile } from '@/shared/types/large-file'
import type { CursorPaginationQuery } from '@/shared/types/pagination'
export interface Address {
@@ -102,6 +103,11 @@ export interface MessageDetail extends MessageSummary {
isBodyTruncated?: boolean
/** 본문 그림(isInline)도 함께 온다 — 읽기 화면은 listedAttachments 로 거른다 */
attachments: MessageAttachment[]
/**
* 대용량 첨부 (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 링크 주인이 그 메일의 보낸 사람이고 서버가 만든 덩이일 때만 온다.
* 그때 htmlBody 의 덩이 바깥 요소에 data-ara-large-files 가 붙는다(읽기 화면이 접는다). 필드가 없으면(11단계 앞 서버) 없는 것으로 본다
*/
largeFiles?: MessageLargeFile[]
}
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
@@ -194,6 +200,16 @@ export interface SendMessagePayload {
draftId?: string
/** 예약 발송(UTC ISO) — 지금부터 5분 뒤 ~ 7일 안. 밖이면 400 VAL_001 (9단계 "보내기 취소 · 예약 발송") */
sendAt?: string
/**
* 이 메일에 새로 싣는 내 대용량 파일(올리기 id — 11단계, 계약 4절 "발송 · 임시 저장과 함께") — 겹치지 않게.
* 발송은 다 올렸고 위험이 아닌 파일만(아니면 400 MAIL_016), 임시 저장은 올리는 중이어도 된다. 없으면 보내지 않는다
*/
largeFileIds?: string[]
/**
* 텍스트 본문(body)에서 인용이 시작하는 자리(JS 문자열 위치 · 줄의 처음) — 서버가 덩이를 넣을 자리.
* 서식 본문은 인용 바로 앞의 <div data-ara-quote-start></div> 표시가 같은 일을 한다. 대용량 파일이 없으면 둘 다 보내지 않는다
*/
bodyQuoteStart?: number
}
/** 엔진이 받는 사람을 받지 않은 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음(한도 등) */
@@ -279,6 +295,11 @@ export interface Draft {
inReplyTo: string | null
/** 마지막으로 저장한 시각 */
updatedAt: string
/**
* 이 저장본에 실은 대용량 파일 (11단계) — 그 직원의 것만. 올리는 중이면 LargeFile(uploading — 같은 파일을 다시 고르면 이어 올린다),
* 지웠거나 기간이 지났으면 MissingLargeFile. 필드가 없으면(11단계 앞 서버) 없는 것으로 본다
*/
largeFiles?: DraftLargeFile[]
}
/** POST /drafts · PUT /drafts/{id} 본문 — 발송 본문과 같은 필드 · 한도 (받는 사람 0명 가능, draftId 없음) */
+7 -1
View File
@@ -13,6 +13,8 @@ export interface ComposeFormSnapshot {
subject: string
/** 첨부 목록 항목의 키 — 쓰기 화면은 저장본에 싣는 첨부(올리기를 마친 것 · 원본 첨부)만 넘긴다 */
attachments: readonly number[]
/** 저장본에 싣는 대용량 파일의 올리기 id (11단계 — 올리는 중인 것도 실린다) */
largeFiles: readonly string[]
}
export interface ComposeFormFields {
@@ -21,6 +23,8 @@ export interface ComposeFormFields {
bcc: readonly RecipientToken[]
subject: string
attachments: readonly { key: number }[]
/** 대용량 파일의 올리기 id — 없으면 빈 목록 */
largeFiles?: readonly string[]
}
const labels = (tokens: readonly RecipientToken[]) => tokens.map((token) => token.label)
@@ -32,6 +36,7 @@ export function snapshotComposeForm(fields: ComposeFormFields): ComposeFormSnaps
bcc: labels(fields.bcc),
subject: fields.subject,
attachments: fields.attachments.map((item) => item.key),
largeFiles: [...(fields.largeFiles ?? [])],
}
}
@@ -46,6 +51,7 @@ export function isSameComposeForm(a: ComposeFormSnapshot, b: ComposeFormSnapshot
sameList(a.to, b.to) &&
sameList(a.cc, b.cc) &&
sameList(a.bcc, b.bcc) &&
sameList(a.attachments, b.attachments)
sameList(a.attachments, b.attachments) &&
sameList(a.largeFiles, b.largeFiles)
)
}
+26
View File
@@ -12,6 +12,7 @@ import {
formatMailTableDate,
formatMegabytes,
formatQuotaUsage,
formatRelativeDayTime,
formatTraceCode,
initialOf,
} from './format'
@@ -189,3 +190,28 @@ describe('9단계 — 예약 시각 · 인쇄 날짜 · 차단한 날', () => {
expect(formatBlockedDay('2025-12-31T01:00:00Z', now, SEOUL)).toBe('2025년 12월 31일')
})
})
describe('formatRelativeDayTime (11단계 — 설정 › 대용량 첨부의 날과 시각)', () => {
/** 2026-10-30(금) 오후 2:15 (서울) */
const now = new Date('2026-10-30T05:15:00Z')
it('오늘 · 어제 · 내일은 그 말과 시각 — "오늘 오후 2:41"', () => {
expect(formatRelativeDayTime('2026-10-30T05:41:00Z', now, SEOUL)).toBe('오늘 오후 2:41')
expect(formatRelativeDayTime('2026-10-29T00:30:00Z', now, SEOUL)).toBe('어제 오전 9:30')
expect(formatRelativeDayTime('2026-10-31T05:41:00Z', now, SEOUL)).toBe('내일 오후 2:41')
})
it('날은 서울 날짜로 가른다 — UTC 로는 같은 날이어도 서울로 자정을 넘으면 다음 날', () => {
// 2026-10-30T15:30Z = 서울 10월 31일 오전 0:30
expect(formatRelativeDayTime('2026-10-30T15:30:00Z', now, SEOUL)).toBe('내일 오전 12:30')
})
it('그 밖의 올해는 "10월 28일 오전 9:30", 다른 해는 연도까지', () => {
expect(formatRelativeDayTime('2026-10-28T00:30:00Z', now, SEOUL)).toBe('10월 28일 오전 9:30')
expect(formatRelativeDayTime('2025-10-28T00:30:00Z', now, SEOUL)).toBe('2025. 10. 28. 오전 9:30')
})
it('읽을 수 없는 값이면 빈 글', () => {
expect(formatRelativeDayTime('나중에', now, SEOUL)).toBe('')
})
})
+28
View File
@@ -210,6 +210,34 @@ export function formatDraftSavedTime(value: string, now: Date = new Date(), time
return `${new Intl.DateTimeFormat('ko-KR', dayOptions).format(date)} ${time}`
}
/** 달력 날짜 하루 뒤 (월·연 경계 포함) */
function nextDay(day: CalendarDay): CalendarDay {
const date = new Date(Date.UTC(day.year, day.month - 1, day.day + 1))
return calendarDayOf(date, 'UTC')
}
/**
* 날과 시각 — 설정 › 대용량 첨부 (디자인 원본 SettingsLargeFiles: "마지막 오늘 오후 2:41" · "내일 오후 2:41까지 남음")
* - 오늘 · 어제 · 내일: "오늘 오후 2:41"
* - 올해: "10월 28일 오전 9:30"
* - 그 밖: "2025. 10. 28. 오전 9:30"
*/
export function formatRelativeDayTime(value: string, now: Date = new Date(), timeZone?: string): string {
const date = parseDate(value)
if (!date) return ''
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
const target = calendarDayOf(date, timeZone)
const today = calendarDayOf(now, timeZone)
if (isSameDay(target, today)) return `오늘 ${time}`
if (isSameDay(target, previousDay(today))) return `어제 ${time}`
if (isSameDay(target, nextDay(today))) return `내일 ${time}`
const dayOptions: Intl.DateTimeFormatOptions =
target.year === today.year
? { timeZone, month: 'long', day: 'numeric' }
: { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }
return `${new Intl.DateTimeFormat('ko-KR', dayOptions).format(date)} ${time}`
}
/** 메일 본문 머리의 날짜 (디자인 원본: "2026. 10. 26. (월) 오전 10:42") */
export function formatMailDateTime(value: string, timeZone?: string): string {
const date = parseDate(value)
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
formatKstEnglishDate,
formatKstFullDate,
formatKstLongDate,
formatKstMonthDay,
formatKstMonthDayWeekday,
formatKstSentDay,
kstDaysLeft,
kstRetentionEnd,
} from './kst-date'
// 계약 4절 "링크가 끝나는 때" — 2026-10-30 에 보내면 expiresAt 은 2026-11-29T14:59:59.999Z (11월 29일(일) 끝까지, 한국 시간)
const EXPIRES = '2026-11-29T14:59:59.999Z'
describe('한국 날짜 (UTC+9 — 브라우저 시간대와 상관없이)', () => {
it('끝나는 날의 여러 표기', () => {
expect(formatKstMonthDay(EXPIRES)).toBe('11월 29일')
expect(formatKstMonthDayWeekday(EXPIRES)).toBe('11월 29일(일)')
expect(formatKstFullDate(EXPIRES)).toBe('2026년 11월 29일(일)')
expect(formatKstLongDate(EXPIRES)).toBe('2026년 11월 29일')
expect(formatKstEnglishDate(EXPIRES)).toBe('Nov 29, 2026')
})
it('UTC 로는 전날이어도 한국 날짜로 센다 — 2026-10-29T15:00Z 는 한국 10월 30일(금)', () => {
expect(formatKstSentDay('2026-10-29T15:00:00Z')).toBe('10월 30일 (금)')
expect(formatKstSentDay('2026-10-29T14:59:59Z')).toBe('10월 29일 (목)')
})
it('읽을 수 없는 값은 빈 글', () => {
expect(formatKstMonthDay('nope')).toBe('')
expect(kstDaysLeft('nope', 0)).toBeNull()
})
it('지금 보내면 끝나는 때 — 서버와 같은 셈 (오늘(한국) + 30일의 끝)', () => {
// 한국 시간 2026-10-30 오후 2:15
const now = Date.parse('2026-10-30T05:15:00Z')
expect(new Date(kstRetentionEnd(now, 30)).toISOString()).toBe(EXPIRES)
// 한국 시간으로는 이미 10월 31일 오전 1시 — 하루 늦게 끝난다
const lateNight = Date.parse('2026-10-30T16:00:00Z')
expect(formatKstMonthDayWeekday(kstRetentionEnd(lateNight, 30))).toBe('11월 30일(월)')
})
it('남은 날 — 한국 날짜끼리 뺀다 (보낸 날이면 30, 끝나는 날이면 0)', () => {
expect(kstDaysLeft(EXPIRES, Date.parse('2026-10-30T05:15:00Z'))).toBe(30)
expect(kstDaysLeft(EXPIRES, Date.parse('2026-11-29T14:00:00Z'))).toBe(0)
expect(kstDaysLeft(EXPIRES, Date.parse('2026-12-01T00:00:00Z'))).toBe(-2)
})
})
+95
View File
@@ -0,0 +1,95 @@
// 한국 날짜 — 대용량 첨부의 끝나는 날은 한국 시간(UTC+9, 일광 절약 없음)의 날짜 하나로 적는다 (계약 4절 "기간 · 보관 · 정리")
// 받는 사람이 어느 나라에 있든 덩이 · 공개 화면 · 읽기 화면 · 설정이 같은 날짜를 보이게, 브라우저 시간대가 아니라 이 셈으로 센다. 순수 함수만 둔다.
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
/** 한국 시간 — UTC+9 (일광 절약이 없다) */
const KST_OFFSET_MS = 9 * HOUR_MS
const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토'] as const
const MONTHS_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const
export interface KstDay {
year: number
/** 1 ~ 12 */
month: number
day: number
/** 0(일) ~ 6(토) */
weekday: number
/** 1970-01-01(한국) 부터 센 날 수 — 두 날짜 사이의 날 수를 셀 때 쓴다 */
index: number
}
function timeOf(value: string | number): number | null {
const time = typeof value === 'number' ? value : Date.parse(value)
return Number.isFinite(time) ? time : null
}
/** 그 순간의 한국 날짜 — 읽을 수 없는 값이면 null */
export function kstDayOf(value: string | number): KstDay | null {
const time = timeOf(value)
if (time === null) return null
const shifted = new Date(time + KST_OFFSET_MS)
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
weekday: shifted.getUTCDay(),
index: Math.floor((time + KST_OFFSET_MS) / DAY_MS),
}
}
/** "11월 29일" */
export function formatKstMonthDay(value: string | number): string {
const day = kstDayOf(value)
return day ? `${day.month}월 ${day.day}일` : ''
}
/** "11월 29일(일)" — 끝나는 날 ("오늘 보내면 11월 29일(일)까지") */
export function formatKstMonthDayWeekday(value: string | number): string {
const day = kstDayOf(value)
return day ? `${day.month}월 ${day.day}일(${WEEKDAYS_KO[day.weekday]})` : ''
}
/** "10월 30일 (금)" — 보낸 날 (설정의 "10월 30일 (금) 보냄" — 요일 앞에 띄어 쓴다) */
export function formatKstSentDay(value: string | number): string {
const day = kstDayOf(value)
return day ? `${day.month}월 ${day.day}일 (${WEEKDAYS_KO[day.weekday]})` : ''
}
/** "2026년 11월 29일(일)" — 공개 화면의 끝나는 날 */
export function formatKstFullDate(value: string | number): string {
const day = kstDayOf(value)
return day ? `${day.year}년 ${day.month}월 ${day.day}일(${WEEKDAYS_KO[day.weekday]})` : ''
}
/** "2026년 11월 29일" — 끝난 링크의 안내 ("…까지 받을 수 있었던 파일입니다") */
export function formatKstLongDate(value: string | number): string {
const day = kstDayOf(value)
return day ? `${day.year}년 ${day.month}월 ${day.day}일` : ''
}
/** "Nov 29, 2026" — 영어 한 줄 */
export function formatKstEnglishDate(value: string | number): string {
const day = kstDayOf(value)
return day ? `${MONTHS_EN[day.month - 1]} ${day.day}, ${day.year}` : ''
}
/**
* 끝나는 날까지 남은 날 수 — 한국 날짜끼리 뺀다 (오늘이 끝나는 날이면 0, 보낸 날이면 보관 날 수).
* 읽을 수 없는 값이면 null
*/
export function kstDaysLeft(expiresAt: string | number, now: number): number | null {
const end = kstDayOf(expiresAt)
const today = kstDayOf(now)
return end && today ? end.index - today.index : null
}
/**
* 지금 보내면 끝나는 때 — 오늘(한국)부터 days 일째 날의 끝(23:59:59.999, 한국 시간). 서버가 링크를 만들 때와 같은 셈이다
* (계약 4절 "링크가 끝나는 때"). 쓰기 화면의 "오늘 보내면 11월 29일(일)까지"
*/
export function kstRetentionEnd(now: number, days: number): number {
const today = Math.floor((now + KST_OFFSET_MS) / DAY_MS)
return (today + days + 1) * DAY_MS - KST_OFFSET_MS - 1
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import type { MessageLargeFile } from '@/shared/types/large-file'
import { isDownloadable, isEndedLinkStatus, messageLargeFileView, sharedFileStatusView } from './large-file-status'
const GB = 1024 * 1024 * 1024
const FILE: MessageLargeFile = {
linkId: 'l1',
fileId: 'f1',
name: '설계도_v3.dwg',
size: 1.2 * GB,
status: 'available',
expiresAt: '2026-11-29T14:59:59.999Z',
revokedAt: null,
url: 'https://files.arasystem.it.kr/s/Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x',
downloads: null,
}
describe('읽기 화면의 대용량 첨부 줄 (디자인 원본 MailReadLargeFiles)', () => {
it('사내 받는 사람 — "1.2 GB · 11월 29일까지" + 받기', () => {
expect(messageLargeFileView(FILE)).toEqual({ meta: '1.2 GB · 11월 29일까지', tone: 'normal', download: 'ready' })
})
it('보낸 사람 자신 — 받은 수를 더한다 (받지 않았으면 "아직 받지 않음")', () => {
expect(messageLargeFileView({ ...FILE, downloads: { count: 3, lastDownloadedAt: null } }).meta).toBe(
'1.2 GB · 3회 받음 · 11월 29일까지',
)
expect(messageLargeFileView({ ...FILE, downloads: { count: 0, lastDownloadedAt: null } }).meta).toBe(
'1.2 GB · 아직 받지 않음 · 11월 29일까지',
)
})
it('검사 중 — 끝나는 날 대신 까닭, 받기는 막는다(aria-disabled) · 검사하지 못함은 받을 수 있다', () => {
expect(messageLargeFileView({ ...FILE, status: 'scanning' })).toEqual({
meta: '1.2 GB · 보안 검사 중 — 끝나면 받을 수 있습니다',
tone: 'normal',
download: 'blocked',
})
expect(messageLargeFileView({ ...FILE, status: 'unscannable' }).download).toBe('ready')
})
it('위험은 빨간 줄 · 끝난 링크와 받을 수 없는 파일은 흐리게 — 받기 단추 없음', () => {
expect(messageLargeFileView({ ...FILE, status: 'infected' })).toEqual({
meta: '위험 — 보안 검사에서 막았습니다. 받을 수 없습니다',
tone: 'danger',
download: 'none',
})
expect(messageLargeFileView({ ...FILE, status: 'expired' })).toEqual({
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', downloads: { count: 3, lastDownloadedAt: null } }).meta).toBe(
'1.2 GB · 3회 받음 · 공유 멈춤 — 링크를 끊었습니다',
)
// 끊은 때(revokedAt)는 보낸 사람 자신에게만 날짜로 보인다(한국 날짜 — 디자인 원본 MailReadLargeFiles 셋째 줄). 받는 사람은 날짜 없이
const revokedAt = '2026-10-30T15:30:00Z'
expect(messageLargeFileView({ ...FILE, status: 'revoked', revokedAt }).meta).toBe(
'1.2 GB · 공유 멈춤 — 보낸 사람이 링크를 끊었습니다',
)
expect(
messageLargeFileView({ ...FILE, status: 'revoked', revokedAt, downloads: { count: 3, lastDownloadedAt: null } }).meta,
).toBe('1.2 GB · 3회 받음 · 공유 멈춤 — 10월 31일에 끊었습니다')
expect(messageLargeFileView({ ...FILE, status: 'limit-reached' }).tone).toBe('dim')
expect(messageLargeFileView({ ...FILE, status: 'missing' }).download).toBe('none')
})
it('받을 수 있는 상태 · 끝난 링크의 상태', () => {
expect(isDownloadable('available')).toBe(true)
expect(isDownloadable('unscannable')).toBe(true)
expect(isDownloadable('scanning')).toBe(false)
expect(isEndedLinkStatus('revoked')).toBe(true)
expect(isEndedLinkStatus('expired')).toBe(true)
expect(isEndedLinkStatus('infected')).toBe(false)
})
})
describe('설정 › 대용량 첨부의 파일 상태 글 (디자인 원본 SettingsLargeFiles — 뱃지 없이 글자만)', () => {
it('사용 중 · 검사 중 · 위험(+ 내려받기를 막음) · 검사하지 못함(상태 아래 갈색)', () => {
expect(sharedFileStatusView({ status: 'available' })).toEqual({ label: '사용 중', note: null, tone: 'normal' })
expect(sharedFileStatusView({ status: 'scanning' })).toEqual({ label: '검사 중', note: null, tone: 'muted' })
expect(sharedFileStatusView({ status: 'infected' })).toEqual({ label: '위험', note: '내려받기를 막음', tone: 'danger' })
expect(sharedFileStatusView({ status: 'unscannable' })).toEqual({
label: '사용 중',
note: '검사하지 못함',
tone: 'warning',
})
expect(sharedFileStatusView({ status: 'revoked' }).label).toBe('공유 멈춤')
expect(sharedFileStatusView({ status: 'expired' }).label).toBe('기간 지남')
})
})
@@ -0,0 +1,108 @@
// 대용량 첨부의 상태 글 — 읽기 화면의 첨부 칸 · 설정 › 대용량 첨부 (순수 함수만 둔다)
// 글은 디자인 README "11단계 › 읽기 화면" · "설정 › 메일 › 대용량 첨부". 뱃지 · 알약 없이 글자와 색만 — 흐리게는 투명도가 아니라 글자색이다.
import { LARGE_FILE_SETTINGS_TEXT, MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import type { MessageLargeFile, SharedFile, SharedFileStatus } from '@/shared/types/large-file'
import { formatCount, formatFileSize } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
/** 줄 모양 — 보통 · 흐리게(끝남 · 받을 수 없음) · 빨강(위험) */
export type LargeFileTone = 'normal' | 'dim' | 'danger'
/** 받기 단추 — 받을 수 있음 · 지금은 못 받음(검사 중 — aria-disabled + 까닭) · 단추 없음 */
export type LargeFileDownload = 'ready' | 'blocked' | 'none'
export interface MessageLargeFileView {
/** 이름 아래 글 */
meta: string
tone: LargeFileTone
download: LargeFileDownload
}
/** 받을 수 있는 상태 — 깨끗함 · 검사하지 못함 */
export function isDownloadable(status: SharedFileStatus): boolean {
return status === 'available' || status === 'unscannable'
}
/** 링크가 끝났다 — 끊음 · 기간 지남 (그 링크의 파일은 모두 같다) */
export function isEndedLinkStatus(status: SharedFileStatus): boolean {
return status === 'revoked' || status === 'expired'
}
/**
* 읽기 화면의 대용량 첨부 한 줄 — 이름 아래 "1.2 GB · 11월 29일까지".
* 보낸 사람 자신(downloads 가 있다)에게는 받은 수를 더한다 — "1.2 GB · 3회 받음 · 11월 29일까지" (받지 않았으면 "아직 받지 않음")
*/
export function messageLargeFileView(file: MessageLargeFile): MessageLargeFileView {
const size = formatFileSize(file.size)
const until = formatKstMonthDay(file.expiresAt)
const own = file.downloads !== null
const downloads = file.downloads
? file.downloads.count > 0
? MESSAGE_LARGE_FILE_TEXT.downloads(formatCount(file.downloads.count))
: MESSAGE_LARGE_FILE_TEXT.notDownloaded
: null
const join = (...parts: readonly (string | null)[]) => parts.filter((part): part is string => !!part).join(' · ')
switch (file.status) {
case 'available':
return { meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.until(until)), tone: 'normal', download: 'ready' }
case 'unscannable':
return {
meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.until(until), MESSAGE_LARGE_FILE_TEXT.unscannable),
tone: 'normal',
download: 'ready',
}
case 'scanning':
return { meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.scanning), tone: 'normal', download: 'blocked' }
case 'infected':
return { meta: MESSAGE_LARGE_FILE_TEXT.infected, tone: 'danger', download: 'none' }
case 'limit-reached':
return { meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.limitReached), tone: 'dim', download: 'none' }
case 'revoked':
return {
meta: join(
size,
downloads,
own
? MESSAGE_LARGE_FILE_TEXT.revokedOwn(file.revokedAt ? formatKstMonthDay(file.revokedAt) || null : null)
: MESSAGE_LARGE_FILE_TEXT.revoked,
),
tone: 'dim',
download: 'none',
}
case 'expired':
return { meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.expired(until)), tone: 'dim', download: 'none' }
case 'missing':
return { meta: join(size, downloads, MESSAGE_LARGE_FILE_TEXT.missing), tone: 'dim', download: 'none' }
}
}
/** 설정 › 대용량 첨부의 파일 줄 상태 — "사용 중" · "검사 중" · "위험"(+ "내려받기를 막음") · "기간 지남" · "공유 멈춤" (검사하지 못함은 아래 갈색 줄) */
export interface SharedFileStatusView {
label: string
/** 상태 아래 작은 글 — 위험의 "내려받기를 막음" · 검사하지 못함 */
note: string | null
tone: 'normal' | 'muted' | 'danger' | 'warning'
}
export function sharedFileStatusView(file: Pick<SharedFile, 'status'>): SharedFileStatusView {
switch (file.status) {
case 'available':
return { label: LARGE_FILE_SETTINGS_TEXT.linkActive, note: null, tone: 'normal' }
case 'unscannable':
return { label: LARGE_FILE_SETTINGS_TEXT.linkActive, note: LARGE_FILE_SETTINGS_TEXT.fileUnscannable, tone: 'warning' }
case 'scanning':
return { label: LARGE_FILE_SETTINGS_TEXT.fileScanning, note: null, tone: 'muted' }
case 'infected':
return { label: LARGE_FILE_SETTINGS_TEXT.fileInfected, note: LARGE_FILE_SETTINGS_TEXT.fileInfectedNote, tone: 'danger' }
case 'limit-reached':
return { label: LARGE_FILE_SETTINGS_TEXT.fileLimitReached, note: null, tone: 'muted' }
case 'revoked':
return { label: LARGE_FILE_SETTINGS_TEXT.linkRevoked, note: null, tone: 'muted' }
case 'expired':
return { label: LARGE_FILE_SETTINGS_TEXT.linkExpired, note: null, tone: 'muted' }
case 'missing':
return { label: LARGE_FILE_SETTINGS_TEXT.fileMissing, note: null, tone: 'muted' }
}
}
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LARGE_FILE_CHUNK_SIZE, LARGE_FILE_FALLBACK_LIMITS } from '@/shared/constants/large-file'
import {
bytesToBase64,
chunkBounds,
chunkCountOf,
composeAttachmentHint,
contentDigestValue,
formatEta,
formatSizeLimit,
formatUploadProgress,
isAllowedLargeFileName,
isLinkOnlyAttachmentName,
largeFileBlockTextReserve,
looksLikeSameFile,
missingChunksOf,
normalizeUploadName,
precheckLargeFile,
progressPercent,
receivedBytesOf,
routeAttachment,
sendBlockOf,
sha256Base64,
type SendBlockState,
} from './large-file'
const MB = 1024 * 1024
const NORMAL = { maxBytes: 20 * MB, totalMaxBytes: 25 * MB }
const LIMITS = {
maxFileBytes: LARGE_FILE_FALLBACK_LIMITS.maxFileBytes,
maxFilesPerMessage: 10,
limitBytes: 10 * 1024 * MB,
usedBytes: 0,
}
const file = (name: string, size: number) => ({ name, size })
describe('링크로 보낼지 고르기 (계약 4절 "링크로 바뀌는 때")', () => {
it('20MB 이하이고 합계 안이면 메일에 담는다', () => {
expect(routeAttachment(file('견적서.xlsx', 52 * 1024), NORMAL, 0)).toBe('normal')
expect(routeAttachment(file('사진.pdf', 20 * MB), NORMAL, 0)).toBe('normal')
})
it('파일 하나가 20MB 를 넘으면 링크로', () => {
expect(routeAttachment(file('설계도.pdf', 20 * MB + 1), NORMAL, 0)).toBe('large-size')
})
it('합계를 25MB 넘게 만드는 파일부터 링크로 — 그 뒤의 작은 파일은 다시 한도 안에서 담긴다', () => {
expect(routeAttachment(file('회의자료.pptx', 9.4 * MB), NORMAL, 18.1 * MB)).toBe('large-total')
expect(routeAttachment(file('메모.txt', 1 * MB), NORMAL, 18.1 * MB)).toBe('normal')
})
it('메일에 담지 못하는 형식(mp4 · dwg 등)은 크기와 상관없이 링크로 · 막힌 형식은 일반 첨부로 보내 FILE_001 로 빠진다', () => {
expect(routeAttachment(file('현장.mp4', 5 * MB), NORMAL, 0)).toBe('large-type')
expect(routeAttachment(file('도면.DWG', 1024), NORMAL, 0)).toBe('large-type')
expect(routeAttachment(file('설치.exe', 48 * MB), NORMAL, 0)).toBe('normal')
})
it('형식 — 링크로만 보내는 형식 · 링크로 보낼 수 있는 형식 (실행 형식 · 디스크 이미지는 아니다)', () => {
expect(isLinkOnlyAttachmentName('a.7z')).toBe(true)
expect(isLinkOnlyAttachmentName('a.zip')).toBe(false)
expect(isAllowedLargeFileName('a.zip')).toBe(true)
expect(isAllowedLargeFileName('a.mov')).toBe(true)
for (const name of ['a.exe', 'a.msi', 'a.bat', 'a.js', 'a.vbs', 'a.scr', 'a.iso', 'a.img', 'a.vhd', 'noext']) {
expect(isAllowedLargeFileName(name)).toBe(false)
}
})
})
describe('올리기 전 검사 (계약 4절 "쓰기 화면이 고르는 것")', () => {
it('서버와 같은 차례 — 형식 → 크기 → 한 통의 개수 → 보관량', () => {
expect(precheckLargeFile(file('a.exe', 30 * MB), LIMITS, 0)).toBe('type')
expect(precheckLargeFile(file('a.zip', LIMITS.maxFileBytes + 1), LIMITS, 20)).toBe('size')
expect(precheckLargeFile(file('a.zip', 30 * MB), LIMITS, 10)).toBe('count')
expect(precheckLargeFile(file('a.zip', 30 * MB), { ...LIMITS, usedBytes: LIMITS.limitBytes - MB }, 0)).toBe('quota')
expect(precheckLargeFile(file('a.zip', 30 * MB), LIMITS, 9)).toBeNull()
})
it('한 파일 한도 그대로(2,047MB)는 받는다', () => {
expect(precheckLargeFile(file('a.mp4', LIMITS.maxFileBytes), LIMITS, 0)).toBeNull()
})
})
describe('크기 · 진행 글', () => {
it('한도 글 — 1GB 가까이부터 GB, 그 아래는 MB', () => {
expect(formatSizeLimit(LARGE_FILE_FALLBACK_LIMITS.maxFileBytes)).toBe('2GB')
expect(formatSizeLimit(10 * 1024 * MB)).toBe('10GB')
expect(formatSizeLimit(20 * MB)).toBe('20MB')
expect(formatSizeLimit(1536 * MB)).toBe('1.5GB')
})
it('첨부 안내 — 일반 한도 뒤에 링크 안내를 붙인다 (디자인 원본 ComposeLargeFiles)', () => {
expect(composeAttachmentHint({ ...NORMAL, maxCount: 20 }, LIMITS)).toBe(
'파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)',
)
})
it('진행 — "420 MB / 1.2 GB", 크기를 넘지 않는다', () => {
expect(formatUploadProgress(420 * MB, 1.2 * 1024 * MB)).toBe('420 MB / 1.2 GB')
expect(formatUploadProgress(2 * MB, MB)).toBe('1 MB / 1 MB')
expect(progressPercent(35, 100)).toBe(35)
expect(progressPercent(99.9, 100)).toBe(99)
expect(progressPercent(5, 0)).toBe(0)
})
it('남은 시간 — 어림으로', () => {
expect(formatEta(3_000)).toBe('약 5초 남음')
expect(formatEta(42_000)).toBe('약 45초 남음')
expect(formatEta(170_000)).toBe('약 3분 남음')
expect(formatEta(80 * 60_000)).toBe('약 1시간 20분 남음')
expect(formatEta(120 * 60_000)).toBe('약 2시간 남음')
})
})
describe('조각 셈 (16MiB — 마지막 조각만 짧다)', () => {
const size = 2 * LARGE_FILE_CHUNK_SIZE + 100
it('조각 수 · 범위 · 받은 바이트 · 빠진 조각', () => {
expect(chunkCountOf(size, LARGE_FILE_CHUNK_SIZE)).toBe(3)
expect(chunkCountOf(0, LARGE_FILE_CHUNK_SIZE)).toBe(0)
expect(chunkBounds(2, LARGE_FILE_CHUNK_SIZE, size)).toEqual({ start: 2 * LARGE_FILE_CHUNK_SIZE, end: size })
expect(receivedBytesOf([0, 2], LARGE_FILE_CHUNK_SIZE, size)).toBe(LARGE_FILE_CHUNK_SIZE + 100)
expect(missingChunksOf(new Set([0, 2]), 3)).toEqual([1])
})
it('Content-Digest 값 — sha-256=:<base64>:', () => {
const bytes = new Uint8Array([0, 1, 2, 250]).buffer
expect(bytesToBase64(bytes)).toBe('AAEC+g==')
expect(contentDigestValue('AAEC+g==')).toBe('sha-256=:AAEC+g==:')
})
})
describe('새로고침 뒤 이어 올리기 — 같은 파일인가 (이름 · 크기 · 고친 시각)', () => {
const upload = { name: '현장영상_1029.mp4', size: 1000, lastModified: 1_700_000_000_000 }
it('셋이 같으면 같아 보인다 — 이름은 서버가 정리한 모양과도 견준다', () => {
expect(looksLikeSameFile({ ...upload }, upload)).toBe(true)
expect(looksLikeSameFile({ ...upload, name: 'C:\\fakepath\\현장영상_1029.mp4' }, upload)).toBe(true)
expect(normalizeUploadName('a\u202Egnp.exe')).toBe('agnp.exe')
})
it('이름 · 크기 · 고친 시각 가운데 하나라도 다르면 다르다', () => {
expect(looksLikeSameFile({ ...upload, name: '다른.mp4' }, upload)).toBe(false)
expect(looksLikeSameFile({ ...upload, size: 999 }, upload)).toBe(false)
expect(looksLikeSameFile({ ...upload, lastModified: 1 }, upload)).toBe(false)
})
})
describe('[보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭")', () => {
const clear: SendBlockState = {
normalUploading: 0,
normalMissing: false,
largeActive: 0,
largeIncomplete: false,
largeMissing: false,
largeInfected: false,
largeCount: 0,
maxLargeFiles: 10,
}
it('없으면 null — 검사 중 · 검사하지 못함은 막지 않는다', () => {
expect(sendBlockOf(clear)).toBeNull()
})
it('올리는 중 — 일반 첨부와 대용량 파일을 합한 수', () => {
expect(sendBlockOf({ ...clear, normalUploading: 1, largeActive: 1 })).toEqual({
code: 'large-file-uploading',
text: '파일 2개를 올리는 중입니다 — 다 올리면 보낼 수 있습니다.',
})
expect(sendBlockOf({ ...clear, normalUploading: 1 })?.code).toBe('attachment-uploading')
})
it('빼야 하는 파일이 먼저, 그다음 다 올리지 못한 파일, 개수, 올리는 중', () => {
const all = { ...clear, largeActive: 1, largeIncomplete: true, largeInfected: true, largeCount: 11 }
expect(sendBlockOf(all)).toEqual({
code: 'large-file-infected',
text: '보낼 수 없는 파일이 있습니다 — 빨간 줄의 파일을 빼 주세요.',
})
expect(sendBlockOf({ ...all, largeInfected: false })).toEqual({
code: 'large-file-incomplete',
text: '다 올리지 못한 파일이 있습니다 — 이어 올리거나 빼 주세요.',
})
expect(sendBlockOf({ ...all, largeInfected: false, largeIncomplete: false })?.code).toBe('large-file-count')
expect(sendBlockOf({ ...clear, largeMissing: true })?.code).toBe('large-file-missing')
expect(sendBlockOf({ ...clear, normalMissing: true })?.code).toBe('attachment-missing')
})
})
describe('덩이 자리의 어림 — 보내기 직전에 텍스트 한도에서 남긴다', () => {
it('파일이 없으면 0, 있으면 이름마다 더한다', () => {
expect(largeFileBlockTextReserve([])).toBe(0)
expect(largeFileBlockTextReserve([{ name: 'a.zip' }])).toBeGreaterThan(500)
const ten = Array.from({ length: 10 }, () => ({ name: 'x'.repeat(255) }))
expect(largeFileBlockTextReserve(ten)).toBeGreaterThan(10 * 255)
})
})
describe('조각의 SHA-256 — Content-Digest 값 (계약 4절 "조각")', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('Web Crypto 로 센 SHA-256 을 base64 로 — 서버가 받은 바이트와 견준다', async () => {
// RFC 6234 의 "abc" 시험 값 (ba7816bf…15ad)
await expect(sha256Base64(new Blob(['abc']))).resolves.toBe('ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=')
// 한글 이름의 파일 조각도 바이트 그대로 센다 (UTF-8)
const digest = await sha256Base64(new File(['가'], '설계도.dwg').slice(0, 3))
expect(digest).toMatch(/^[A-Za-z0-9+/]{43}=$/)
})
it('crypto.subtle 이 없으면(HTTPS · localhost 가 아닌 곳) null — Content-Digest 없이 보낸다', async () => {
vi.stubGlobal('crypto', {})
await expect(sha256Base64(new Blob(['abc']))).resolves.toBeNull()
})
it('셈하다 실패해도 null — 올리기를 막지 않는다', async () => {
vi.stubGlobal('crypto', { subtle: { digest: () => Promise.reject(new Error('NotSupportedError')) } })
await expect(sha256Base64(new Blob(['abc']))).resolves.toBeNull()
})
})
+313
View File
@@ -0,0 +1,313 @@
// 대용량 첨부의 규칙 — 순수 함수만 둔다 (계약 4절 "쓰기 화면이 고르는 것" · "올리기" · 디자인 README "11단계 › 쓰기 화면")
// 링크로 보낼지 고르기 · 올리기 전 검사 · 조각 셈 · 진행 글 · [보내기]를 막는 까닭.
// 최종 판단은 서버다 — 통과해도 서버가 FILE_001 · FILE_002 · FILE_005 · FILE_006 으로 거절할 수 있다.
import {
LARGE_FILE_BLOCK_TEXT_BASE,
LARGE_FILE_BLOCK_TEXT_PER_FILE,
LARGE_FILE_EXTRA_EXTENSIONS,
LARGE_FILE_TEXT,
SEND_BLOCK_TEXT,
} from '@/shared/constants/large-file'
import { formatCount, formatFileSize, formatMegabytes } from '@/shared/utils/format'
import { extensionOf, isAllowedAttachmentName } from '@/shared/utils/mail-attachment'
const BYTES_PER_MB = 1024 * 1024
const BYTES_PER_GB = 1024 * BYTES_PER_MB
/** GB 로 적기 시작하는 크기 — 1,023MB 부터 (2,047MB → "2GB") */
const GB_DISPLAY_FROM = 1023 * BYTES_PER_MB
// ── 형식 ─────────────────────────────────────────────
/** 메일에는 담지 못하고 링크로만 보내는 형식인가 (mp4 · mov · dwg · dxf · psd · ai · 7z) */
export function isLinkOnlyAttachmentName(name: string): boolean {
return LARGE_FILE_EXTRA_EXTENSIONS.includes(extensionOf(name))
}
/** 링크로 보낼 수 있는 형식인가 — 일반 첨부 허용 목록 + 링크로만 보내는 형식. 실행 형식 · 디스크 이미지 등은 아니다 */
export function isAllowedLargeFileName(name: string): boolean {
return isAllowedAttachmentName(name) || isLinkOnlyAttachmentName(name)
}
// ── 링크로 보낼지 고르기 ─────────────────────────────────────────────
/**
* 고른 파일을 어디에 싣는가
* - normal: 메일에 담는다 (일반 첨부 — 사전 검사는 그쪽이 한다. 허용 밖 형식도 여기로 와 FILE_001 로 빠진다)
* - large-size: 파일 하나가 일반 첨부 한도(20MB)를 넘는다
* - large-total: 메일에 담은 파일의 합계를 일반 첨부 합계 한도(25MB)보다 크게 만든다 — 칸 아래에 한 줄로 알린다
* - large-type: 메일에 담지 못하는 형식(mp4 · dwg 등)이다 — 크기와 상관없이 링크로
*/
export type AttachmentRoute = 'normal' | 'large-size' | 'large-total' | 'large-type'
export interface NormalAttachmentLimits {
/** 파일당 (GET /mail-quota 의 attachmentMaxBytes) */
maxBytes: number
/** 합계 (attachmentTotalMaxBytes) */
totalMaxBytes: number
}
/**
* 파일 하나를 고른 차례대로 가른다 — attachedBytes 는 그 앞까지 메일에 담은(올리는 중 포함) 파일의 합계.
* 합계를 넘게 만드는 파일만 링크로 간다 — 그 뒤에 고른 작은 파일은 다시 한도 안에서 담긴다 (계약 4절 "링크로 바뀌는 때")
*/
export function routeAttachment(
file: Pick<File, 'name' | 'size'>,
limits: NormalAttachmentLimits,
attachedBytes: number,
): AttachmentRoute {
if (!isAllowedAttachmentName(file.name)) return isLinkOnlyAttachmentName(file.name) ? 'large-type' : 'normal'
if (file.size > limits.maxBytes) return 'large-size'
if (attachedBytes + file.size > limits.totalMaxBytes) return 'large-total'
return 'normal'
}
// ── 올리기 전 검사 ─────────────────────────────────────────────
export interface LargeFileLimits {
/** 한 파일 */
maxFileBytes: number
/** 메일 한 통에 새로 싣는 대용량 파일 수 */
maxFilesPerMessage: number
/** 직원당 보관량 한도 */
limitBytes: number
/** 지금 잡고 있는 양 — 받은 사용량 + 이 화면에서 새로 만든 올리기 */
usedBytes: number
}
/** 넘는 한도 — 형식(FILE_001 사전 문구) · 한 파일 · 한 통의 개수 · 보관량 */
export type LargeFilePrecheckProblem = 'type' | 'size' | 'count' | 'quota'
/**
* 올리기 전 검사 — 걸리면 그 파일은 만들지 않는다(빨간 줄, 보내기는 막지 않는다). 서버와 같은 차례: 형식 → 크기 → (개수) → 보관량.
* count 는 이 메일에 이미 실은 대용량 파일 수(빨간 줄 · 지운 파일은 빼고)
*/
export function precheckLargeFile(
file: Pick<File, 'name' | 'size'>,
limits: LargeFileLimits,
count: number,
): LargeFilePrecheckProblem | null {
if (!isAllowedLargeFileName(file.name)) return 'type'
if (file.size > limits.maxFileBytes) return 'size'
if (count + 1 > limits.maxFilesPerMessage) return 'count'
if (limits.usedBytes + file.size > limits.limitBytes) return 'quota'
return null
}
// ── 크기 · 진행 글 ─────────────────────────────────────────────
/** 한도 글의 크기 — 1GB 가까이부터 GB(소수 한 자리), 그 아래는 MB ("2GB" · "10GB" · "20MB") */
export function formatSizeLimit(bytes: number): string {
if (bytes < GB_DISPLAY_FROM) return formatMegabytes(bytes)
const gigabytes = Math.round((bytes / BYTES_PER_GB) * 10) / 10
return `${gigabytes}GB`
}
/**
* 첨부 안내 — "파일당 최대 20MB · 합계 25MB · 최대 20개 · 더 큰 파일은 링크로 보냅니다 (파일당 2GB · 10개까지)".
* 일반 첨부의 형식 설명(문서, 이미지, 압축 파일)은 링크 안내로 바꾼다 (디자인 원본 ComposeLargeFiles)
*/
export function composeAttachmentHint(
normal: NormalAttachmentLimits & { maxCount: number },
large: Pick<LargeFileLimits, 'maxFileBytes' | 'maxFilesPerMessage'>,
): string {
return [
`파일당 최대 ${formatMegabytes(normal.maxBytes)}`,
`합계 ${formatMegabytes(normal.totalMaxBytes)}`,
`최대 ${normal.maxCount}개`,
LARGE_FILE_TEXT.hintTail(formatSizeLimit(large.maxFileBytes), formatCount(large.maxFilesPerMessage)),
].join(' · ')
}
/** "420 MB / 1.2 GB" */
export function formatUploadProgress(uploadedBytes: number, size: number): string {
return `${formatFileSize(Math.min(Math.max(uploadedBytes, 0), size))} / ${formatFileSize(size)}`
}
/** 남은 시간 — "약 40초 남음" · "약 3분 남음" · "약 1시간 20분 남음" (속도로 셈한 값이라 어림으로 적는다) */
export function formatEta(ms: number): string {
const seconds = Math.max(1, Math.ceil(ms / 1000))
if (seconds < 60) return LARGE_FILE_TEXT.etaSeconds(String(Math.max(5, Math.ceil(seconds / 5) * 5)))
const minutes = Math.ceil(seconds / 60)
if (minutes < 60) return LARGE_FILE_TEXT.etaMinutes(String(minutes))
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest === 0 ? LARGE_FILE_TEXT.etaHours(String(hours)) : LARGE_FILE_TEXT.etaHoursMinutes(String(hours), String(rest))
}
/** 진행률(0~100, 내림) — 화면 낭독기는 10% 마다 알린다 */
export function progressPercent(uploadedBytes: number, size: number): number {
if (size <= 0) return 0
return Math.min(100, Math.max(0, Math.floor((uploadedBytes / size) * 100)))
}
// ── 조각 셈 (계약 4절 "올리기" — 16MiB 고정, 마지막 조각만 짧다) ─────────────
/** 조각 수 = ceil(size / chunkSize) */
export function chunkCountOf(size: number, chunkSize: number): number {
return size <= 0 ? 0 : Math.ceil(size / chunkSize)
}
/** 조각 하나의 바이트 범위 [start, end) */
export function chunkBounds(index: number, chunkSize: number, size: number): { start: number; end: number } {
const start = index * chunkSize
return { start, end: Math.min(size, start + chunkSize) }
}
/** 받은 조각의 바이트 합 */
export function receivedBytesOf(received: Iterable<number>, chunkSize: number, size: number): number {
let total = 0
for (const index of received) {
const { start, end } = chunkBounds(index, chunkSize, size)
if (end > start) total += end - start
}
return total
}
/** 아직 받지 않은 조각 번호 — 작은 번호부터 */
export function missingChunksOf(received: ReadonlySet<number>, chunkCount: number): number[] {
const missing: number[] = []
for (let index = 0; index < chunkCount; index += 1) if (!received.has(index)) missing.push(index)
return missing
}
/** 바이트 → base64 (SHA-256 32바이트 등 작은 값) */
export function bytesToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
const step = 0x8000
for (let index = 0; index < bytes.length; index += step) {
binary += String.fromCharCode(...bytes.subarray(index, index + step))
}
return btoa(binary)
}
/** 조각 요청의 Content-Digest 값 (RFC 9530) — `sha-256=:<base64>:` */
export function contentDigestValue(sha256Base64: string): string {
return `sha-256=:${sha256Base64}:`
}
/**
* 조각 내용의 SHA-256(base64) — 브라우저의 Web Crypto 로 센다. 보안 컨텍스트(HTTPS · localhost)에만 있어,
* 없거나 셈하지 못하면 null — 그때는 Content-Digest 없이 보내고(서버는 받는다 — 계약 4절 "조각"), 다시 고른 파일은 새로 올린다
*/
export async function sha256Base64(chunk: Blob): Promise<string | null> {
const subtle = globalThis.crypto?.subtle
if (!subtle || typeof chunk.arrayBuffer !== 'function') return null
try {
return bytesToBase64(await subtle.digest('SHA-256', await chunk.arrayBuffer()))
} catch {
return null
}
}
// ── 새로고침 뒤 이어 올리기 — 같은 파일인가 ─────────────
/** 이름에서 지우는 글자 — 제어 문자 · 방향 제어 문자 (짝: backend attachment.policy.ts 의 isControlOrBidiChar) */
function isControlOrBidi(codePoint: number): boolean {
return (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
codePoint === 0x200e ||
codePoint === 0x200f ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
)
}
/**
* 서버가 정리한 이름과 같은 모양 — 경로 앞부분 · 제어 · 방향 제어 문자를 지우고 앞뒤 공백을 뗀다
* (짝: backend attachment.policy.ts 의 sanitizeFileName — 255자 넘는 이름의 자르기는 견주지 않는다)
*/
export function normalizeUploadName(name: string): string {
const lastSegment = name.split(/[\\/]/).pop() ?? ''
return Array.from(lastSegment)
.filter((char) => !isControlOrBidi(char.codePointAt(0) ?? 0))
.join('')
.trim()
}
/**
* 다시 고른 파일이 멈춘 올리기와 같아 보이는가 — 이름 · 크기 · 고친 시각 (계약 4절 "새로고침 · 창 닫힘 뒤 이어 올리기").
* 맨 앞 조각의 SHA-256 은 따로 견준다 (읽어야 알 수 있다)
*/
export function looksLikeSameFile(
file: Pick<File, 'name' | 'size' | 'lastModified'>,
upload: { name: string; size: number; lastModified: number | null },
): boolean {
const sameName = upload.name === file.name || upload.name === normalizeUploadName(file.name)
const sameTime = upload.lastModified === null || upload.lastModified === file.lastModified
return sameName && upload.size === file.size && sameTime
}
// ── [보내기]를 막는 까닭 (계약 4절 "보내기를 막는 까닭" · 디자인 README "다 올릴 때까지 보내기를 막는다") ─────────────
/**
* 막는 까닭 — 일반 첨부의 까닭(올리는 중 · 찾지 못함)에 대용량 첨부의 까닭을 더한다.
* 서버도 같은 것을 막는다(MAIL_016 · VAL_001 · MAIL_004)
*/
export type SendBlockCode =
| 'attachment-uploading'
| 'attachment-missing'
| 'large-file-uploading'
| 'large-file-incomplete'
| 'large-file-missing'
| 'large-file-infected'
| 'large-file-count'
export interface SendBlockState {
/** 올리는 중인 일반 첨부 수 */
normalUploading: number
/** 서버가 찾지 못한 일반 첨부가 있다 (MAIL_004) */
normalMissing: boolean
/** 올리는 중 · 다시 잇는 중인 대용량 파일 수 */
largeActive: number
/** 멈춤 · 오래 끊김 · 다시 골라야 이어지는 대용량 파일이 있다 */
largeIncomplete: boolean
/** 지운 · 기간이 지난 대용량 파일이 있다 */
largeMissing: boolean
/** 위험 판정 대용량 파일이 있다 */
largeInfected: boolean
/** 이 메일에 실은 대용량 파일 수 */
largeCount: number
maxLargeFiles: number
}
export interface SendBlock {
code: SendBlockCode
/** 단추 옆 글 */
text: string
}
/**
* 보내기를 막는 까닭 — 하나만 보인다. 빼야 하는 파일(빨간 줄)이 먼저, 그다음 다 올리지 못한 파일, 개수, 올리는 중.
* 막지 않으면 null (검사 중 · 검사하지 못함은 막지 않는다 — 받는 쪽이 끝난 뒤 받는다)
*/
export function sendBlockOf(state: SendBlockState): SendBlock | null {
if (state.largeInfected) return { code: 'large-file-infected', text: SEND_BLOCK_TEXT.unsendable }
if (state.largeMissing) return { code: 'large-file-missing', text: SEND_BLOCK_TEXT.unsendable }
if (state.normalMissing) return { code: 'attachment-missing', text: SEND_BLOCK_TEXT.unsendable }
if (state.largeIncomplete) return { code: 'large-file-incomplete', text: SEND_BLOCK_TEXT.incomplete }
if (state.largeCount > state.maxLargeFiles) {
return { code: 'large-file-count', text: SEND_BLOCK_TEXT.tooMany(formatCount(state.maxLargeFiles)) }
}
const uploading = state.normalUploading + state.largeActive
if (uploading > 0) {
return {
code: state.largeActive > 0 ? 'large-file-uploading' : 'attachment-uploading',
text: SEND_BLOCK_TEXT.uploading(formatCount(uploading)),
}
}
return null
}
// ── 덩이 자리 (계약 4절 "덩이 — 서버가 본문에 넣는 안내") ─────────────
/**
* 서버가 텍스트 본문에 넣을 덩이 길이의 어림 — 보내기 직전에 본문 한도(100,000자)에서 이만큼을 남기고 인용을 줄인다
* (서버는 덩이를 넣은 뒤 다시 잰다 — 넘으면 MAIL_007). 파일마다 "- 이름 (크기)" 한 줄 + 머리 · 주소 · 안내 · 영어 한 줄
*/
export function largeFileBlockTextReserve(files: readonly { name: string }[]): number {
if (files.length === 0) return 0
return files.reduce((sum, file) => sum + file.name.length + LARGE_FILE_BLOCK_TEXT_PER_FILE, LARGE_FILE_BLOCK_TEXT_BASE)
}
@@ -80,6 +80,18 @@ describe('parseMailEvent (실시간 알림의 서버 이벤트)', () => {
expect(parseMailEvent(event('end', ''))).toEqual({ type: 'end', reason: '' })
})
it('large-file — 올린 대용량 파일의 판정 (11단계). 파일 id · 판정이 없으면 버린다', () => {
expect(parseMailEvent(event('large-file', '{"fileId":"f1","name":"견적서.zip","scanStatus":"infected"}'))).toEqual({
type: 'large-file',
fileId: 'f1',
name: '견적서.zip',
scanStatus: 'infected',
})
expect(parseMailEvent(event('large-file', '{"fileId":"f1","scanStatus":"infected"}'))).toMatchObject({ name: '' })
expect(parseMailEvent(event('large-file', '{"name":"x","scanStatus":"infected"}'))).toBeNull()
expect(parseMailEvent(event('large-file', '{"fileId":"f1","scanStatus":"weird"}'))).toBeNull()
})
it('모르는 이벤트는 버린다', () => {
expect(parseMailEvent(event('message', '{"state":"s1"}'))).toBeNull()
expect(parseMailEvent(event('mailbox-changed', '{}'))).toBeNull()
+37 -5
View File
@@ -1,10 +1,12 @@
// 실시간 알림의 서버 이벤트 읽기 — 계약 2절 "실시간 알림 (GET /api/mail-events)" (순수 함수만 둔다)
// SSE 이벤트(sse-parser.ts)의 data(JSON)를 모양까지 확인해 아래 네 가지로 바꾼다. 모르는 이벤트 · 모양이 틀린 것은 null (버린다).
// state — 메일함 · 메일이 바뀌었다 (GET /mail-state 의 state 와 같은 값) + 받은편지함의 안 읽은 수
// new-mail — 받은편지함에 새로 배달된 메일 (새것부터 최대 5통, count 는 전체 수)
// ping — 25초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다
// end — 서버가 곧 닫는다 (token-expiring · server-shutdown)
// SSE 이벤트(sse-parser.ts)의 data(JSON)를 모양까지 확인해 아래 다섯 가지로 바꾼다. 모르는 이벤트 · 모양이 틀린 것은 null (버린다).
// state — 메일함 · 메일이 바뀌었다 (GET /mail-state 의 state 와 같은 값) + 받은편지함의 안 읽은 수
// new-mail — 받은편지함에 새로 배달된 메일 (새것부터 최대 5통, count 는 전체 수)
// ping — 25초마다. upstream: false 면 서버와 메일 엔진 사이가 끊겨 다시 잇는 중이다
// end — 서버가 곧 닫는다 (token-expiring · server-shutdown)
// large-file — 올린 대용량 파일의 위험 판정 (11단계 — 계약 4절 "악성 검사")
import type { ScanStatus } from '@/shared/types/large-file'
import type { Address } from '@/shared/types/mail'
import type { SseEvent } from '@/shared/utils/sse-parser'
@@ -28,11 +30,20 @@ export interface NewMailSignal {
messages: NewMailItem[]
}
/** 올린 대용량 파일의 검사 판정 — 지금은 위험(infected) 때만 온다 */
export interface LargeFileSignal {
fileId: string
/** 파일 이름 — 알림에 쓴다 */
name: string
scanStatus: ScanStatus
}
export type MailServerEvent =
| ({ type: 'state' } & MailStateSignal)
| ({ type: 'new-mail' } & NewMailSignal)
| { type: 'ping'; upstream: boolean }
| { type: 'end'; reason: string }
| ({ type: 'large-file' } & LargeFileSignal)
type JsonObject = Record<string, unknown>
@@ -94,6 +105,25 @@ function endOf(data: string): MailServerEvent {
return { type: 'end', reason: typeof reason === 'string' ? reason : '' }
}
const SCAN_STATUSES: readonly string[] = ['pending', 'clean', 'infected', 'unscannable']
function isScanStatus(value: unknown): value is ScanStatus {
return typeof value === 'string' && SCAN_STATUSES.includes(value)
}
/** 대용량 파일 판정 — 파일 id · 판정이 없으면 버린다 (이름이 없으면 빈 글) */
export function largeFileSignalOf(value: unknown): LargeFileSignal | null {
if (typeof value !== 'object' || value === null) return null
const body = value as JsonObject
if (typeof body.fileId !== 'string' || body.fileId === '' || !isScanStatus(body.scanStatus)) return null
return { fileId: body.fileId, name: typeof body.name === 'string' ? body.name : '', scanStatus: body.scanStatus }
}
function largeFileOf(data: string): MailServerEvent | null {
const signal = largeFileSignalOf(parseObject(data))
return signal ? { type: 'large-file', ...signal } : null
}
export function parseMailEvent(event: SseEvent): MailServerEvent | null {
switch (event.event) {
case 'state':
@@ -104,6 +134,8 @@ export function parseMailEvent(event: SseEvent): MailServerEvent | null {
return pingOf(event.data)
case 'end':
return endOf(event.data)
case 'large-file':
return largeFileOf(event.data)
default:
return null
}
@@ -232,3 +232,49 @@ describe('외부 이미지 보기 — 중계 주소의 그림만 보일 때 남
expect(mailFrameCspImagesShown('javascript:alert(1)')).toBe(fallback)
})
})
describe('대용량 첨부 덩이 접기 (11단계 — 디자인 원본 MailReadLargeFiles · 계약 4절 "우리 웹메일에서 읽기")', () => {
const BLOCK =
'<table data-ara-large-files=""><tbody><tr><td>대용량 첨부 2개 (합계 1.8 GB)</td></tr>' +
'<tr><td>내려받기 <a href="https://files.arasystem.it.kr/s/abc">https://files.arasystem.it.kr/s/abc</a></td></tr></tbody></table>'
const BODY = `<div><p>서준님, 자료입니다.</p>${BLOCK}<blockquote>원문</blockquote></div>`
function bodyOf(doc: string): HTMLElement {
return new DOMParser().parseFromString(doc, 'text/html').body
}
it('서버가 표시한 덩이를 details · summary 로 접는다 — 표시 속성은 남지 않는다, 스크립트 없이 열고 닫는다', () => {
const body = bodyOf(buildMailDocument({ htmlBody: BODY, textBody: null, foldLargeFiles: true }))
const details = body.querySelector('details.ara-large-files')
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('table a')?.getAttribute('href')).toBe('https://files.arasystem.it.kr/s/abc')
expect(body.innerHTML).not.toContain('data-ara-large-files')
// 덩이는 제자리 — 내 글 아래, 인용 위
expect(body.querySelector('div > p + details + blockquote')).not.toBeNull()
})
it('접지 않으면(인쇄 화면 · 대용량 첨부가 없는 메일) 덩이는 그냥 본문이다', () => {
const body = bodyOf(buildMailDocument({ htmlBody: BODY, textBody: null }))
expect(body.querySelector('details')).toBeNull()
expect(body.querySelector('table a')).not.toBeNull()
})
it('표시가 없는 덩이(바깥이 흉내 낸 것 · 인용 속 원래 덩이)는 접지 않는다', () => {
const body = bodyOf(
buildMailDocument({ htmlBody: BODY.replace(' data-ara-large-files=""', ''), textBody: null, foldLargeFiles: true }),
)
expect(body.querySelector('details')).toBeNull()
})
it('접는 줄의 모양은 문서 안 style 이 정한다 — 접힌 동안 「펼치기」, 펼치면 「접기」', () => {
const doc = buildMailDocument({ htmlBody: BODY, textBody: null, foldLargeFiles: true })
expect(doc).toContain('details[open] .ara-large-files__hint,details[open] .ara-large-files__open,details:not([open]) .ara-large-files__close{display:none}')
})
})
+64 -2
View File
@@ -9,6 +9,7 @@
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
import { MESSAGE_LARGE_FILE_TEXT } from '@/shared/constants/large-file'
import { MAIL_IMAGE_PROXY_PATH } from '@/shared/constants/mail'
/**
@@ -165,8 +166,63 @@ const MAIL_FRAME_STYLE = [
'pre{white-space:pre-wrap}',
'blockquote{margin:0 0 0 0.25rem;padding-left:0.75rem;border-left:0.125rem solid #98A2B3;color:#4A5363}',
'.mail-text{white-space:pre-wrap}',
// 대용량 첨부 덩이를 접는 줄 (11단계 — 디자인 원본 MailReadLargeFiles: 높이 40 · 12.5px · 바탕 #F6F7F9 · 펼치기 #0A5F67).
// 스크립트 없이 details · summary 로 열고 닫는다 — 접힌 동안은 "(접어 둠) — …" 와 「펼치기」, 펼치면 「접기」
'details.ara-large-files{margin:0.875rem 0}',
'summary.ara-large-files__summary{display:flex;align-items:center;gap:0.5rem;min-height:2.5rem;padding:0 0.75rem 0 0.625rem;border-radius:0.375rem;background:#F6F7F9;color:#4A5363;font-size:0.78125rem;line-height:1.4;cursor:pointer;list-style:none}',
'summary.ara-large-files__summary::-webkit-details-marker{display:none}',
"summary.ara-large-files__summary::before{content:'';flex:0 0 auto;width:0.375rem;height:0.375rem;margin:0 0.25rem 0 0.125rem;border-right:0.09375rem solid currentColor;border-bottom:0.09375rem solid currentColor;transform:rotate(-45deg)}",
'details[open]>summary.ara-large-files__summary::before{transform:rotate(45deg)}',
'summary.ara-large-files__summary:hover{background:#EEF0F3}',
'summary.ara-large-files__summary:focus-visible{outline:0.125rem solid #0E7C86;outline-offset:0.125rem}',
'details[open]>summary.ara-large-files__summary{margin-bottom:0.5rem}',
'.ara-large-files__hint{flex:1 1 auto}',
'.ara-large-files__open,.ara-large-files__close{flex:0 0 auto;margin-left:auto;color:#0A5F67;font-weight:600}',
'details[open] .ara-large-files__hint,details[open] .ara-large-files__open,details:not([open]) .ara-large-files__close{display:none}',
].join('')
/**
* 서버가 대용량 첨부 덩이의 바깥 요소에 붙이는 표시 (11단계 — 계약 4절 "우리 웹메일에서 읽기").
* 받은 메일의 data-* 는 서버 정화가 지우므로 서버만 붙일 수 있다 — 링크 주인이 그 메일의 보낸 사람일 때만
*/
export const LARGE_FILE_BLOCK_ATTRIBUTE = 'data-ara-large-files'
/** 접는 줄의 class 앞부분 — 위 MAIL_FRAME_STYLE 이 꾸민다 */
const LARGE_FILE_FOLD_CLASS = 'ara-large-files'
/**
* 대용량 첨부 덩이를 접는다 — 첫 덩이(한 통에 하나)를 details · summary 로 감싼다. 덩이가 없으면 그대로.
* 정화 전에, 화면과 떨어진 문서(<template> — 처리기가 돌지 않는다)에서 한다: 클라이언트 정화가 data-* 를 지우기 때문이다.
* 표시 속성은 여기서 뗀다. 정화(DOMPurify)는 details · summary · class 를 남긴다
*/
export function foldLargeFileBlock(html: string): string {
const template = document.createElement('template')
template.innerHTML = html
const root = template.content
const block = root.querySelector(`[${LARGE_FILE_BLOCK_ATTRIBUTE}]`)
if (!block) return html
const inert = root.ownerDocument ?? document.implementation.createHTMLDocument('')
const details = inert.createElement('details')
details.className = LARGE_FILE_FOLD_CLASS
const summary = inert.createElement('summary')
summary.className = `${LARGE_FILE_FOLD_CLASS}__summary`
const parts: readonly (readonly [string, string])[] = [
['label', MESSAGE_LARGE_FILE_TEXT.foldLabel],
['hint', MESSAGE_LARGE_FILE_TEXT.foldHint],
['open', MESSAGE_LARGE_FILE_TEXT.foldOpen],
['close', MESSAGE_LARGE_FILE_TEXT.foldClose],
]
for (const [name, text] of parts) {
const span = inert.createElement('span')
span.className = `${LARGE_FILE_FOLD_CLASS}__${name}`
span.textContent = text
summary.append(span)
}
block.replaceWith(details)
block.removeAttribute(LARGE_FILE_BLOCK_ATTRIBUTE)
details.append(summary, block)
return template.innerHTML
}
export interface MailBodySource {
htmlBody: string | null
textBody: string | null
@@ -174,6 +230,10 @@ export interface MailBodySource {
imagesShown?: boolean
/** 중계 주소를 여는 CSP 의 출처 — 주지 않으면 이 화면의 출처(location.origin). 그림이 그 출처의 /api/mail-images/ 로 풀린다 */
origin?: string
/**
* 대용량 첨부 덩이를 접는다 (11단계 — 읽기 화면이 첨부 칸에 그 파일을 보일 때). 인쇄 화면은 접지 않는다(종이에는 주소가 있어야 한다)
*/
foldLargeFiles?: boolean
}
/** 이 화면의 출처 — 브라우저가 아니면 빈 글 (CSP 는 'self' 로 연다 — mailFrameCspImagesShown) */
@@ -195,11 +255,13 @@ export function displayedHtmlBody(htmlBody: string | null | undefined): string |
* 본문 → iframe srcdoc 문서.
* HTML 이 있으면 정화해서, 없으면(공백뿐 포함 — displayedHtmlBody) 텍스트를 이스케이프해서 넣는다.
* <base target="_blank"> 로 남은 링크도 모두 새 창으로 연다.
* 외부 이미지를 보이는 본문(imagesShown)이면 CSP 의 그림만 우리 서버의 중계 경로를 열고 중계 주소의 그림을 남긴다 — 그 밖은 늘 같다
* 외부 이미지를 보이는 본문(imagesShown)이면 CSP 의 그림만 우리 서버의 중계 경로를 열고 중계 주소의 그림을 남긴다 — 그 밖은 늘 같다.
* 대용량 첨부 덩이는 foldLargeFiles 일 때 접는다 (foldLargeFileBlock)
*/
export function buildMailDocument(source: MailBodySource): string {
const imagesShown = source.imagesShown === true
const html = displayedHtmlBody(source.htmlBody)
const displayed = displayedHtmlBody(source.htmlBody)
const html = displayed !== null && source.foldLargeFiles === true ? foldLargeFileBlock(displayed) : displayed
const content =
html !== null
? sanitizeMailHtml(html.trim(), { imagesShown })
@@ -74,6 +74,7 @@ describe('탭끼리 주고받는 메시지', () => {
{ type: 'visibility-query', id: 'q1' },
{ type: 'visibility-reply', id: 'q1' },
{ type: 'attended' },
{ type: 'large-file', fileId: 'f1', name: '견적서.zip', scanStatus: 'infected' },
] as const
for (const message of messages) {
@@ -97,6 +98,7 @@ describe('탭끼리 주고받는 메시지', () => {
expect(unpackRealtimeMessage({ ...packed, message: { type: 'visibility-reply' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'baseline' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'new-mail' } }, EMAIL)).toBeNull()
expect(unpackRealtimeMessage({ ...packed, message: { type: 'large-file', name: 'x', scanStatus: 'infected' } }, EMAIL)).toBeNull()
})
it('state 의 안 읽은 수가 이상하면 null 로 읽는다', () => {
@@ -9,6 +9,7 @@ import {
MAIL_NOTIFY_TEXT,
} from '@/shared/constants/mail-notify'
import { normalizeEmail } from '@/shared/utils/mail-address'
import { largeFileSignalOf, type LargeFileSignal } from '@/shared/utils/mail-events'
/**
* 이 탭이 보는 실시간 연결 상태
@@ -83,6 +84,8 @@ export function mailEventsLockName(email: string): string {
// 연결한 탭 → baseline: 끊긴 채일 때의 마지막 state — 살아 있다는 신호로 세지 않고 기준값으로만 쓴다
// 연결한 탭 → 모든 탭: visibility-query (사용자가 보고 있는 탭이 있는가 — 바탕화면 알림 전에) / 보고 있는 탭 → visibility-reply
// 사용자가 보게 된 탭 → 연결한 탭: attended (띄운 바탕화면 알림을 닫는다)
// 연결한 탭 → 모든 탭: large-file (올린 대용량 파일의 위험 판정 — 11단계. 탭마다 알림을 띄우고 쓰기 화면의 그 파일을 "위험"으로)
// 새 종류를 더한 것이라 판(MESSAGE_VERSION)은 그대로다 — 옛 판의 탭은 모르는 메시지를 버린다
export type RealtimeMessage =
| { type: 'ping'; upstream: boolean }
@@ -93,6 +96,7 @@ export type RealtimeMessage =
| { type: 'visibility-query'; id: string }
| { type: 'visibility-reply'; id: string }
| { type: 'attended' }
| ({ type: 'large-file' } & LargeFileSignal)
const MESSAGE_APP = 'arasystem.mail-events'
/** 메시지 모양을 바꾸면 올린다 — 배포 전에 연 탭(옛 판)의 메시지를 새 판이 잘못 읽지 않게 */
@@ -135,6 +139,10 @@ function messageOf(value: unknown): RealtimeMessage | null {
case 'visibility-query':
case 'visibility-reply':
return typeof value.id === 'string' && value.id !== '' ? { type: value.type, id: value.id } : null
case 'large-file': {
const signal = largeFileSignalOf(value)
return signal ? { type: 'large-file', ...signal } : null
}
default:
return null
}
@@ -12,12 +12,14 @@ import {
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID, STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import { IMAGE_SAMPLES, QUOTE_MARK_SAMPLES, SAMPLE_IMAGE_ID, STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import {
QUOTE_START_MARK,
acceptStyleValue,
cellSpanOf,
imageAltOf,
imageWidthOf,
isOutgoingDataAttribute,
isOutgoingImageAttribute,
isOutgoingStyleValue,
lengthToPx,
@@ -25,6 +27,7 @@ import {
normalizeSignatureImageSrc,
signatureImageIdOf,
signatureImageSrc,
withoutQuoteStartMark,
} from './outgoing-html-rules'
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
@@ -155,3 +158,17 @@ describe('그림 — 서명 그림만 남는다 (계약 2절 "서식 본문"의
expect(imageAltOf(undefined)).toBeNull()
})
})
describe('인용 경계 표시 — div 의 data-ara-quote-start 만 남는다 (계약 4절, 서버와 같은 표본)', () => {
it.each(QUOTE_MARK_SAMPLES)('<%s %s> — 남는가: %s', (tag, attribute, kept) => {
expect(isOutgoingDataAttribute(tag, attribute)).toBe(kept)
})
it('대용량 파일이 없는 메일은 표시를 뺀다 — 직렬화가 둔 모양 그대로 하나만', () => {
const html = `<div style="font-size: 14px"><p style="margin: 0">글</p>${QUOTE_START_MARK}<blockquote>인용</blockquote></div>`
expect(withoutQuoteStartMark(html)).toBe(
'<div style="font-size: 14px"><p style="margin: 0">글</p><blockquote>인용</blockquote></div>',
)
expect(withoutQuoteStartMark('<p>표시 없음</p>')).toBe('<p>표시 없음</p>')
})
})
@@ -194,6 +194,30 @@ export function cellSpanOf(value: unknown): number {
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
}
// ── 인용 경계 표시 (11단계 — 계약 4절 "발송 · 임시 저장과 함께") ─────────────
// 쓰기 화면은 원문 인용 바로 앞에 빈 표시 <div data-ara-quote-start></div> 하나를 둔다 — 서버가 대용량 첨부 덩이를 넣을 자리다.
// 보내는 정화는 이 속성을 div 에만 남기고 그 밖의 data-* 는 지금처럼 지운다. 서버는 첫 표시 자리를 쓴 뒤 표시를 모두 지운다
// (받는 사람에게 가지 않고, 임시 저장본 · 지문에도 들지 않는다). 대용량 파일이 없으면 쓰기 화면이 표시를 보내지 않는다.
// ⚠️ 서버 정화(backend/src/modules/mail/html/sanitize-outgoing-html.ts)와 같은 규칙이다 — 같은 표본 표를 양쪽 테스트에 둔다
// (test-utils/outgoing-style-samples.ts 의 QUOTE_MARK_SAMPLES).
/** 인용 경계 표시 속성 */
export const QUOTE_START_ATTRIBUTE = 'data-ara-quote-start'
/** 인용 경계 표시 — 직렬화가 늘 이 모양으로 쓴다 (빈 속성 값은 ="" 로 적힌다) */
export const QUOTE_START_MARK = `<div ${QUOTE_START_ATTRIBUTE}=""></div>`
/** 이 표시가 남는 태그 */
const QUOTE_START_TAG = 'div'
/** 인용 경계 표시를 뺀 서식 본문 — 대용량 파일이 없는 메일은 표시를 보내지 않는다 (직렬화가 둔 그 모양 하나만 지운다) */
export function withoutQuoteStartMark(html: string): string {
return html.replace(QUOTE_START_MARK, '')
}
/** 보내는 정화가 남기는 data-* 속성인가 — 인용 경계 표시가 div 에 있을 때뿐이다 (태그 · 속성 이름은 대소문자를 가리지 않는다) */
export function isOutgoingDataAttribute(tag: string, name: string): boolean {
return tag.toLowerCase() === QUOTE_START_TAG && name.toLowerCase() === QUOTE_START_ATTRIBUTE
}
// ── 그림 (계약 2절 "서식 본문" — 보내는 사람 자신의 서명 그림만 남는다) ─────────────
// <img src="/api/mail-signature/images/{id}" width="144" alt=""> 의 src · width · alt 만 남고, 형식이 틀린 width · alt 는 그 속성만 지운다.
// src 가 서명 그림 주소가 아니면(외부 주소 · data: · 쿼리 · 다른 경로) 그림째 지운다.
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { filesByShareUrl, quotedLinkNotices, shareUrlOf, shareUrlsIn } from './share-links'
const TOKEN = 'Qx7mK2vR9pL4wT8nB3cF6hJ1dS5gA0eY2uI7oP9kZ3x'
const URL = `https://files.arasystem.it.kr/s/${TOKEN}`
const OTHER = `https://files.arasystem.it.kr/s/${'A'.repeat(43)}`
describe('공개 화면 주소 고르기 (계약 4절 "인용 속 링크의 상태" — 경로가 /s/ + 43자)', () => {
it('맞는 주소는 출처 + 경로로 맞춘다 — 쿼리 · 조각 · 끝 문장 부호는 뗀다', () => {
expect(shareUrlOf(URL)).toBe(URL)
expect(shareUrlOf(`${URL}?utm=x#top`)).toBe(URL)
expect(shareUrlOf(`${URL}.`)).toBe(URL)
})
it('모양이 아니면 null — 토큰 길이 · 글자 · 경로 · 스킴', () => {
expect(shareUrlOf(`https://files.arasystem.it.kr/s/${TOKEN.slice(1)}`)).toBeNull()
expect(shareUrlOf(`https://files.arasystem.it.kr/s/${TOKEN}x`)).toBeNull()
expect(shareUrlOf(`https://files.arasystem.it.kr/x/${TOKEN}`)).toBeNull()
expect(shareUrlOf(`ftp://files.arasystem.it.kr/s/${TOKEN}`)).toBeNull()
expect(shareUrlOf('https://files.arasystem.it.kr/s/Qx7mK2…')).toBeNull()
})
it('원문 HTML · 글자에서 찾는다 — 처음 나온 차례, 겹치지 않게', () => {
const html = `<table><tr><td>내려받기 <a href="${URL}">${URL}</a></td></tr></table><p><a href="${OTHER}">링크</a></p>`
expect(shareUrlsIn(html, `내려받기: ${URL}`)).toEqual([URL, OTHER])
expect(shareUrlsIn(null, undefined, '')).toEqual([])
})
it('20개까지만 묻는다', () => {
const many = Array.from({ length: 25 }, (_, index) => `https://files.example/s/${String(index).padStart(43, 'a')}`)
expect(shareUrlsIn(many.join(' '))).toHaveLength(20)
})
})
describe('인용 속 링크의 상태 → 칸 아래 글 (디자인 README "11단계 › 전달 쓰기 화면")', () => {
const names = filesByShareUrl([
{ url: URL, name: '설계도_v3.dwg' },
{ url: URL, name: '현장영상_1029.mp4' },
])
it('받을 수 있는 링크 — 원래 메일의 파일 수와 끝나는 날(한국)', () => {
expect(quotedLinkNotices([{ url: URL, status: 'active', expiresAt: '2026-11-29T14:59:59.999Z' }], names)).toEqual([
{ 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일까지',
)
})
it('끝난 링크는 갈색 — 파일이 여럿이면 "‘첫 파일’ 외 N개", 모르면 "대용량 첨부"', () => {
const notices = quotedLinkNotices(
[
{ url: URL, status: 'revoked', expiresAt: '2026-11-29T14:59:59.999Z' },
{ url: OTHER, status: 'expired', expiresAt: '2026-10-01T14:59:59.999Z' },
],
names,
)
expect(notices).toEqual([
{ key: URL, tone: 'warning', text: '인용 속 ‘설계도_v3.dwg’ 외 1개 링크는 공유가 멈춰 받을 수 없습니다.' },
{ key: OTHER, tone: 'warning', text: '인용 속 대용량 첨부 링크는 기간이 지나 받을 수 없습니다.' },
])
})
it('모르는 링크(unknown)는 알리지 않는다', () => {
expect(quotedLinkNotices([{ url: URL, status: 'unknown', expiresAt: null }], names)).toEqual([])
})
})
+105
View File
@@ -0,0 +1,105 @@
// 인용 속 대용량 첨부 링크 — 순수 함수만 둔다 (계약 4절 "인용 속 링크의 상태" · 디자인 README "11단계 › 전달 쓰기 화면")
// 답장 · 전달 쓰기 화면이 원문(인용)에서 공개 화면 주소를 찾아 상태를 묻고(POST /share-link-lookups), 첨부 칸 아래 글자로만 알린다.
// 링크를 다시 싣거나 바꾸지 않는다 — 원래 링크는 인용 속 글로 그대로 간다.
import {
QUOTED_LINK_TEXT,
SHARE_LOOKUP_MAX_URLS,
SHARE_LOOKUP_MAX_URL_LENGTH,
SHARE_PATH_PATTERN,
} from '@/shared/constants/large-file'
import type { MessageLargeFile, ShareLinkState } from '@/shared/types/large-file'
import { formatCount } from '@/shared/utils/format'
import { formatKstMonthDay } from '@/shared/utils/kst-date'
/** 글 속 주소 후보 — 따옴표 · 꺾쇠 · 괄호 · 공백에서 끝난다 (HTML 의 href 값 · 보이는 주소 글 모두) */
const URL_CANDIDATE = /https?:\/\/[^\s"'<>()]+/gi
/** 글 속 주소 뒤에 붙은 문장 부호 — 주소가 아니다 */
const TRAILING_PUNCTUATION = /[.,;:!?]+$/
/**
* 공개 화면 주소면 출처 + 경로로 맞춘 주소(쿼리 · 조각은 뗀다), 아니면 null.
* 경로가 /s/ + 43자([A-Za-z0-9_-])여야 한다 — 서버는 자기 주소(SHARE_PUBLIC_URL) 모양이 아니면 unknown 으로 답한다
*/
export function shareUrlOf(candidate: string): string | null {
let url: URL
try {
url = new URL(candidate.trim().replace(TRAILING_PUNCTUATION, ''))
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
if (!SHARE_PATH_PATTERN.test(url.pathname)) return null
const normalized = `${url.origin}${url.pathname}`
return normalized.length <= SHARE_LOOKUP_MAX_URL_LENGTH ? normalized : null
}
/** 원문(HTML · 글자)에서 공개 화면 주소를 찾는다 — 처음 나온 차례 · 겹치지 않게 · 20개까지 (묻는 한도) */
export function shareUrlsIn(...sources: readonly (string | null | undefined)[]): string[] {
const found: string[] = []
for (const source of sources) {
if (!source) continue
for (const match of source.matchAll(URL_CANDIDATE)) {
const url = shareUrlOf(match[0])
if (url === null || found.includes(url)) continue
found.push(url)
if (found.length >= SHARE_LOOKUP_MAX_URLS) return found
}
}
return found
}
/** 원래 메일이 알려 준 대용량 첨부를 링크 주소로 묶는다 — 끝난 링크의 글에 파일 이름을 쓴다 */
export function filesByShareUrl(files: readonly Pick<MessageLargeFile, 'url' | 'name'>[]): Map<string, string[]> {
const byUrl = new Map<string, string[]>()
for (const file of files) {
const url = shareUrlOf(file.url)
if (url === null) continue
byUrl.set(url, [...(byUrl.get(url) ?? []), file.name])
}
return byUrl
}
/** 첨부 칸 아래 한 줄 — info(ⓘ 회색) · warning(갈색, 보내기는 막지 않는다) */
export interface QuotedLinkNotice {
key: string
tone: 'info' | 'warning'
text: string
}
function nameLabel(names: readonly string[] | undefined): string {
const [first] = names ?? []
if (first === undefined) return QUOTED_LINK_TEXT.unnamedLabel
const others = (names?.length ?? 1) - 1
return others > 0 ? QUOTED_LINK_TEXT.labelWithOthers(first, formatCount(others)) : QUOTED_LINK_TEXT.label(first)
}
/**
* 링크 상태 → 칸 아래 글. 받을 수 있는 링크는 파일 수 · 끝나는 날(한국), 끝난 링크는 파일 이름과 까닭.
* 모르는 링크(unknown — 우리 링크가 아니거나 기록을 지움)는 알리지 않는다
*/
export function quotedLinkNotices(
states: readonly ShareLinkState[],
namesByUrl: ReadonlyMap<string, readonly string[]>,
): QuotedLinkNotice[] {
const notices: QuotedLinkNotice[] = []
for (const state of states) {
const url = shareUrlOf(state.url) ?? state.url
const names = namesByUrl.get(url)
if (state.status === 'active' && state.expiresAt) {
const until = formatKstMonthDay(state.expiresAt)
notices.push({
key: url,
tone: 'info',
text: names?.length
? QUOTED_LINK_TEXT.active(formatCount(names.length), until)
: QUOTED_LINK_TEXT.activeUnknownCount(until),
})
} else if (state.status === 'revoked') {
notices.push({ key: url, tone: 'warning', text: QUOTED_LINK_TEXT.revoked(nameLabel(names)) })
} else if (state.status === 'expired') {
notices.push({ key: url, tone: 'warning', text: QUOTED_LINK_TEXT.expired(nameLabel(names)) })
}
}
return notices
}
+232
View File
@@ -0,0 +1,232 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { toApiError, type ApiError } from '@/composables/useApi'
import { useLargeFiles } from '@/composables/useLargeFiles'
import { LARGE_FILE_FALLBACK_LIMITS } from '@/shared/constants/large-file'
import type { FileQuota, LargeFile, ShareLink, SharedFileStatus } from '@/shared/types/large-file'
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 대용량 첨부 스토어 — 계약 4절 "보낸 사람 관리" (11단계)
// - 보관 사용량 · 한도(GET /file-quota): 쓰기 화면이 열 때마다 새로 받는다(loadQuota — 못 받으면 대체값으로 검사한다, 알림 없음).
// - 설정 › 메일 › 대용량 첨부: 들어올 때마다 보내지 않은 파일 · 보낸 링크 · 사용량을 새로 받는다(loadSettings). 오류는 화면이 직접 그린다.
// 보내지 않은 파일은 직원마다 50개까지라 한 번에 다 받는다(UNSENT_PAGE_SIZE) — 그래야 사용량을 "보낸 링크 · 보내지 않은 파일"로 나눌 수 있다.
// 보낸 링크는 20개씩 [더 보기].
// - 링크 끊기 · 보내지 않은 파일 지우기는 응답을 받은 뒤 목록 · 사용량을 고친다 (실패는 인터셉터가 알린다).
// - 실시간 알림의 위험 판정(noteInfected)은 받아 둔 목록의 상태를 바꾸고, 쓰기 화면이 그 파일을 "위험"으로 바꾸도록 신호를 남긴다(infectedSignal).
// - 세션이 바뀌면(로그아웃 · 다른 계정) 지운다 — 늦게 온 응답은 순번(seq)으로 버린다
/** 보내지 않은 파일을 한 번에 받는 수 — 직원마다 50개까지(계약 4절 "한도")라 목록 한도(100) 안에서 한 번에 다 받는다 */
const UNSENT_PAGE_SIZE = 100
/** 실시간 알림의 위험 판정 — 쓰기 화면이 지켜본다 (seq 가 오르면 새 신호) */
export interface LargeFileInfectedSignal {
fileId: string
name: string
seq: number
}
export const useLargeFilesStore = defineStore('large-files', () => {
const api = useLargeFiles()
const authStore = useAuthStore()
// ── 사용량 · 한도 ─────────────
const quota = shallowRef<FileQuota | null>(null)
let quotaSeq = 0
/** 한도 — 받은 값, 못 받았으면 서버 기본값 */
const limits = computed(() => ({
maxFileBytes: quota.value?.maxFileBytes ?? LARGE_FILE_FALLBACK_LIMITS.maxFileBytes,
maxFilesPerMessage: quota.value?.maxFilesPerMessage ?? LARGE_FILE_FALLBACK_LIMITS.maxFilesPerMessage,
limitBytes: quota.value?.limitBytes ?? LARGE_FILE_FALLBACK_LIMITS.limitBytes,
retentionDays: quota.value?.retentionDays ?? LARGE_FILE_FALLBACK_LIMITS.retentionDays,
usedBytes: quota.value?.usedBytes ?? 0,
}))
/** 사용량을 새로 받는다 — 실패하면 알리지 않고 지난 값(없으면 대체값)을 둔다 */
async function loadQuota(): Promise<FileQuota | null> {
quotaSeq += 1
const current = quotaSeq
try {
const result = await api.getFileQuota({ silent: true })
if (current === quotaSeq) quota.value = result
return result
} catch {
return null
}
}
// ── 설정 화면 — 보내지 않은 파일 · 보낸 링크 ─────────────
const unsent = ref<LargeFile[]>([])
const links = ref<ShareLink[]>([])
const linksPageInfo = shallowRef<PageInfo | null>(null)
const unsentComplete = ref<boolean>(false)
const status = ref<LoadStatus>('idle')
const error = ref<ApiError | null>(null)
const loadingMore = ref<boolean>(false)
let settingsSeq = 0
/** 설정 화면에 들어올 때 — 셋을 함께 받는다. 하나라도 못 받으면 오류 상태(화면이 "다시 시도") */
async function loadSettings(): Promise<void> {
settingsSeq += 1
const current = settingsSeq
status.value = 'loading'
error.value = null
try {
const [unsentPage, linkPage] = await Promise.all([
api.listUploads({ limit: UNSENT_PAGE_SIZE }, { silent: true }),
api.listShareLinks({ limit: DEFAULT_PAGE_SIZE }, { silent: true }),
loadQuota(),
])
if (current !== settingsSeq) return
unsent.value = unsentPage.items
unsentComplete.value = !unsentPage.pageInfo.hasNext
links.value = linkPage.items
linksPageInfo.value = linkPage.pageInfo
status.value = 'ready'
} catch (caught) {
if (current !== settingsSeq) return
status.value = 'error'
error.value = toApiError(caught)
}
}
/**
* 화면을 그대로 둔 채 목록 · 사용량을 다시 받는다 — 보내지 않은 파일 · 보낸 링크 첫 쪽(받아 둔 쪽은 버린다).
* 실패하면 알리지 않고 받아 둔 목록을 둔다 (지우기가 409 FILE_003 일 때 — 그 파일이 그사이 보낸 메일의 링크에 실렸다)
*/
async function refreshLists(): Promise<void> {
settingsSeq += 1
const current = settingsSeq
// 진행 중이던 [더 보기]는 버린다 — 첫 쪽부터 다시 받는다
loadingMore.value = false
try {
const [unsentPage, linkPage] = await Promise.all([
api.listUploads({ limit: UNSENT_PAGE_SIZE }, { silent: true }),
api.listShareLinks({ limit: DEFAULT_PAGE_SIZE }, { silent: true }),
loadQuota(),
])
if (current !== settingsSeq) return
unsent.value = unsentPage.items
unsentComplete.value = !unsentPage.pageInfo.hasNext
links.value = linkPage.items
linksPageInfo.value = linkPage.pageInfo
} catch {
// 받아 둔 목록을 둔다 — 다음에 들어올 때 새로 받는다
}
}
/** 보낸 링크 [더 보기] — 실패는 인터셉터가 알린다 */
async function loadMoreLinks(): Promise<void> {
const cursor = linksPageInfo.value?.nextCursor
if (loadingMore.value || !cursor) return
const current = settingsSeq
loadingMore.value = true
try {
const page = await api.listShareLinks({ cursor, limit: DEFAULT_PAGE_SIZE })
if (current !== settingsSeq) return
const known = new Set(links.value.map((link) => link.id))
links.value = [...links.value, ...page.items.filter((link) => !known.has(link.id))]
linksPageInfo.value = page.pageInfo
} catch {
// 인터셉터가 알렸다 — 받아 둔 목록은 그대로
} finally {
if (current === settingsSeq) loadingMore.value = false
}
}
/** 보낸 링크 수 — 서버의 total, 모르면 받아 둔 수 */
const linksTotal = computed(() => linksPageInfo.value?.total ?? links.value.length)
/**
* 링크 끊기 — 그 링크(메일)의 파일 모두. 받은 링크로 목록의 그 덩이를 바꾸고 사용량을 다시 받는다 (끊은 파일은 보관량에서 바로 빠진다).
* 실패는 예외가 그대로 올라간다(알림은 인터셉터)
*/
async function revokeLink(linkId: string): Promise<ShareLink> {
const session = authStore.generation
const revoked = await api.revokeShareLink(linkId)
if (authStore.generation !== session) return revoked
links.value = links.value.map((link) => (link.id === revoked.id ? revoked : link))
void loadQuota()
return revoked
}
/**
* 보내지 않은 파일 지우기 — 지우면 목록에서 빼고 사용량을 다시 받는다. 이미 없으면(404) 지운 것과 같다.
* 그 밖의 실패는 예외가 그대로 올라간다(알림은 인터셉터). 살아 있는 링크에 실린 파일(409 FILE_003 — 그사이 그 파일을 실은 메일을 보냈다)이면
* 목록을 다시 받은 뒤 올린다 — 그 파일은 보내지 않은 파일에서 빠지고 보낸 링크로 간다
*/
async function deleteUnsent(fileId: string): Promise<void> {
const session = authStore.generation
try {
await api.deleteUpload(fileId, { silent: ['RES_001'] })
} catch (caught) {
const { code } = toApiError(caught)
if (code === 'FILE_003' && authStore.generation === session) await refreshLists()
if (code !== 'RES_001') throw caught
}
if (authStore.generation !== session) return
unsent.value = unsent.value.filter((file) => file.id !== fileId)
void loadQuota()
}
// ── 실시간 알림의 위험 판정 ─────────────
const infectedSignal = shallowRef<LargeFileInfectedSignal | null>(null)
let infectedSeq = 0
/** 위험 판정 — 받아 둔 목록의 그 파일을 "위험"으로 바꾸고 쓰기 화면에 신호를 남긴다 */
function noteInfected(fileId: string, name: string) {
infectedSeq += 1
infectedSignal.value = { fileId, name, seq: infectedSeq }
unsent.value = unsent.value.map((file) => (file.id === fileId ? { ...file, scanStatus: 'infected' } : file))
const infected: SharedFileStatus = 'infected'
links.value = links.value.map((link) =>
link.files.some((file) => file.id === fileId)
? { ...link, files: link.files.map((file) => (file.id === fileId ? { ...file, status: infected } : file)) }
: link,
)
}
function reset() {
quotaSeq += 1
settingsSeq += 1
quota.value = null
unsent.value = []
links.value = []
linksPageInfo.value = null
unsentComplete.value = false
status.value = 'idle'
error.value = null
loadingMore.value = false
infectedSignal.value = null
}
// 로그아웃 · 다른 계정 로그인 — 앞 사용자의 파일 · 링크가 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return {
quota,
limits,
loadQuota,
unsent,
unsentComplete,
links,
linksPageInfo,
linksTotal,
status,
error,
loadingMore,
loadSettings,
loadMoreLinks,
revokeLink,
deleteUnsent,
infectedSignal,
noteInfected,
reset,
}
})
+2
View File
@@ -157,6 +157,8 @@ export const useMailStore = defineStore('mail', () => {
closeMessage: opened.closeMessage,
markRead: message.markRead,
showImages: message.showImages,
applyLargeFileLink: message.applyLargeFileLink,
refreshOpenedLargeFiles: message.refreshOpenedLargeFiles,
toggleFlag: message.toggleFlag,
moveMessage: message.moveMessage,
// 예약됨 · 붙잡은 발송 (9단계)
+49 -1
View File
@@ -3,6 +3,7 @@
import { ref } from 'vue'
import { toApiError, type ApiError } from '@/composables/useApi'
import type { MessageLargeFile, ShareLink } from '@/shared/types/large-file'
import type { MessageDetail } from '@/shared/types/mail'
import type { ListState } from './list'
import type { MailboxesState } from './mailboxes'
@@ -134,6 +135,8 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
isHtmlBodyOmitted: detail.isHtmlBodyOmitted,
isBodyTruncated: detail.isBodyTruncated,
attachments: detail.attachments,
// 덩이 표시(data-ara-large-files)와 대용량 첨부는 함께 온다 — 새 본문의 것을 쓴다 (11단계)
largeFiles: detail.largeFiles,
}
return true
} catch {
@@ -183,5 +186,50 @@ export function createMessageActions({ mailApi, mailboxes, list, opened }: Messa
return true
}
return { markRead, openMessage, showImages, toggleFlag, moveMessage }
/**
* 링크를 끊은 결과를 열린 메일의 대용량 첨부에 옮긴다 (11단계 — 읽기 화면의 [링크 끊기]).
* 그 링크의 파일은 받은 링크의 상태 · 받은 수로 바꾼다 — 끊은 링크의 파일은 응답에서도 revoked 다
*/
function applyLargeFileLink(link: ShareLink): void {
const current = opened.selected.value
if (!current?.largeFiles?.some((file) => file.linkId === link.id)) return
const filesById = new Map(link.files.map((file) => [file.id, file]))
const largeFiles = current.largeFiles.map((file): MessageLargeFile => {
if (file.linkId !== link.id) return file
const shared = filesById.get(file.fileId)
const status = link.status === 'revoked' || link.status === 'expired' ? link.status : (shared?.status ?? file.status)
return {
...file,
status,
expiresAt: link.expiresAt,
revokedAt: link.revokedAt,
downloads:
file.downloads && shared
? { count: shared.downloadCount, lastDownloadedAt: shared.lastDownloadedAt }
: file.downloads,
}
})
opened.selected.value = { ...current, largeFiles }
}
/**
* 열린 메일의 대용량 첨부 상태를 다시 받는다 — 검사 중인 파일이 있을 때 새 메일 확인 · 실시간 소식에 맞춰 (11단계 "보안 검사").
* 대용량 첨부만 바꾼다 — 본문 · 읽음 표시는 그대로. 그사이 다른 메일을 열었으면 버린다. 실패는 알리지 않는다
*/
async function refreshOpenedLargeFiles(): Promise<void> {
const current = opened.selected.value
if (!current?.largeFiles?.some((file) => file.status === 'scanning')) return
const id = current.id
const seq = opened.currentDetailSeq()
try {
const detail = await mailApi.getMessage(id, { silent: true, showImages: current.imagesShown === true })
const latest = opened.selected.value
if (!opened.isCurrentDetail(seq) || latest?.id !== id || !detail.largeFiles) return
opened.selected.value = { ...latest, largeFiles: detail.largeFiles }
} catch {
// 다음 확인에서 다시 본다
}
}
return { markRead, openMessage, showImages, toggleFlag, moveMessage, applyLargeFileLink, refreshOpenedLargeFiles }
}
@@ -175,3 +175,22 @@ export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'al
['alt', 'ㄱ'.repeat(200), true],
['alt', 'ㄱ'.repeat(201), false],
]
/**
* 인용 경계 표시(data-*) 표본 — [태그, 속성, 서버가 남기는가]. 계약 4절 "발송 · 임시 저장과 함께" (11단계).
* 보내는 정화는 data-ara-quote-start 를 div 에만 남기고 그 밖의 data-* 는 지금처럼 지운다(서버는 첫 표시 자리를 덩이 자리로 쓴 뒤 표시를 모두 지운다).
* ⚠️ 서버 정화(backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다 — 한쪽을 고치면 둘 다.
*/
export const QUOTE_MARK_SAMPLES: readonly (readonly [tag: string, attribute: string, kept: boolean])[] = [
['div', 'data-ara-quote-start', true],
['DIV', 'DATA-ARA-QUOTE-START', true],
['p', 'data-ara-quote-start', false],
['span', 'data-ara-quote-start', false],
['table', 'data-ara-quote-start', false],
['blockquote', 'data-ara-quote-start', false],
['div', 'data-ara-large-files', false],
['div', 'data-mail-quote', false],
['div', 'data-quote-header', false],
['div', 'data-ara-quote-start-x', false],
['div', 'data-x', false],
]
+8
View File
@@ -81,6 +81,14 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 11단계 — 대용량 첨부 설정 · 공개 내려받기 화면 (router/routes.ts 와 같은 이름 · 모양)
{
path: '/settings/mail/large-files',
name: 'settings-mail-large-files',
component: Empty,
meta: { requiresAuth: true },
},
{ path: '/s/:token', name: 'public-share', component: Empty, meta: { public: true } },
{
path: '/settings/mail/notify',
name: 'settings-mail-notify',