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
@@ -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>